diff --git a/apps/api/package.json b/apps/api/package.json index 25fa0e90e..f277ec4e2 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": "^22.1.1", "zod": "^3.23.8" } } diff --git a/apps/api/src/services/paymentService.js b/apps/api/src/services/paymentService.js index 956a70dc7..859e942b9 100644 --- a/apps/api/src/services/paymentService.js +++ b/apps/api/src/services/paymentService.js @@ -1,9 +1,77 @@ -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" +import Stripe from "stripe"; +import { env } from "../config/env.js"; + +let stripeClient; + +function getStripeClient() { + if (stripeClient) { + return stripeClient; + } + + const secretKey = env.stripeSecretKey || process.env.STRIPE_SECRET_KEY; + + if (!secretKey) { + throw new Error("STRIPE_SECRET_KEY is required to create payment intents"); + } + + stripeClient = new Stripe(secretKey); + return stripeClient; +} + +function validatePaymentPayload(payload) { + const amount = payload?.amount; + + if (!Number.isInteger(amount) || amount <= 0) { + throw new Error("payload.amount must be a positive integer in the smallest currency unit"); + } + + const currency = payload.currency ?? "usd"; + + if (typeof currency !== "string" || currency.trim() === "") { + throw new Error("payload.currency must be a non-empty string"); + } + + const params = { + amount, + currency: currency.trim().toLowerCase() }; + + if (payload.metadata !== undefined) { + if (payload.metadata === null || typeof payload.metadata !== "object" || Array.isArray(payload.metadata)) { + throw new Error("payload.metadata must be an object when provided"); + } + + params.metadata = payload.metadata; + } + + return params; +} + +export async function createPaymentIntent(payload) { + const params = validatePaymentPayload(payload); + + try { + const paymentIntent = await getStripeClient().paymentIntents.create(params); + + if (!paymentIntent?.id || !paymentIntent?.client_secret) { + throw new Error("Stripe response did not include a payment intent id and client secret"); + } + + return { + paymentId: paymentIntent.id, + clientSecret: paymentIntent.client_secret, + amount: paymentIntent.amount, + currency: paymentIntent.currency, + provider: "stripe" + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown Stripe error"; + const wrappedError = new Error(`Stripe payment intent creation failed: ${message}`); + wrappedError.cause = error; + throw wrappedError; + } +} + +export function setStripeClientForTest(client) { + stripeClient = client; } diff --git a/apps/api/src/tests/paymentService.test.js b/apps/api/src/tests/paymentService.test.js new file mode 100644 index 000000000..110e4383d --- /dev/null +++ b/apps/api/src/tests/paymentService.test.js @@ -0,0 +1,100 @@ +import test, { afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { createPaymentIntent, setStripeClientForTest } from "../services/paymentService.js"; + +afterEach(() => { + setStripeClientForTest(undefined); +}); + +test("createPaymentIntent creates a Stripe PaymentIntent with validated defaults", async () => { + const calls = []; + + setStripeClientForTest({ + paymentIntents: { + async create(params) { + calls.push(params); + return { + id: "pi_test_123", + client_secret: "pi_test_123_secret_456", + amount: params.amount, + currency: params.currency + }; + } + } + }); + + const result = await createPaymentIntent({ + amount: 2500, + metadata: { orderId: "order_123" } + }); + + assert.deepEqual(calls, [ + { + amount: 2500, + currency: "usd", + metadata: { orderId: "order_123" } + } + ]); + assert.deepEqual(result, { + paymentId: "pi_test_123", + clientSecret: "pi_test_123_secret_456", + amount: 2500, + currency: "usd", + provider: "stripe" + }); +}); + +test("createPaymentIntent rejects invalid amounts before calling Stripe", async () => { + let createCalled = false; + + setStripeClientForTest({ + paymentIntents: { + async create() { + createCalled = true; + } + } + }); + + await assert.rejects( + () => createPaymentIntent({ amount: 0 }), + /payload\.amount must be a positive integer/ + ); + assert.equal(createCalled, false); +}); + +test("createPaymentIntent preserves Stripe error messages", async () => { + setStripeClientForTest({ + paymentIntents: { + async create() { + const error = new Error("No such customer: cus_missing"); + error.type = "StripeInvalidRequestError"; + throw error; + } + } + }); + + await assert.rejects( + () => createPaymentIntent({ amount: 1000, currency: "USD" }), + /Stripe payment intent creation failed: No such customer: cus_missing/ + ); +}); + +test( + "createPaymentIntent smoke test creates a real Stripe test-mode PaymentIntent", + { skip: process.env.RUN_STRIPE_SMOKE_TESTS !== "1" || !process.env.STRIPE_SECRET_KEY }, + async () => { + setStripeClientForTest(undefined); + + const result = await createPaymentIntent({ + amount: 100, + currency: "usd", + metadata: { smokeTest: "true" } + }); + + assert.match(result.paymentId, /^pi_/); + assert.match(result.clientSecret, /^pi_/); + assert.equal(result.amount, 100); + assert.equal(result.currency, "usd"); + assert.equal(result.provider, "stripe"); + } +); diff --git a/demos/stripe-payment-intent-demo.mp4 b/demos/stripe-payment-intent-demo.mp4 new file mode 100644 index 000000000..9f271d6d7 Binary files /dev/null and b/demos/stripe-payment-intent-demo.mp4 differ diff --git a/package-lock.json b/package-lock.json index a19a99281..6cfdfb712 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": "^22.1.1", "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" @@ -2053,6 +2054,23 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/stripe": { + "version": "22.1.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz", + "integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "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 +2146,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": {