diff --git a/apps/api/package.json b/apps/api/package.json index 25fa0e90e..5bd086c4d 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/controllers/paymentController.js b/apps/api/src/controllers/paymentController.js index 138f909e2..29b85450f 100644 --- a/apps/api/src/controllers/paymentController.js +++ b/apps/api/src/controllers/paymentController.js @@ -1,6 +1,14 @@ -import { ok } from "../utils/response.js"; -import { createPaymentIntent } from "../services/paymentService.js"; +import { fail, ok } from "../utils/response.js"; +import { createPaymentIntent, PaymentServiceError } 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) { + if (error instanceof PaymentServiceError) { + return fail(res, error.message, error.statusCode); + } + + throw error; + } } diff --git a/apps/api/src/services/paymentService.js b/apps/api/src/services/paymentService.js index 956a70dc7..9b888d1aa 100644 --- a/apps/api/src/services/paymentService.js +++ b/apps/api/src/services/paymentService.js @@ -1,9 +1,113 @@ -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; + +export class PaymentServiceError extends Error { + constructor(message, statusCode = 400) { + super(message); + this.name = "PaymentServiceError"; + this.statusCode = statusCode; + } +} + +export function setStripeClientForTest(client) { + stripeClient = client; +} + +export function resetStripeClientForTest() { + stripeClient = undefined; +} + +function getStripeClient() { + if (stripeClient) { + return stripeClient; + } + + if (!env.stripeSecretKey) { + throw new PaymentServiceError("STRIPE_SECRET_KEY is required to create payment intents", 500); + } + + stripeClient = new Stripe(env.stripeSecretKey); + return stripeClient; +} + +function validateAmount(amount) { + if (!Number.isInteger(amount) || amount <= 0) { + throw new PaymentServiceError("amount is required and must be a positive integer"); + } + return amount; +} + +function validateCurrency(currency) { + const requestedCurrency = currency ?? "usd"; + + if (typeof requestedCurrency !== "string") { + throw new PaymentServiceError("currency must be a valid three-letter currency code"); + } + + const normalizedCurrency = requestedCurrency.toLowerCase(); + + if (!/^[a-z]{3}$/.test(normalizedCurrency)) { + throw new PaymentServiceError("currency must be a valid three-letter currency code"); + } + + return normalizedCurrency; +} + +function validateMetadata(metadata) { + if (metadata === undefined) { + return undefined; + } + + if (!metadata || Array.isArray(metadata) || typeof metadata !== "object") { + throw new PaymentServiceError("metadata must be an object when provided"); + } + + return Object.fromEntries( + Object.entries(metadata).map(([key, value]) => { + if (value === null || value === undefined || typeof value === "object") { + throw new PaymentServiceError("metadata values must be strings, numbers, or booleans"); + } + + return [key, String(value)]; + }) + ); +} + +function toStripeError(error) { + const type = error?.type ?? error?.raw?.type ?? ""; + if (type.startsWith("Stripe") && error?.message) { + return new PaymentServiceError(error.message, 502); + } + + return new PaymentServiceError("Unable to create payment intent", 502); +} + +export async function createPaymentIntent(payload = {}) { + 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, + currency, + provider: "stripe" + }; + } catch (error) { + if (error instanceof PaymentServiceError) { + throw error; + } + + throw toStripeError(error); + } } diff --git a/apps/api/src/tests/paymentService.test.js b/apps/api/src/tests/paymentService.test.js new file mode 100644 index 000000000..ee32dc903 --- /dev/null +++ b/apps/api/src/tests/paymentService.test.js @@ -0,0 +1,140 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createPaymentIntent, + resetStripeClientForTest, + setStripeClientForTest +} from "../services/paymentService.js"; + +test.afterEach(() => { + resetStripeClientForTest(); +}); + +test("createPaymentIntent validates positive integer amounts", async () => { + await assert.rejects( + () => createPaymentIntent({ amount: 0 }), + /amount is required and must be a positive integer/ + ); + + await assert.rejects( + () => createPaymentIntent({ amount: 12.5 }), + /amount is required and must be a positive integer/ + ); +}); + +test("createPaymentIntent validates currency values", async () => { + await assert.rejects( + () => createPaymentIntent({ amount: 2500, currency: 123 }), + /currency must be a valid three-letter currency code/ + ); + + await assert.rejects( + () => createPaymentIntent({ amount: 2500, currency: "usdollar" }), + /currency must be a valid three-letter currency code/ + ); +}); + +test("createPaymentIntent creates a Stripe payment intent with default currency", async () => { + const calls = []; + setStripeClientForTest({ + paymentIntents: { + create: async (params) => { + calls.push(params); + return { + id: "pi_test_123", + client_secret: "pi_test_123_secret_abc" + }; + } + } + }); + + const result = await createPaymentIntent({ amount: 2500 }); + + assert.deepEqual(calls, [{ amount: 2500, currency: "usd" }]); + assert.deepEqual(result, { + paymentId: "pi_test_123", + clientSecret: "pi_test_123_secret_abc", + amount: 2500, + currency: "usd", + provider: "stripe" + }); +}); + +test("createPaymentIntent normalizes currency and metadata before calling Stripe", async () => { + const calls = []; + setStripeClientForTest({ + paymentIntents: { + create: async (params) => { + calls.push(params); + return { + id: "pi_test_456", + client_secret: "pi_test_456_secret_def" + }; + } + } + }); + + await createPaymentIntent({ + amount: 5000, + currency: "EUR", + metadata: { + jobId: 42, + urgent: true, + source: "checkout" + } + }); + + assert.deepEqual(calls, [ + { + amount: 5000, + currency: "eur", + metadata: { + jobId: "42", + urgent: "true", + source: "checkout" + } + } + ]); +}); + +test("createPaymentIntent preserves Stripe error messages", async () => { + setStripeClientForTest({ + paymentIntents: { + create: async () => { + const error = new Error("No such customer: cus_missing"); + error.type = "StripeInvalidRequestError"; + throw error; + } + } + }); + + await assert.rejects( + () => createPaymentIntent({ amount: 5000 }), + /No such customer: cus_missing/ + ); +}); + +test("createPaymentIntent live Stripe smoke test", { skip: shouldSkipStripeSmoke() }, async () => { + const result = await createPaymentIntent({ + amount: 100, + currency: "usd", + metadata: { + smoke: "true" + } + }); + + assert.match(result.paymentId, /^pi_/); + assert.match(result.clientSecret, /^pi_.*_secret_/); +}); + +function shouldSkipStripeSmoke() { + if (process.env.STRIPE_PAYMENT_SMOKE !== "true") { + return "set STRIPE_PAYMENT_SMOKE=true to run the live Stripe smoke test"; + } + + if (!process.env.STRIPE_SECRET_KEY) { + return "set STRIPE_SECRET_KEY to run the live Stripe smoke test"; + } + + return false; +} diff --git a/package-lock.json b/package-lock.json index a19a99281..aecc492c6 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" @@ -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,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 +2145,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": {