Skip to content

Commit 64f4c90

Browse files
Implement Stripe payment intents
1 parent ac13101 commit 64f4c90

7 files changed

Lines changed: 2495 additions & 2218 deletions

File tree

apps/api/package.json

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
1-
{
2-
"name": "@freelanceflow/api",
3-
"private": true,
4-
"type": "module",
5-
"scripts": {
6-
"dev": "node src/server.js",
7-
"start": "node src/server.js",
8-
"test": "node --test src/tests"
9-
},
10-
"dependencies": {
11-
"cors": "^2.8.5",
12-
"express": "^4.19.2",
13-
"express-rate-limit": "^7.4.0",
14-
"helmet": "^7.1.0",
15-
"jsonwebtoken": "^9.0.2",
16-
"multer": "^2.1.1",
17-
"zod": "^3.23.8"
18-
}
19-
}
1+
{
2+
"name": "@freelanceflow/api",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev": "node src/server.js",
7+
"start": "node src/server.js",
8+
"test": "node --test \"src/tests/**/*.test.js\""
9+
},
10+
"dependencies": {
11+
"cors": "^2.8.5",
12+
"express": "^4.19.2",
13+
"express-rate-limit": "^7.4.0",
14+
"helmet": "^7.1.0",
15+
"jsonwebtoken": "^9.0.2",
16+
"multer": "^2.1.1",
17+
"stripe": "^18.5.0",
18+
"zod": "^3.23.8"
19+
}
20+
}
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { ok } from "../utils/response.js";
2-
import { createPaymentIntent } from "../services/paymentService.js";
2+
import { createPaymentIntent, PaymentValidationError } from "../services/paymentService.js";
33

