Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -14,6 +14,7 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"stripe": "^18.5.0",
"zod": "^3.23.8"
}
}
9 changes: 7 additions & 2 deletions apps/api/src/controllers/paymentController.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
115 changes: 110 additions & 5 deletions apps/api/src/services/paymentService.js
Original file line number Diff line number Diff line change
@@ -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" }
})
};
}
126 changes: 126 additions & 0 deletions apps/api/src/tests/paymentService.test.js
Original file line number Diff line number Diff line change
@@ -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_/);
});
26 changes: 23 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading