From cf38962d5c345d2aaadd9bbc7e16f7a69504ebae Mon Sep 17 00:00:00 2001 From: sunguangning Date: Sun, 17 May 2026 22:04:42 +0800 Subject: [PATCH] feat: integrate Stripe SDK for PaymentIntent creation - Add stripe npm package (v16) - Rewrite paymentService.js to use real Stripe SDK instead of stub - Add initStripe(secretKey, stripeInstance) for DI/testability - Validate payload.amount (required, positive integer) before API call - Return {paymentId, clientSecret, amount, currency, provider} - Pass through Stripe error messages unchanged - Add payment.test.js with 8 unit tests covering: - Validation (missing/zero/negative/non-numeric amount) - Correct PaymentIntent field mapping - Default currency to 'usd' - Error message passthrough Closes #1 Signed-off-by: sunguangning --- apps/api/package.json | 1 + apps/api/src/services/paymentService.js | 67 ++++++++++++-- apps/api/src/tests/payment.test.js | 114 ++++++++++++++++++++++++ package-lock.json | 20 ++++- 4 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/tests/payment.test.js diff --git a/apps/api/package.json b/apps/api/package.json index 25fa0e90e3..2c6cb77c83 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", + "stripe": "^16.0.0", "zod": "^3.23.8" } } diff --git a/apps/api/src/services/paymentService.js b/apps/api/src/services/paymentService.js index 956a70dc78..47e4e72639 100644 --- a/apps/api/src/services/paymentService.js +++ b/apps/api/src/services/paymentService.js @@ -1,9 +1,62 @@ +import Stripe from "stripe"; + +let _stripe = null; + +/** + * Initialise (or replace) the Stripe singleton. + * Call once at app startup with the real key. + * + * @param {string} [secretKey] - Stripe secret key. Falls back to STRIPE_SECRET_KEY env var. + * @param {import("stripe").default} [stripeInstance] - Optional pre-configured Stripe instance + * (useful for testing without real API calls). + */ +export function initStripe(secretKey, stripeInstance) { + if (stripeInstance) { + _stripe = stripeInstance; + return _stripe; + } + const key = secretKey ?? process.env.STRIPE_SECRET_KEY; + if (!key) throw new Error("STRIPE_SECRET_KEY environment variable is not set"); + _stripe = new Stripe(key, { apiVersion: "2024-06-20" }); + return _stripe; +} + +function getStripe() { + if (!_stripe) { + initStripe(); + } + return _stripe; +} + +/** + * Create a Stripe PaymentIntent. + * + * @param {{ amount: number, currency?: string }} payload + * @returns {{ paymentId: string, clientSecret: string, amount: number, currency: string, provider: string }} + */ export async function createPaymentIntent(payload) { - // TODO: integrate Stripe SDK and return client secret. - return { - paymentId: `pay_${Date.now()}`, - amount: payload.amount, - currency: payload.currency ?? "usd", - provider: "stripe" - }; + if (!payload.amount || typeof payload.amount !== "number" || payload.amount <= 0) { + throw new Error( + "payload.amount is required and must be a positive integer (smallest currency unit, e.g. cents)" + ); + } + + const currency = payload.currency ?? "usd"; + + try { + const paymentIntent = await getStripe().paymentIntents.create({ + amount: payload.amount, + currency, + }); + + return { + paymentId: paymentIntent.id, + clientSecret: paymentIntent.client_secret, + amount: paymentIntent.amount, + currency: paymentIntent.currency, + provider: "stripe", + }; + } catch (err) { + throw new Error(err.message); + } } diff --git a/apps/api/src/tests/payment.test.js b/apps/api/src/tests/payment.test.js new file mode 100644 index 0000000000..f0af08763f --- /dev/null +++ b/apps/api/src/tests/payment.test.js @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createPaymentIntent, initStripe } from "../services/paymentService.js"; + +test("createPaymentIntent throws when amount is missing", async () => { + await assert.rejects( + () => createPaymentIntent({}), + /payload.amount is required/ + ); +}); + +test("createPaymentIntent throws when amount is zero", async () => { + await assert.rejects( + () => createPaymentIntent({ amount: 0 }), + /payload.amount is required/ + ); +}); + +test("createPaymentIntent throws when amount is negative", async () => { + await assert.rejects( + () => createPaymentIntent({ amount: -100 }), + /payload.amount is required/ + ); +}); + +test("createPaymentIntent throws when amount is not a number", async () => { + await assert.rejects( + () => createPaymentIntent({ amount: "100" }), + /payload.amount is required/ + ); +}); + +test("createPaymentIntent returns correct shape and maps fields from PaymentIntent", async () => { + const mockStripe = { + paymentIntents: { + create: async (args) => ({ + id: `pi_${Date.now()}`, + client_secret: `pi_${Date.now()}_secret_${args.currency}`, + amount: args.amount, + currency: args.currency, + }), + }, + }; + initStripe(undefined, mockStripe); + + const result = await createPaymentIntent({ amount: 2000, currency: "usd" }); + + assert.equal(typeof result.paymentId, "string"); + assert.equal(result.paymentId.startsWith("pi_"), true, "paymentId starts with pi_"); + assert.equal(typeof result.clientSecret, "string"); + assert.ok(result.clientSecret.includes("_secret_"), "clientSecret contains _secret_"); + assert.equal(result.amount, 2000); + assert.equal(result.currency, "usd"); + assert.equal(result.provider, "stripe"); +}); + +test("createPaymentIntent uses default currency 'usd' when not provided", async () => { + const mockStripe = { + paymentIntents: { + create: async (args) => ({ + id: "pi_fixed", + client_secret: "pi_fixed_secret_xyz", + amount: args.amount, + currency: args.currency, + }), + }, + }; + initStripe(undefined, mockStripe); + + const result = await createPaymentIntent({ amount: 5000 }); + + assert.equal(result.currency, "usd"); +}); + +test("createPaymentIntent passes amount and currency to Stripe paymentIntents.create", async () => { + let capturedArgs = null; + const mockStripe = { + paymentIntents: { + create: async (args) => { + capturedArgs = args; + return { + id: "pi_fixed", + client_secret: "pi_fixed_secret_xyz", + amount: args.amount, + currency: args.currency, + }; + }, + }, + }; + initStripe(undefined, mockStripe); + + await createPaymentIntent({ amount: 3450, currency: "eur" }); + + assert.equal(capturedArgs.amount, 3450); + assert.equal(capturedArgs.currency, "eur"); +}); + +test("createPaymentIntent throws and preserves Stripe error message", async () => { + const mockStripe = { + paymentIntents: { + create: async () => { + const err = new Error("Your card was declined"); + err.type = "StripeCardError"; + throw err; + }, + }, + }; + initStripe(undefined, mockStripe); + + await assert.rejects( + () => createPaymentIntent({ amount: 1000 }), + /Your card was declined/ + ); +}); diff --git a/package-lock.json b/package-lock.json index a19a992813..74d56d62a2 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": "^16.0.0", "zod": "^3.23.8" } }, @@ -742,7 +743,6 @@ "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, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1107,6 +1107,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -1706,6 +1707,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -1776,6 +1778,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1785,6 +1788,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2053,6 +2057,19 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/stripe": { + "version": "16.12.0", + "resolved": "https://registry.npmmirror.com/stripe/-/stripe-16.12.0.tgz", + "integrity": "sha512-H7eFVLDxeTNNSn4JTRfL2//LzCbDrMSZ+2q1c7CanVWgK2qIW5TwS+0V7N9KcKZZNpYh/uCqK0PyZh/2UsaAtQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -2128,7 +2145,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": {