From ee5f0080b8fb867a828e29887b8977a469066e08 Mon Sep 17 00:00:00 2001 From: devnull37 Date: Sun, 17 May 2026 13:27:30 +0400 Subject: [PATCH] feat(api): add Stripe payment intents --- apps/api/package.json | 3 +- apps/api/src/controllers/paymentController.js | 9 +- apps/api/src/services/paymentService.js | 115 +++++++++++++++- apps/api/src/tests/paymentService.test.js | 126 ++++++++++++++++++ package-lock.json | 26 +++- 5 files changed, 268 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/tests/paymentService.test.js diff --git a/apps/api/package.json b/apps/api/package.json index 25fa0e90e3..0d2672d8ca 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "node src/server.js", "start": "node src/server.js", - "test": "node --test src/tests" + "test": "node --test \"src/tests/*.test.js\"" }, "dependencies": { "cors": "^2.8.5", @@ -14,6 +14,7 @@ "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", + "stripe": "^18.5.0", "zod": "^3.23.8" } } diff --git a/apps/api/src/controllers/paymentController.js b/apps/api/src/controllers/paymentController.js index 138f909e21..60ab266eb9 100644 --- a/apps/api/src/controllers/paymentController.js +++ b/apps/api/src/controllers/paymentController.js @@ -1,6 +1,11 @@ -import { ok } from "../utils/response.js"; +import { fail, ok } from "../utils/response.js"; import { createPaymentIntent } from "../services/paymentService.js"; export async function createPayment(req, res) { - return ok(res, await createPaymentIntent(req.body), 201); + try { + return ok(res, await createPaymentIntent(req.body), 201); + } catch (error) { + const status = error?.type?.startsWith("Stripe") || error?.rawType?.startsWith("Stripe") ? 502 : 400; + return fail(res, error.message, status); + } } diff --git a/apps/api/src/services/paymentService.js b/apps/api/src/services/paymentService.js index 956a70dc78..81b00bffca 100644 --- a/apps/api/src/services/paymentService.js +++ b/apps/api/src/services/paymentService.js @@ -1,9 +1,114 @@ +import Stripe from "stripe"; +import { env } from "../config/env.js"; + +let stripeClient; + +export function setStripeClientForTests(client) { + stripeClient = client; +} + +export function resetStripeClientForTests() { + stripeClient = undefined; +} + +function getStripeClient() { + if (stripeClient) { + return stripeClient; + } + + if (!env.stripeSecretKey) { + throw new Error("STRIPE_SECRET_KEY is required to create a payment intent"); + } + + stripeClient = new Stripe(env.stripeSecretKey); + return stripeClient; +} + +function validateAmount(amount) { + if (amount === undefined || amount === null) { + throw new Error("amount is required and must be a positive integer"); + } + + if (!Number.isInteger(amount) || amount <= 0) { + throw new Error("amount must be a positive integer in the smallest currency unit"); + } + + return amount; +} + +function validateCurrency(currency) { + const normalized = (currency ?? "usd").toString().trim().toLowerCase(); + + if (!/^[a-z]{3}$/.test(normalized)) { + throw new Error("currency must be a valid three-letter ISO currency code"); + } + + return normalized; +} + +function validateMetadata(metadata) { + if (metadata === undefined) { + return undefined; + } + + if (!metadata || Array.isArray(metadata) || typeof metadata !== "object") { + throw new Error("metadata must be an object when provided"); + } + + return Object.fromEntries( + Object.entries(metadata).map(([key, value]) => { + if (!key || key.length > 40) { + throw new Error("metadata keys must be between 1 and 40 characters"); + } + + if (value === null || typeof value === "object") { + throw new Error("metadata values must be strings, numbers, or booleans"); + } + + return [key, String(value)]; + }) + ); +} + export async function createPaymentIntent(payload) { - // TODO: integrate Stripe SDK and return client secret. + const amount = validateAmount(payload?.amount); + const currency = validateCurrency(payload?.currency); + const metadata = validateMetadata(payload?.metadata); + + try { + const paymentIntent = await getStripeClient().paymentIntents.create({ + amount, + currency, + ...(metadata ? { metadata } : {}) + }); + + return { + paymentId: paymentIntent.id, + clientSecret: paymentIntent.client_secret, + amount: paymentIntent.amount, + currency: paymentIntent.currency, + provider: "stripe" + }; + } catch (error) { + if (error?.type?.startsWith("Stripe") || error?.rawType?.startsWith("Stripe")) { + throw error; + } + + throw error; + } +} + +export async function smokeTestPaymentIntent() { + if (process.env.STRIPE_PAYMENT_SMOKE_TEST !== "true") { + return { skipped: true, reason: "STRIPE_PAYMENT_SMOKE_TEST is not true" }; + } + return { - paymentId: `pay_${Date.now()}`, - amount: payload.amount, - currency: payload.currency ?? "usd", - provider: "stripe" + skipped: false, + paymentIntent: await createPaymentIntent({ + amount: 100, + currency: "usd", + metadata: { smokeTest: "true" } + }) }; } diff --git a/apps/api/src/tests/paymentService.test.js b/apps/api/src/tests/paymentService.test.js new file mode 100644 index 0000000000..812dc1120f --- /dev/null +++ b/apps/api/src/tests/paymentService.test.js @@ -0,0 +1,126 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createPaymentIntent, + resetStripeClientForTests, + setStripeClientForTests, + smokeTestPaymentIntent +} from "../services/paymentService.js"; + +test.afterEach(() => { + resetStripeClientForTests(); +}); + +test("createPaymentIntent validates amount", async () => { + await assert.rejects( + () => createPaymentIntent({ currency: "usd" }), + /amount is required/ + ); + await assert.rejects( + () => createPaymentIntent({ amount: 10.5, currency: "usd" }), + /positive integer/ + ); + await assert.rejects( + () => createPaymentIntent({ amount: 0, currency: "usd" }), + /positive integer/ + ); +}); + +test("createPaymentIntent validates currency and metadata", async () => { + await assert.rejects( + () => createPaymentIntent({ amount: 100, currency: "usdollar" }), + /currency must be/ + ); + await assert.rejects( + () => createPaymentIntent({ amount: 100, metadata: ["bad"] }), + /metadata must be an object/ + ); + await assert.rejects( + () => createPaymentIntent({ amount: 100, metadata: { nested: { bad: true } } }), + /metadata values/ + ); +}); + +test("createPaymentIntent creates a Stripe PaymentIntent with validated fields", async () => { + let receivedArgs; + setStripeClientForTests({ + paymentIntents: { + async create(args) { + receivedArgs = args; + return { + id: "pi_test_123", + client_secret: "pi_test_123_secret_abc", + amount: args.amount, + currency: args.currency + }; + } + } + }); + + const result = await createPaymentIntent({ + amount: 2500, + currency: "USD", + metadata: { orderId: 123, expedited: true } + }); + + assert.deepEqual(receivedArgs, { + amount: 2500, + currency: "usd", + metadata: { orderId: "123", expedited: "true" } + }); + assert.deepEqual(result, { + paymentId: "pi_test_123", + clientSecret: "pi_test_123_secret_abc", + amount: 2500, + currency: "usd", + provider: "stripe" + }); +}); + +test("createPaymentIntent preserves Stripe error messages", async () => { + setStripeClientForTests({ + paymentIntents: { + async create() { + const error = new Error("Your card was declined."); + error.type = "StripeCardError"; + throw error; + } + } + }); + + await assert.rejects( + () => createPaymentIntent({ amount: 100, currency: "usd" }), + /Your card was declined\./ + ); +}); + +test("smokeTestPaymentIntent is skipped unless explicitly enabled", async () => { + const previous = process.env.STRIPE_PAYMENT_SMOKE_TEST; + delete process.env.STRIPE_PAYMENT_SMOKE_TEST; + + try { + assert.deepEqual(await smokeTestPaymentIntent(), { + skipped: true, + reason: "STRIPE_PAYMENT_SMOKE_TEST is not true" + }); + } finally { + if (previous === undefined) { + delete process.env.STRIPE_PAYMENT_SMOKE_TEST; + } else { + process.env.STRIPE_PAYMENT_SMOKE_TEST = previous; + } + } +}); + +test("smokeTestPaymentIntent can create a real Stripe test PaymentIntent when enabled", async (t) => { + if (process.env.STRIPE_PAYMENT_SMOKE_TEST !== "true" || !process.env.STRIPE_SECRET_KEY) { + t.skip("Set STRIPE_PAYMENT_SMOKE_TEST=true and STRIPE_SECRET_KEY to run the Stripe smoke test"); + return; + } + + const result = await smokeTestPaymentIntent(); + + assert.equal(result.skipped, false); + assert.match(result.paymentIntent.paymentId, /^pi_/); + assert.match(result.paymentIntent.clientSecret, /_secret_/); +}); diff --git a/package-lock.json b/package-lock.json index a19a992813..01e9e9c663 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", + "stripe": "^18.5.0", "zod": "^3.23.8" } }, @@ -742,7 +743,7 @@ "version": "22.15.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.19.tgz", "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1203,7 +1204,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2053,6 +2053,26 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/stripe": { + "version": "18.5.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-18.5.0.tgz", + "integrity": "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==", + "license": "MIT", + "dependencies": { + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + }, + "peerDependencies": { + "@types/node": ">=12.x.x" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -2128,7 +2148,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unpipe": {