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
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"stripe": "^17.0.0",
"zod": "^3.23.8"
}
}
47 changes: 39 additions & 8 deletions apps/api/src/services/paymentService.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,40 @@
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 { z } from "zod";
import { env } from "../config/env.js";

const paymentIntentSchema = z.object({
amount: z
.number({ required_error: "payload.amount is required" })
.int("payload.amount must be an integer")
.positive("payload.amount must be a positive integer"),
currency: z.string().toLowerCase().default("usd"),
metadata: z.record(z.string()).optional(),
});

function createStripeClient() {
if (!env.stripeSecretKey) {
throw new Error("STRIPE_SECRET_KEY environment variable is not set");
}
return new Stripe(env.stripeSecretKey, { apiVersion: "2025-04-30.basil" });
}

export async function createPaymentIntent(payload, stripeClient = createStripeClient()) {
const parsed = paymentIntentSchema.safeParse(payload);
if (!parsed.success) {
throw new Error(parsed.error.issues[0].message);
}

const { amount, currency, metadata } = parsed.data;
const params = { amount, currency };
if (metadata) params.metadata = metadata;

try {
const paymentIntent = await stripeClient.paymentIntents.create(params);
return {
clientSecret: paymentIntent.client_secret,
paymentId: paymentIntent.id,
};
} catch (error) {
throw new Error(error.message ?? String(error));
}
}
46 changes: 46 additions & 0 deletions apps/api/src/tests/paymentService.smoke.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createApp } from "../app.js";

const runSmokeTest = process.env.STRIPE_SMOKE_TEST === "1";
const smokeTest = runSmokeTest ? test : test.skip;

function startServer(app) {
return new Promise((resolve, reject) => {
const server = app.listen(0, () => resolve(server));
server.once("error", reject);
});
}

function stopServer(server) {
return new Promise((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}

smokeTest("POST /api/payments creates a real Stripe PaymentIntent in test mode", async () => {
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is required when STRIPE_SMOKE_TEST=1");
}

const app = createApp();
const server = await startServer(app);

try {
const { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}/api/payments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: 100, currency: "usd" }),
});

const text = await response.text();
assert.equal(response.status, 201, `Unexpected status: ${text}`);

const body = JSON.parse(text);
assert.equal(typeof body.clientSecret, "string", "clientSecret should be a string");
assert.match(body.paymentId, /^pi_/, "paymentId should start with pi_");
} finally {
await stopServer(server);
}
});
91 changes: 91 additions & 0 deletions apps/api/src/tests/paymentService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createPaymentIntent } from "../services/paymentService.js";

function makeStripeClient(overrides = {}) {
return {
paymentIntents: {
create: async () => ({ id: "pi_test_123", client_secret: "secret_test_123" }),
...overrides,
},
};
}

test("returns clientSecret and paymentId from Stripe response", async () => {
const result = await createPaymentIntent({ amount: 2500 }, makeStripeClient());
assert.deepEqual(result, { clientSecret: "secret_test_123", paymentId: "pi_test_123" });
});

test("sends amount and default currency usd to Stripe", async () => {
let captured;
const client = makeStripeClient({
create: async (params) => { captured = params; return { id: "pi_1", client_secret: "cs_1" }; },
});
await createPaymentIntent({ amount: 100 }, client);
assert.deepEqual(captured, { amount: 100, currency: "usd" });
});

test("sends explicit currency when provided", async () => {
let captured;
const client = makeStripeClient({
create: async (params) => { captured = params; return { id: "pi_2", client_secret: "cs_2" }; },
});
await createPaymentIntent({ amount: 500, currency: "EUR" }, client);
assert.equal(captured.currency, "eur");
});

test("sends metadata when provided", async () => {
let captured;
const client = makeStripeClient({
create: async (params) => { captured = params; return { id: "pi_3", client_secret: "cs_3" }; },
});
await createPaymentIntent({ amount: 1000, metadata: { orderId: "order_42" } }, client);
assert.deepEqual(captured.metadata, { orderId: "order_42" });
});

test("omits metadata key when not provided", async () => {
let captured;
const client = makeStripeClient({
create: async (params) => { captured = params; return { id: "pi_4", client_secret: "cs_4" }; },
});
await createPaymentIntent({ amount: 200 }, client);
assert.equal("metadata" in captured, false);
});

test("throws when amount is missing", async () => {
await assert.rejects(
() => createPaymentIntent({}, makeStripeClient()),
/payload\.amount is required/
);
});

test("throws when amount is zero", async () => {
await assert.rejects(
() => createPaymentIntent({ amount: 0 }, makeStripeClient()),
/positive integer/
);
});

test("throws when amount is negative", async () => {
await assert.rejects(
() => createPaymentIntent({ amount: -1 }, makeStripeClient()),
/positive integer/
);
});

test("throws when amount is a float", async () => {
await assert.rejects(
() => createPaymentIntent({ amount: 9.99 }, makeStripeClient()),
/integer/
);
});

test("rethrows Stripe errors preserving the original message", async () => {
const client = makeStripeClient({
create: async () => { throw new Error("Your card has insufficient funds."); },
});
await assert.rejects(
() => createPaymentIntent({ amount: 1000 }, client),
/insufficient funds/
);
});
Loading