Skip to content
Merged
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
12 changes: 12 additions & 0 deletions app/api/webhooks/stripe/__tests__/routeTestMocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,15 @@ vi.mock("@/lib/stripe/processSubscriptionUpdated", () => ({
vi.mock("@/lib/stripe/processSubscriptionDeleted", () => ({
processSubscriptionDeleted: vi.fn(),
}));

vi.mock("@/lib/stripe/processInvoicePaid", () => ({
processInvoicePaid: vi.fn(),
}));

vi.mock("@/lib/stripe/notifyCreditsTopupPaymentIntent", () => ({
notifyCreditsTopupPaymentIntent: vi.fn(),
}));

vi.mock("@/lib/stripe/notifyCreditsTopupSession", () => ({
notifyCreditsTopupSession: vi.fn(),
}));
42 changes: 42 additions & 0 deletions lib/stripe/__tests__/buildCustomerSalesContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

const { getCustomerEmailMock, getLtvMock } = vi.hoisted(() => ({
getCustomerEmailMock: vi.fn(),
getLtvMock: vi.fn(),
}));

vi.mock("@/lib/stripe/getCustomerEmail", () => ({ getCustomerEmail: getCustomerEmailMock }));
vi.mock("@/lib/stripe/getCustomerLifetimeValue", () => ({
getCustomerLifetimeValue: getLtvMock,
}));

const { buildCustomerSalesContext } = await import("@/lib/stripe/buildCustomerSalesContext");

describe("buildCustomerSalesContext", () => {
beforeEach(() => {
vi.clearAllMocks();
getCustomerEmailMock.mockResolvedValue("fan@example.com");
getLtvMock.mockResolvedValue(12345);
});

it("resolves email and lifetime value for a customer", async () => {
await expect(buildCustomerSalesContext("cus_9")).resolves.toEqual({
email: "fan@example.com",
customerLine: "Customer: fan@example.com (cus_9)",
lifetimeLine: "Lifetime value: $123.45",
lifetimeCents: 12345,
});
});

it("skips the email lookup when a known email is provided", async () => {
const ctx = await buildCustomerSalesContext("cus_9", "known@example.com");
expect(ctx.email).toBe("known@example.com");
expect(getCustomerEmailMock).not.toHaveBeenCalled();
});

it("falls back to the id-only customer line when no email exists", async () => {
getCustomerEmailMock.mockResolvedValue(null);
const ctx = await buildCustomerSalesContext("cus_9");
expect(ctx.customerLine).toBe("Customer: cus_9");
});
});
67 changes: 67 additions & 0 deletions lib/stripe/__tests__/notifyCreditsTopupPaymentIntent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type Stripe from "stripe";

const { buildCustomerContextMock, sendMock } = vi.hoisted(() => ({
buildCustomerContextMock: vi.fn(),
sendMock: vi.fn(),
}));

vi.mock("@/lib/stripe/buildCustomerSalesContext", () => ({
buildCustomerSalesContext: buildCustomerContextMock,
}));
vi.mock("@/lib/telegram/sendSalesNotification", () => ({ sendSalesNotification: sendMock }));

const { notifyCreditsTopupPaymentIntent } = await import(
"@/lib/stripe/notifyCreditsTopupPaymentIntent"
);

const pi = (metadata: object, overrides: object = {}): Stripe.PaymentIntent =>
({
id: "pi_1",
customer: "cus_9",
amount: 546,
metadata,
...overrides,
}) as unknown as Stripe.PaymentIntent;

describe("notifyCreditsTopupPaymentIntent", () => {
beforeEach(() => {
vi.clearAllMocks();
buildCustomerContextMock.mockResolvedValue({
email: "fan@example.com",
customerLine: "Customer: fan@example.com (cus_9)",
lifetimeLine: "Lifetime value: $5.46",
lifetimeCents: 546,
});
});

it("notifies an auto-recharge with amount and credits", async () => {
await notifyCreditsTopupPaymentIntent(pi({ purpose: "credits_auto_recharge", credits: "500" }));

expect(sendMock).toHaveBeenCalledTimes(1);
const { email, text } = sendMock.mock.calls[0][0];
expect(email).toBe("fan@example.com");
expect(text).toContain("auto-recharge");
expect(text).toContain("Amount: $5.46");
expect(text).toContain("Credits: 500");
expect(text).toContain("Lifetime value: $5.46");
});

it("stays silent for manual credits_topup PIs (notified via their checkout session)", async () => {
await notifyCreditsTopupPaymentIntent(pi({ purpose: "credits_topup" }));
expect(sendMock).not.toHaveBeenCalled();
});

it("stays silent for bare PIs without purpose metadata (subscription/checkout charges)", async () => {
await notifyCreditsTopupPaymentIntent(pi({}));
expect(sendMock).not.toHaveBeenCalled();
expect(buildCustomerContextMock).not.toHaveBeenCalled();
});

it("stays silent when the PI has no customer", async () => {
await notifyCreditsTopupPaymentIntent(
pi({ purpose: "credits_auto_recharge" }, { customer: null }),
);
expect(sendMock).not.toHaveBeenCalled();
});
});
61 changes: 61 additions & 0 deletions lib/stripe/__tests__/notifyCreditsTopupSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type Stripe from "stripe";

const { buildCustomerContextMock, sendMock } = vi.hoisted(() => ({
buildCustomerContextMock: vi.fn(),
sendMock: vi.fn(),
}));

vi.mock("@/lib/stripe/buildCustomerSalesContext", () => ({
buildCustomerSalesContext: buildCustomerContextMock,
}));
vi.mock("@/lib/telegram/sendSalesNotification", () => ({ sendSalesNotification: sendMock }));

const { notifyCreditsTopupSession } = await import("@/lib/stripe/notifyCreditsTopupSession");

const session = (overrides: object = {}): Stripe.Checkout.Session =>
({
id: "cs_1",
mode: "payment",
payment_status: "paid",
customer: "cus_9",
customer_details: { email: "fan@example.com" },
amount_total: 2000,
metadata: { purpose: "credits_topup", credits: "500" },
...overrides,
}) as unknown as Stripe.Checkout.Session;

describe("notifyCreditsTopupSession", () => {
beforeEach(() => {
vi.clearAllMocks();
buildCustomerContextMock.mockResolvedValue({
email: "fan@example.com",
customerLine: "Customer: fan@example.com (cus_9)",
lifetimeLine: "Lifetime value: $20.00",
lifetimeCents: 2000,
});
});

it("notifies a paid manual credits top-up", async () => {
await notifyCreditsTopupSession(session());

expect(sendMock).toHaveBeenCalledTimes(1);
const { email, text } = sendMock.mock.calls[0][0];
expect(email).toBe("fan@example.com");
expect(text).toContain("top-up");
expect(text).toContain("Amount: $20.00");
expect(text).toContain("Credits: 500");
expect(text).toContain("Lifetime value: $20.00");
expect(buildCustomerContextMock).toHaveBeenCalledWith("cus_9", "fan@example.com");
});

it("stays silent for sessions that are not credits top-ups", async () => {
await notifyCreditsTopupSession(session({ metadata: { purpose: "other" } }));
expect(sendMock).not.toHaveBeenCalled();
});

it("stays silent for unpaid sessions", async () => {
await notifyCreditsTopupSession(session({ payment_status: "unpaid" }));
expect(sendMock).not.toHaveBeenCalled();
});
});
70 changes: 70 additions & 0 deletions lib/stripe/__tests__/processInvoicePaid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type Stripe from "stripe";

const { buildCustomerContextMock, sendMock } = vi.hoisted(() => ({
buildCustomerContextMock: vi.fn(),
sendMock: vi.fn(),
}));

vi.mock("@/lib/stripe/buildCustomerSalesContext", () => ({
buildCustomerSalesContext: buildCustomerContextMock,
}));
vi.mock("@/lib/telegram/sendSalesNotification", () => ({ sendSalesNotification: sendMock }));

const { processInvoicePaid } = await import("@/lib/stripe/processInvoicePaid");

const ctx = (lifetimeCents: number) => ({
email: "fan@example.com",
customerLine: "Customer: fan@example.com (cus_9)",
lifetimeLine: `Lifetime value: $${(lifetimeCents / 100).toFixed(2)}`,
lifetimeCents,
});

const invoice = (overrides: object = {}): Stripe.Invoice =>
({
id: "in_1",
customer: "cus_9",
customer_email: "fan@example.com",
amount_paid: 9900,
billing_reason: "subscription_cycle",
...overrides,
}) as unknown as Stripe.Invoice;

describe("processInvoicePaid", () => {
beforeEach(() => {
vi.clearAllMocks();
buildCustomerContextMock.mockResolvedValue(ctx(9900));
});

it("stays silent for $0 invoices (trial-start invoices)", async () => {
await processInvoicePaid(invoice({ amount_paid: 0 }));
expect(sendMock).not.toHaveBeenCalled();
expect(buildCustomerContextMock).not.toHaveBeenCalled();
});

it("labels a trial conversion as the first payment (prior lifetime value $0)", async () => {
buildCustomerContextMock.mockResolvedValue(ctx(9900));
await processInvoicePaid(invoice());

expect(sendMock).toHaveBeenCalledTimes(1);
const { email, text } = sendMock.mock.calls[0][0];
expect(email).toBe("fan@example.com");
expect(text).toContain("First subscription payment");
expect(text).toContain("Amount: $99.00");
expect(text).toContain("Lifetime value: $99.00");
});

it("labels later payments as recurring", async () => {
buildCustomerContextMock.mockResolvedValue(ctx(19800));
await processInvoicePaid(invoice());

const { text } = sendMock.mock.calls[0][0];
expect(text).toContain("recurring");
expect(text).not.toContain("First subscription payment");
});

it("passes the invoice's customer_email to the context builder", async () => {
await processInvoicePaid(invoice());
expect(buildCustomerContextMock).toHaveBeenCalledWith("cus_9", "fan@example.com");
});
});
43 changes: 43 additions & 0 deletions lib/stripe/__tests__/stripeWebhookHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const {
processSubscriptionTrialWillEndMock,
processSubscriptionUpdatedMock,
processSubscriptionDeletedMock,
processInvoicePaidMock,
notifyCreditsTopupPaymentIntentMock,
notifyCreditsTopupSessionMock,
} = vi.hoisted(() => ({
verifyStripeWebhookEventMock: vi.fn(),
processCreditsTopupSessionMock: vi.fn(),
Expand All @@ -18,6 +21,9 @@ const {
processSubscriptionTrialWillEndMock: vi.fn(),
processSubscriptionUpdatedMock: vi.fn(),
processSubscriptionDeletedMock: vi.fn(),
processInvoicePaidMock: vi.fn(),
notifyCreditsTopupPaymentIntentMock: vi.fn(),
notifyCreditsTopupSessionMock: vi.fn(),
}));

vi.mock("@/lib/stripe/verifyStripeWebhookEvent", () => ({
Expand All @@ -41,6 +47,15 @@ vi.mock("@/lib/stripe/processSubscriptionUpdated", () => ({
vi.mock("@/lib/stripe/processSubscriptionDeleted", () => ({
processSubscriptionDeleted: processSubscriptionDeletedMock,
}));
vi.mock("@/lib/stripe/processInvoicePaid", () => ({
processInvoicePaid: processInvoicePaidMock,
}));
vi.mock("@/lib/stripe/notifyCreditsTopupPaymentIntent", () => ({
notifyCreditsTopupPaymentIntent: notifyCreditsTopupPaymentIntentMock,
}));
vi.mock("@/lib/stripe/notifyCreditsTopupSession", () => ({
notifyCreditsTopupSession: notifyCreditsTopupSessionMock,
}));
vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));
Expand Down Expand Up @@ -144,6 +159,34 @@ describe("stripeWebhookHandler", () => {
expect(processSubscriptionDeletedMock).toHaveBeenCalledWith(sub);
});

it("delegates invoice.paid to processInvoicePaid", async () => {
const invoice = { id: "in_1", amount_paid: 9900 };
verifyStripeWebhookEventMock.mockResolvedValue({ event: event("invoice.paid", invoice) });
const res = await stripeWebhookHandler(makeReq());
expect(res.status).toBe(200);
expect(processInvoicePaidMock).toHaveBeenCalledWith(invoice);
});

it("notifies alongside the credits grant on payment_intent.succeeded", async () => {
const pi = { id: "pi_1", metadata: { purpose: "credits_auto_recharge" } };
verifyStripeWebhookEventMock.mockResolvedValue({
event: event("payment_intent.succeeded", pi),
});
await stripeWebhookHandler(makeReq());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Missing response-status assertion: payment_intent.succeeded and checkout.session.completed notification tests don't verify the handler returned 200. Since the catch block converts any exception into a 500 response, a routing bug that triggers an error path could silently pass. Add const res = await stripeWebhookHandler(makeReq()) and expect(res.status).toBe(200) to stay consistent with every other test in this file.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/stripe/__tests__/stripeWebhookHandler.test.ts, line 175:

<comment>Missing response-status assertion: `payment_intent.succeeded` and `checkout.session.completed` notification tests don't verify the handler returned 200. Since the catch block converts any exception into a 500 response, a routing bug that triggers an error path could silently pass. Add `const res = await stripeWebhookHandler(makeReq())` and `expect(res.status).toBe(200)` to stay consistent with every other test in this file.</comment>

<file context>
@@ -145,6 +159,34 @@ describe("stripeWebhookHandler", () => {
+    verifyStripeWebhookEventMock.mockResolvedValue({
+      event: event("payment_intent.succeeded", pi),
+    });
+    await stripeWebhookHandler(makeReq());
+    expect(processCreditsTopupPaymentIntentMock).toHaveBeenCalledWith(pi);
+    expect(notifyCreditsTopupPaymentIntentMock).toHaveBeenCalledWith(pi);
</file context>

expect(processCreditsTopupPaymentIntentMock).toHaveBeenCalledWith(pi);
expect(notifyCreditsTopupPaymentIntentMock).toHaveBeenCalledWith(pi);
});

it("notifies alongside the credits grant on checkout.session.completed", async () => {
const session = { id: "cs_1", mode: "payment" };
verifyStripeWebhookEventMock.mockResolvedValue({
event: event("checkout.session.completed", session),
});
await stripeWebhookHandler(makeReq());
expect(processCreditsTopupSessionMock).toHaveBeenCalledWith(session);
expect(notifyCreditsTopupSessionMock).toHaveBeenCalledWith(session);
});

it("returns 500 when the event handler throws (so Stripe retries)", async () => {
verifyStripeWebhookEventMock.mockResolvedValue({
event: event("checkout.session.completed", { id: "cs_x" }),
Expand Down
32 changes: 32 additions & 0 deletions lib/stripe/buildCustomerSalesContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { getCustomerEmail } from "@/lib/stripe/getCustomerEmail";
import { getCustomerLifetimeValue } from "@/lib/stripe/getCustomerLifetimeValue";
import { formatUsd } from "@/lib/stripe/formatUsd";

export interface CustomerSalesContext {
email: string | null;
customerLine: string;
lifetimeLine: string;
lifetimeCents: number;
}

/**
* Resolves the customer identity and lifetime-value lines shared by every
* sales notification. Pass `knownEmail` when the event payload already
* carries the email (e.g. `invoice.customer_email`) to skip a lookup.
*/
export const buildCustomerSalesContext = async (
customerId: string,
knownEmail?: string | null,
): Promise<CustomerSalesContext> => {
const [email, lifetimeCents] = await Promise.all([
knownEmail ? Promise.resolve(knownEmail) : getCustomerEmail(customerId),
getCustomerLifetimeValue(customerId),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Out-of-band invoice.paid notifications can show an incorrect lifetime value and first/recurring label because the shared context always uses getCustomerLifetimeValue(customerId), which sums Stripe charges only. For wire/out-of-band invoices, invoice.amount_paid is included on the invoice but may not have a succeeded charge, so the invoice path should adjust the context/derivation with the invoice payment amount or use an invoice-aware lifetime source.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/stripe/buildCustomerSalesContext.ts, line 23:

<comment>Out-of-band `invoice.paid` notifications can show an incorrect lifetime value and first/recurring label because the shared context always uses `getCustomerLifetimeValue(customerId)`, which sums Stripe charges only. For wire/out-of-band invoices, `invoice.amount_paid` is included on the invoice but may not have a succeeded charge, so the invoice path should adjust the context/derivation with the invoice payment amount or use an invoice-aware lifetime source.</comment>

<file context>
@@ -0,0 +1,32 @@
+): Promise<CustomerSalesContext> => {
+  const [email, lifetimeCents] = await Promise.all([
+    knownEmail ? Promise.resolve(knownEmail) : getCustomerEmail(customerId),
+    getCustomerLifetimeValue(customerId),
+  ]);
+
</file context>

]);

return {
email,
customerLine: email ? `Customer: ${email} (${customerId})` : `Customer: ${customerId}`,
lifetimeLine: `Lifetime value: ${formatUsd(lifetimeCents)}`,
lifetimeCents,
};
};
15 changes: 3 additions & 12 deletions lib/stripe/buildSubscriptionSalesContext.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type Stripe from "stripe";
import { getCustomerEmail } from "@/lib/stripe/getCustomerEmail";
import { getCustomerLifetimeValue } from "@/lib/stripe/getCustomerLifetimeValue";
import { buildCustomerSalesContext } from "@/lib/stripe/buildCustomerSalesContext";
import { formatUsd } from "@/lib/stripe/formatUsd";

export interface SubscriptionSalesContext {
Expand All @@ -21,21 +20,13 @@ export const buildSubscriptionSalesContext = async (
const customerId =
typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id;

const [email, lifetimeCents] = await Promise.all([
getCustomerEmail(customerId),
getCustomerLifetimeValue(customerId),
]);
const { email, customerLine, lifetimeLine } = await buildCustomerSalesContext(customerId);

const price = subscription.items?.data?.[0]?.price;
const planLine =
price?.unit_amount != null && price.recurring?.interval
? `Plan: ${formatUsd(price.unit_amount)}/${price.recurring.interval}`
: "Plan: unknown";

return {
email,
customerLine: email ? `Customer: ${email} (${customerId})` : `Customer: ${customerId}`,
planLine,
lifetimeLine: `Lifetime value: ${formatUsd(lifetimeCents)}`,
};
return { email, customerLine, planLine, lifetimeLine };
};
Loading
Loading