44
export async function createPayment(req, res) {
5-
return ok(res, await createPaymentIntent(req.body), 201);
5+
try {
6+
return ok(res, await createPaymentIntent(req.body), 201);
7+
} catch (error) {
8+
if (error instanceof PaymentValidationError) {
9+
return res.status(400).json({ error: error.message });
10+
}
11+
12+
return res.status(502).json({ error: error.message });
13+
}
614
}
Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,87 @@
1-
export async function createPaymentIntent(payload) {
2-
// TODO: integrate Stripe SDK and return client secret.
3-
return {
4-
paymentId: `pay_${Date.now()}`,
5-
amount: payload.amount,
6-
currency: payload.currency ?? "usd",
7-
provider: "stripe"
8-
};
1+
import Stripe from "stripe";
2+
3+
let stripeClient;
4+
5+
export class PaymentValidationError extends Error {
6+
constructor(message) {
7+
super(message);
8+
this.name = "PaymentValidationError";
9+
}
10+
}
11+
12+
function getStripeClient() {
13+
if (stripeClient) {
14+
return stripeClient;
15+
}
16+
17+
if (!process.env.STRIPE_SECRET_KEY) {
18+
throw new PaymentValidationError("STRIPE_SECRET_KEY is required");
19+
}
20+
21+
stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY);
22+
return stripeClient;
23+
}
24+
25+
export function setStripeClientForTests(client) {
26+
stripeClient = client;
27+
}
28+
29+
function validateAmount(amount) {
30+
if (!Number.isInteger(amount) || amount <= 0) {
31+
throw new PaymentValidationError("amount must be a positive integer in the smallest currency unit");
32+
}
33+
34+
return amount;
35+
}
36+
37+
function validateCurrency(currency) {
38+
if (currency === undefined) {
39+
return "usd";
40+
}
41+
42+
if (typeof currency !== "string" || !/^[a-z]{3}$/i.test(currency)) {
43+
throw new PaymentValidationError("currency must be a three-letter currency code");
44+
}
45+
46+
return currency.toLowerCase();
47+
}
48+
49+
function validateMetadata(metadata) {
50+
if (metadata === undefined) {
51+
return undefined;
52+
}
53+
54+
if (metadata === null || Array.isArray(metadata) || typeof metadata !== "object") {
55+
throw new PaymentValidationError("metadata must be an object when provided");
56+
}
57+
58+
return metadata;
59+
}
60+
61+
export async function createPaymentIntent(payload = {}) {
62+
const amount = validateAmount(payload.amount);
63+
const currency = validateCurrency(payload.currency);
64+
const metadata = validateMetadata(payload.metadata);
65+
66+
try {
67+
const paymentIntent = await getStripeClient().paymentIntents.create({
68+
amount,
69+
currency,
70+
...(metadata ? { metadata } : {})
71+
});
72+
73+
return {
74+
paymentId: paymentIntent.id,
75+
clientSecret: paymentIntent.client_secret,
76+
amount,
77+
currency,
78+
provider: "stripe"
79+
};
80+
} catch (error) {
81+
if (error instanceof PaymentValidationError) {
82+
throw error;
83+
}
84+
85+
throw new Error(error.message || "Stripe payment intent creation failed");
86+
}
987
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { createApp } from "../app.js";
4+
import { setStripeClientForTests } from "../services/paymentService.js";
5+
6+
async function withServer(callback) {
7+
const app = createApp();
8+
const server = app.listen(0);
9+
10+
await new Promise((resolve, reject) => {
11+
server.once("listening", resolve);
12+
server.once("error", reject);
13+
});
14+
15+
try {
16+
const { port } = server.address();
17+
await callback(`http://127.0.0.1:${port}`);
18+
} finally {
19+
await new Promise((resolve, reject) => {
20+
server.close((error) => (error ? reject(error) : resolve()));
21+
});
22+
}
23+
}
24+
25+
test("POST /api/payments returns Stripe client secret", async () => {
26+
setStripeClientForTests({
27+
paymentIntents: {
28+
async create() {
29+
return {
30+
id: "pi_route_123",
31+
client_secret: "pi_route_123_secret"
32+
};
33+
}
34+
}
35+
});
36+
37+
await withServer(async (baseUrl) => {
38+
const response = await fetch(`${baseUrl}/api/payments`, {
39+
method: "POST",
40+
headers: { "content-type": "application/json" },
41+
body: JSON.stringify({ amount: 1999 })
42+
});
43+
const payload = await response.json();
44+
45+
assert.equal(response.status, 201);
46+
assert.equal(payload.data.paymentId, "pi_route_123");
47+
assert.equal(payload.data.clientSecret, "pi_route_123_secret");
48+
});
49+
});
50+
51+
test("POST /api/payments returns 400 for invalid amount", async () => {
52+
await withServer(async (baseUrl) => {
53+
const response = await fetch(`${baseUrl}/api/payments`, {
54+
method: "POST",
55+
headers: { "content-type": "application/json" },
56+
body: JSON.stringify({ amount: -1 })
57+
});
58+
const payload = await response.json();
59+
60+
assert.equal(response.status, 400);
61+
assert.equal(payload.error, "amount must be a positive integer in the smallest currency unit");
62+
});
63+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import {
4+
createPaymentIntent,
5+
PaymentValidationError,
6+
setStripeClientForTests
7+
} from "../services/paymentService.js";
8+
9+
test("createPaymentIntent creates a Stripe payment intent", async () => {
10+
const calls = [];
11+
12+
setStripeClientForTests({
13+
paymentIntents: {
14+
async create(params) {
15+
calls.push(params);
16+
return {
17+
id: "pi_test_123",
18+
client_secret: "pi_test_123_secret_456"
19+
};
20+
}
21+
}
22+
});
23+
24+
const result = await createPaymentIntent({
25+
amount: 2500,
26+
currency: "USD",
27+
metadata: { orderId: "order_123" }
28+
});
29+
30+
assert.deepEqual(calls, [
31+
{
32+
amount: 2500,
33+
currency: "usd",
34+
metadata: { orderId: "order_123" }
35+
}
36+
]);
37+
assert.deepEqual(result, {
38+
paymentId: "pi_test_123",
39+
clientSecret: "pi_test_123_secret_456",
40+
amount: 2500,
41+
currency: "usd",
42+
provider: "stripe"
43+
});
44+
});
45+
46+
test("createPaymentIntent defaults currency to usd", async () => {
47+
const calls = [];
48+
49+
setStripeClientForTests({
50+
paymentIntents: {
51+
async create(params) {
52+
calls.push(params);
53+
return {
54+
id: "pi_test_default",
55+
client_secret: "pi_test_default_secret"
56+
};
57+
}
58+
}
59+
});
60+
61+
await createPaymentIntent({ amount: 1000 });
62+
63+
assert.equal(calls[0].currency, "usd");
64+
});
65+
66+
test("createPaymentIntent rejects invalid amounts", async () => {
67+
await assert.rejects(
68+
createPaymentIntent({ amount: 0 }),
69+
(error) =>
70+
error instanceof PaymentValidationError &&
71+
error.message === "amount must be a positive integer in the smallest currency unit"
72+
);
73+
});
74+
75+
test("createPaymentIntent preserves Stripe error messages", async () => {
76+
setStripeClientForTests({
77+
paymentIntents: {
78+
async create() {
79+
throw new Error("Your card was declined.");
80+
}
81+
}
82+
});
83+
84+
await assert.rejects(createPaymentIntent({ amount: 1000 }), /Your card was declined\./);
85+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { createPaymentIntent, setStripeClientForTests } from "../services/paymentService.js";
4+
5+
test("creates a real Stripe test-mode payment intent when explicitly enabled", { skip: process.env.RUN_STRIPE_SMOKE_TEST !== "1" }, async () => {
6+
setStripeClientForTests(undefined);
7+
8+
const result = await createPaymentIntent({
9+
amount: 100,
10+
currency: "usd",
11+
metadata: { smoke: "true" }
12+
});
13+
14+
assert.match(result.paymentId, /^pi_/);
15+
assert.match(result.clientSecret, /^pi_/);
16+
});

0 commit comments

Comments
 (0)