diff --git a/app/api/webhooks/stripe/__tests__/routeTestMocks.ts b/app/api/webhooks/stripe/__tests__/routeTestMocks.ts index 34260da3c..a5fca8825 100644 --- a/app/api/webhooks/stripe/__tests__/routeTestMocks.ts +++ b/app/api/webhooks/stripe/__tests__/routeTestMocks.ts @@ -15,3 +15,19 @@ vi.mock("@/lib/stripe/processCreditsTopupSession", () => ({ vi.mock("@/lib/stripe/processCreditsTopupPaymentIntent", () => ({ processCreditsTopupPaymentIntent: vi.fn(), })); + +vi.mock("@/lib/stripe/processSubscriptionCreated", () => ({ + processSubscriptionCreated: vi.fn(), +})); + +vi.mock("@/lib/stripe/processSubscriptionTrialWillEnd", () => ({ + processSubscriptionTrialWillEnd: vi.fn(), +})); + +vi.mock("@/lib/stripe/processSubscriptionUpdated", () => ({ + processSubscriptionUpdated: vi.fn(), +})); + +vi.mock("@/lib/stripe/processSubscriptionDeleted", () => ({ + processSubscriptionDeleted: vi.fn(), +})); diff --git a/lib/stripe/__tests__/buildSubscriptionSalesContext.test.ts b/lib/stripe/__tests__/buildSubscriptionSalesContext.test.ts new file mode 100644 index 000000000..fa0433472 --- /dev/null +++ b/lib/stripe/__tests__/buildSubscriptionSalesContext.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type Stripe from "stripe"; + +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 { buildSubscriptionSalesContext } = await import( + "@/lib/stripe/buildSubscriptionSalesContext" +); + +const sub = (overrides: object = {}): Stripe.Subscription => + ({ + id: "sub_1", + customer: "cus_9", + items: { + data: [{ price: { unit_amount: 9900, recurring: { interval: "month" } } }], + }, + ...overrides, + }) as unknown as Stripe.Subscription; + +describe("buildSubscriptionSalesContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + getCustomerEmailMock.mockResolvedValue("fan@example.com"); + getLtvMock.mockResolvedValue(12345); + }); + + it("builds customer, plan, and lifetime lines from the subscription", async () => { + await expect(buildSubscriptionSalesContext(sub())).resolves.toEqual({ + email: "fan@example.com", + customerLine: "Customer: fan@example.com (cus_9)", + planLine: "Plan: $99.00/month", + lifetimeLine: "Lifetime value: $123.45", + }); + expect(getCustomerEmailMock).toHaveBeenCalledWith("cus_9"); + expect(getLtvMock).toHaveBeenCalledWith("cus_9"); + }); + + it("uses the id when the customer field is an expanded object", async () => { + const ctx = await buildSubscriptionSalesContext(sub({ customer: { id: "cus_9" } })); + expect(ctx.customerLine).toBe("Customer: fan@example.com (cus_9)"); + }); + + it("falls back to the customer id when no email is found", async () => { + getCustomerEmailMock.mockResolvedValue(null); + const ctx = await buildSubscriptionSalesContext(sub()); + expect(ctx.email).toBeNull(); + expect(ctx.customerLine).toBe("Customer: cus_9"); + }); + + it("tolerates a subscription without price data", async () => { + const ctx = await buildSubscriptionSalesContext(sub({ items: { data: [] } })); + expect(ctx.planLine).toBe("Plan: unknown"); + }); +}); diff --git a/lib/stripe/__tests__/formatStripeTimestamp.test.ts b/lib/stripe/__tests__/formatStripeTimestamp.test.ts new file mode 100644 index 000000000..4078f15f3 --- /dev/null +++ b/lib/stripe/__tests__/formatStripeTimestamp.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from "vitest"; +import { formatStripeTimestamp } from "@/lib/stripe/formatStripeTimestamp"; + +describe("formatStripeTimestamp", () => { + it("formats a unix-seconds timestamp as an ISO date", () => { + expect(formatStripeTimestamp(1783415103)).toBe("2026-07-07"); + }); +}); diff --git a/lib/stripe/__tests__/formatUsd.test.ts b/lib/stripe/__tests__/formatUsd.test.ts new file mode 100644 index 000000000..c2a95b76a --- /dev/null +++ b/lib/stripe/__tests__/formatUsd.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { formatUsd } from "@/lib/stripe/formatUsd"; + +describe("formatUsd", () => { + it("formats cents as dollars", () => { + expect(formatUsd(9900)).toBe("$99.00"); + expect(formatUsd(546)).toBe("$5.46"); + expect(formatUsd(500000)).toBe("$5000.00"); + }); + + it("formats zero", () => { + expect(formatUsd(0)).toBe("$0.00"); + }); + + it("formats negative amounts (net refunds)", () => { + expect(formatUsd(-150)).toBe("-$1.50"); + }); +}); diff --git a/lib/stripe/__tests__/getCustomerEmail.test.ts b/lib/stripe/__tests__/getCustomerEmail.test.ts new file mode 100644 index 000000000..764b46ae3 --- /dev/null +++ b/lib/stripe/__tests__/getCustomerEmail.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { customersRetrieveMock } = vi.hoisted(() => ({ customersRetrieveMock: vi.fn() })); + +vi.mock("@/lib/stripe/client", () => ({ + default: { customers: { retrieve: customersRetrieveMock } }, +})); + +const { getCustomerEmail } = await import("@/lib/stripe/getCustomerEmail"); + +describe("getCustomerEmail", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + afterEach(() => vi.mocked(console.error).mockRestore()); + + it("returns the email of a live customer", async () => { + customersRetrieveMock.mockResolvedValue({ id: "cus_1", email: "fan@example.com" }); + await expect(getCustomerEmail("cus_1")).resolves.toBe("fan@example.com"); + expect(customersRetrieveMock).toHaveBeenCalledWith("cus_1"); + }); + + it("returns null for a deleted customer", async () => { + customersRetrieveMock.mockResolvedValue({ id: "cus_1", deleted: true }); + await expect(getCustomerEmail("cus_1")).resolves.toBeNull(); + }); + + it("returns null when the customer has no email", async () => { + customersRetrieveMock.mockResolvedValue({ id: "cus_1", email: null }); + await expect(getCustomerEmail("cus_1")).resolves.toBeNull(); + }); + + it("returns null and logs when the lookup fails", async () => { + customersRetrieveMock.mockRejectedValue(new Error("stripe down")); + await expect(getCustomerEmail("cus_1")).resolves.toBeNull(); + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/lib/stripe/__tests__/getCustomerLifetimeValue.test.ts b/lib/stripe/__tests__/getCustomerLifetimeValue.test.ts new file mode 100644 index 000000000..d83a2f530 --- /dev/null +++ b/lib/stripe/__tests__/getCustomerLifetimeValue.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { chargesListMock } = vi.hoisted(() => ({ chargesListMock: vi.fn() })); + +vi.mock("@/lib/stripe/client", () => ({ + default: { charges: { list: chargesListMock } }, +})); + +const { getCustomerLifetimeValue } = await import("@/lib/stripe/getCustomerLifetimeValue"); + +const charge = (amount: number, amountRefunded = 0, status = "succeeded") => ({ + amount, + amount_refunded: amountRefunded, + status, +}); + +describe("getCustomerLifetimeValue", () => { + beforeEach(() => vi.clearAllMocks()); + + it("sums succeeded charges in cents, net of refunds", async () => { + chargesListMock.mockResolvedValue({ + data: [charge(9900), charge(546, 546), charge(2000, 500)], + has_more: false, + }); + + await expect(getCustomerLifetimeValue("cus_1")).resolves.toBe(9900 + 0 + 1500); + expect(chargesListMock).toHaveBeenCalledWith({ customer: "cus_1", limit: 100 }); + }); + + it("ignores charges that did not succeed", async () => { + chargesListMock.mockResolvedValue({ + data: [charge(9900, 0, "failed"), charge(546)], + has_more: false, + }); + + await expect(getCustomerLifetimeValue("cus_1")).resolves.toBe(546); + }); + + it("paginates until has_more is false", async () => { + chargesListMock + .mockResolvedValueOnce({ + data: [charge(100), { ...charge(200), id: "ch_last" }], + has_more: true, + }) + .mockResolvedValueOnce({ data: [charge(300)], has_more: false }); + + await expect(getCustomerLifetimeValue("cus_1")).resolves.toBe(600); + expect(chargesListMock).toHaveBeenNthCalledWith(2, { + customer: "cus_1", + limit: 100, + starting_after: "ch_last", + }); + }); + + it("returns 0 for a customer with no charges", async () => { + chargesListMock.mockResolvedValue({ data: [], has_more: false }); + await expect(getCustomerLifetimeValue("cus_1")).resolves.toBe(0); + }); +}); diff --git a/lib/stripe/__tests__/processSubscriptionCreated.test.ts b/lib/stripe/__tests__/processSubscriptionCreated.test.ts new file mode 100644 index 000000000..de0ea9b1f --- /dev/null +++ b/lib/stripe/__tests__/processSubscriptionCreated.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type Stripe from "stripe"; + +const { buildContextMock, sendMock } = vi.hoisted(() => ({ + buildContextMock: vi.fn(), + sendMock: vi.fn(), +})); + +vi.mock("@/lib/stripe/buildSubscriptionSalesContext", () => ({ + buildSubscriptionSalesContext: buildContextMock, +})); +vi.mock("@/lib/telegram/sendSalesNotification", () => ({ sendSalesNotification: sendMock })); + +const { processSubscriptionCreated } = await import("@/lib/stripe/processSubscriptionCreated"); + +const CTX = { + email: "fan@example.com", + customerLine: "Customer: fan@example.com (cus_9)", + planLine: "Plan: $99.00/month", + lifetimeLine: "Lifetime value: $0.00", +}; + +const sub = (overrides: object = {}): Stripe.Subscription => + ({ + id: "sub_1", + customer: "cus_9", + status: "active", + ...overrides, + }) as unknown as Stripe.Subscription; + +describe("processSubscriptionCreated", () => { + beforeEach(() => { + vi.clearAllMocks(); + buildContextMock.mockResolvedValue(CTX); + }); + + it("notifies a trial signup with the trial end date (new card on file)", async () => { + await processSubscriptionCreated(sub({ status: "trialing", trial_end: 1783415103 })); + + expect(sendMock).toHaveBeenCalledTimes(1); + const { email, text } = sendMock.mock.calls[0][0]; + expect(email).toBe("fan@example.com"); + expect(text).toContain("New subscription (trial)"); + expect(text).toContain("Customer: fan@example.com (cus_9)"); + expect(text).toContain("Plan: $99.00/month"); + expect(text).toContain("Trial ends: 2026-07-07"); + expect(text).toContain("Lifetime value: $0.00"); + }); + + it("notifies a direct (non-trial) subscription without a trial line", async () => { + await processSubscriptionCreated(sub()); + + const { text } = sendMock.mock.calls[0][0]; + expect(text).toContain("New subscription"); + expect(text).not.toContain("(trial)"); + expect(text).not.toContain("Trial ends"); + }); +}); diff --git a/lib/stripe/__tests__/processSubscriptionDeleted.test.ts b/lib/stripe/__tests__/processSubscriptionDeleted.test.ts new file mode 100644 index 000000000..80644ac2a --- /dev/null +++ b/lib/stripe/__tests__/processSubscriptionDeleted.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type Stripe from "stripe"; + +const { buildContextMock, sendMock } = vi.hoisted(() => ({ + buildContextMock: vi.fn(), + sendMock: vi.fn(), +})); + +vi.mock("@/lib/stripe/buildSubscriptionSalesContext", () => ({ + buildSubscriptionSalesContext: buildContextMock, +})); +vi.mock("@/lib/telegram/sendSalesNotification", () => ({ sendSalesNotification: sendMock })); + +const { processSubscriptionDeleted } = await import("@/lib/stripe/processSubscriptionDeleted"); + +describe("processSubscriptionDeleted", () => { + beforeEach(() => { + vi.clearAllMocks(); + buildContextMock.mockResolvedValue({ + email: "fan@example.com", + customerLine: "Customer: fan@example.com (cus_9)", + planLine: "Plan: $99.00/month", + lifetimeLine: "Lifetime value: $0.00", + }); + }); + + it("notifies final churn with cancellation feedback", async () => { + await processSubscriptionDeleted({ + id: "sub_1", + customer: "cus_9", + cancellation_details: { feedback: "too_expensive", reason: "cancellation_requested" }, + } as unknown as Stripe.Subscription); + + expect(sendMock).toHaveBeenCalledTimes(1); + const { email, text } = sendMock.mock.calls[0][0]; + expect(email).toBe("fan@example.com"); + expect(text).toContain("Subscription canceled"); + expect(text).toContain("Feedback: too_expensive"); + expect(text).toContain("Lifetime value: $0.00"); + }); + + it("omits the feedback line when Stripe has none", async () => { + await processSubscriptionDeleted({ + id: "sub_1", + customer: "cus_9", + cancellation_details: { feedback: null, reason: null }, + } as unknown as Stripe.Subscription); + + expect(sendMock.mock.calls[0][0].text).not.toContain("Feedback:"); + }); +}); diff --git a/lib/stripe/__tests__/processSubscriptionTrialWillEnd.test.ts b/lib/stripe/__tests__/processSubscriptionTrialWillEnd.test.ts new file mode 100644 index 000000000..511349b59 --- /dev/null +++ b/lib/stripe/__tests__/processSubscriptionTrialWillEnd.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type Stripe from "stripe"; + +const { buildContextMock, sendMock } = vi.hoisted(() => ({ + buildContextMock: vi.fn(), + sendMock: vi.fn(), +})); + +vi.mock("@/lib/stripe/buildSubscriptionSalesContext", () => ({ + buildSubscriptionSalesContext: buildContextMock, +})); +vi.mock("@/lib/telegram/sendSalesNotification", () => ({ sendSalesNotification: sendMock })); + +const { processSubscriptionTrialWillEnd } = await import( + "@/lib/stripe/processSubscriptionTrialWillEnd" +); + +describe("processSubscriptionTrialWillEnd", () => { + beforeEach(() => { + vi.clearAllMocks(); + buildContextMock.mockResolvedValue({ + email: "fan@example.com", + customerLine: "Customer: fan@example.com (cus_9)", + planLine: "Plan: $99.00/month", + lifetimeLine: "Lifetime value: $0.00", + }); + }); + + it("notifies that the trial is ending with the conversion date", async () => { + await processSubscriptionTrialWillEnd({ + id: "sub_1", + trial_end: 1783415103, + } as unknown as Stripe.Subscription); + + expect(sendMock).toHaveBeenCalledTimes(1); + const { email, text } = sendMock.mock.calls[0][0]; + expect(email).toBe("fan@example.com"); + expect(text).toContain("Trial ending soon"); + expect(text).toContain("reach out now"); + expect(text).toContain("Trial ends: 2026-07-07"); + expect(text).toContain("Lifetime value: $0.00"); + }); +}); diff --git a/lib/stripe/__tests__/processSubscriptionUpdated.test.ts b/lib/stripe/__tests__/processSubscriptionUpdated.test.ts new file mode 100644 index 000000000..acfe0dc90 --- /dev/null +++ b/lib/stripe/__tests__/processSubscriptionUpdated.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type Stripe from "stripe"; + +const { buildContextMock, sendMock } = vi.hoisted(() => ({ + buildContextMock: vi.fn(), + sendMock: vi.fn(), +})); + +vi.mock("@/lib/stripe/buildSubscriptionSalesContext", () => ({ + buildSubscriptionSalesContext: buildContextMock, +})); +vi.mock("@/lib/telegram/sendSalesNotification", () => ({ sendSalesNotification: sendMock })); + +const { processSubscriptionUpdated } = await import("@/lib/stripe/processSubscriptionUpdated"); + +const sub = (overrides: object = {}): Stripe.Subscription => + ({ + id: "sub_1", + customer: "cus_9", + cancel_at_period_end: false, + ...overrides, + }) as unknown as Stripe.Subscription; + +const prev = (attrs: object): Partial => attrs as Partial; + +describe("processSubscriptionUpdated", () => { + beforeEach(() => { + vi.clearAllMocks(); + buildContextMock.mockResolvedValue({ + email: "fan@example.com", + customerLine: "Customer: fan@example.com (cus_9)", + planLine: "Plan: $99.00/month", + lifetimeLine: "Lifetime value: $0.00", + }); + }); + + it("notifies churn scheduled on the false→true cancel_at_period_end flip", async () => { + await processSubscriptionUpdated( + sub({ + cancel_at_period_end: true, + cancel_at: 1783415103, + cancellation_details: { feedback: "too_expensive", reason: "cancellation_requested" }, + }), + prev({ cancel_at_period_end: false }), + ); + + expect(sendMock).toHaveBeenCalledTimes(1); + const { text } = sendMock.mock.calls[0][0]; + expect(text).toContain("Churn scheduled"); + expect(text).toContain("Cancels: 2026-07-07"); + expect(text).toContain("Feedback: too_expensive"); + expect(text).toContain("Lifetime value: $0.00"); + }); + + it("notifies customer saved on the true→false flip", async () => { + await processSubscriptionUpdated( + sub({ cancel_at_period_end: false }), + prev({ cancel_at_period_end: true }), + ); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][0].text).toContain("Cancellation withdrawn"); + }); + + it("stays silent on updates that do not flip cancel_at_period_end (e.g. metadata)", async () => { + await processSubscriptionUpdated(sub(), prev({ metadata: { foo: "bar" } })); + expect(sendMock).not.toHaveBeenCalled(); + expect(buildContextMock).not.toHaveBeenCalled(); + }); + + it("stays silent when previous_attributes is undefined", async () => { + await processSubscriptionUpdated(sub({ cancel_at_period_end: true }), undefined); + expect(sendMock).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/stripe/__tests__/stripeWebhookHandler.test.ts b/lib/stripe/__tests__/stripeWebhookHandler.test.ts index c9e3847ae..63ebe389d 100644 --- a/lib/stripe/__tests__/stripeWebhookHandler.test.ts +++ b/lib/stripe/__tests__/stripeWebhookHandler.test.ts @@ -6,10 +6,18 @@ const { verifyStripeWebhookEventMock, processCreditsTopupSessionMock, processCreditsTopupPaymentIntentMock, + processSubscriptionCreatedMock, + processSubscriptionTrialWillEndMock, + processSubscriptionUpdatedMock, + processSubscriptionDeletedMock, } = vi.hoisted(() => ({ verifyStripeWebhookEventMock: vi.fn(), processCreditsTopupSessionMock: vi.fn(), processCreditsTopupPaymentIntentMock: vi.fn(), + processSubscriptionCreatedMock: vi.fn(), + processSubscriptionTrialWillEndMock: vi.fn(), + processSubscriptionUpdatedMock: vi.fn(), + processSubscriptionDeletedMock: vi.fn(), })); vi.mock("@/lib/stripe/verifyStripeWebhookEvent", () => ({ @@ -21,6 +29,18 @@ vi.mock("@/lib/stripe/processCreditsTopupSession", () => ({ vi.mock("@/lib/stripe/processCreditsTopupPaymentIntent", () => ({ processCreditsTopupPaymentIntent: processCreditsTopupPaymentIntentMock, })); +vi.mock("@/lib/stripe/processSubscriptionCreated", () => ({ + processSubscriptionCreated: processSubscriptionCreatedMock, +})); +vi.mock("@/lib/stripe/processSubscriptionTrialWillEnd", () => ({ + processSubscriptionTrialWillEnd: processSubscriptionTrialWillEndMock, +})); +vi.mock("@/lib/stripe/processSubscriptionUpdated", () => ({ + processSubscriptionUpdated: processSubscriptionUpdatedMock, +})); +vi.mock("@/lib/stripe/processSubscriptionDeleted", () => ({ + processSubscriptionDeleted: processSubscriptionDeletedMock, +})); vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); @@ -79,6 +99,51 @@ describe("stripeWebhookHandler", () => { expect(processCreditsTopupPaymentIntentMock).not.toHaveBeenCalled(); }); + it("delegates customer.subscription.created to processSubscriptionCreated", async () => { + const sub = { id: "sub_1", status: "trialing" }; + verifyStripeWebhookEventMock.mockResolvedValue({ + event: event("customer.subscription.created", sub), + }); + const res = await stripeWebhookHandler(makeReq()); + expect(res.status).toBe(200); + expect(processSubscriptionCreatedMock).toHaveBeenCalledWith(sub); + }); + + it("delegates customer.subscription.trial_will_end to processSubscriptionTrialWillEnd", async () => { + const sub = { id: "sub_1", trial_end: 1783415103 }; + verifyStripeWebhookEventMock.mockResolvedValue({ + event: event("customer.subscription.trial_will_end", sub), + }); + const res = await stripeWebhookHandler(makeReq()); + expect(res.status).toBe(200); + expect(processSubscriptionTrialWillEndMock).toHaveBeenCalledWith(sub); + }); + + it("delegates customer.subscription.updated with previous_attributes", async () => { + const sub = { id: "sub_1", cancel_at_period_end: true }; + const previous = { cancel_at_period_end: false }; + verifyStripeWebhookEventMock.mockResolvedValue({ + event: { + id: "evt_1", + type: "customer.subscription.updated", + data: { object: sub, previous_attributes: previous }, + } as unknown as Stripe.Event, + }); + const res = await stripeWebhookHandler(makeReq()); + expect(res.status).toBe(200); + expect(processSubscriptionUpdatedMock).toHaveBeenCalledWith(sub, previous); + }); + + it("delegates customer.subscription.deleted to processSubscriptionDeleted", async () => { + const sub = { id: "sub_1", status: "canceled" }; + verifyStripeWebhookEventMock.mockResolvedValue({ + event: event("customer.subscription.deleted", sub), + }); + const res = await stripeWebhookHandler(makeReq()); + expect(res.status).toBe(200); + expect(processSubscriptionDeletedMock).toHaveBeenCalledWith(sub); + }); + it("returns 500 when the event handler throws (so Stripe retries)", async () => { verifyStripeWebhookEventMock.mockResolvedValue({ event: event("checkout.session.completed", { id: "cs_x" }), diff --git a/lib/stripe/buildSubscriptionSalesContext.ts b/lib/stripe/buildSubscriptionSalesContext.ts new file mode 100644 index 000000000..14d18eb05 --- /dev/null +++ b/lib/stripe/buildSubscriptionSalesContext.ts @@ -0,0 +1,41 @@ +import type Stripe from "stripe"; +import { getCustomerEmail } from "@/lib/stripe/getCustomerEmail"; +import { getCustomerLifetimeValue } from "@/lib/stripe/getCustomerLifetimeValue"; +import { formatUsd } from "@/lib/stripe/formatUsd"; + +export interface SubscriptionSalesContext { + email: string | null; + customerLine: string; + planLine: string; + lifetimeLine: string; +} + +/** + * Resolves the shared message lines every subscription sales notification + * carries: who the customer is, what plan the subscription is on, and the + * customer's lifetime value. + */ +export const buildSubscriptionSalesContext = async ( + subscription: Stripe.Subscription, +): Promise => { + const customerId = + typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id; + + const [email, lifetimeCents] = await Promise.all([ + getCustomerEmail(customerId), + getCustomerLifetimeValue(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)}`, + }; +}; diff --git a/lib/stripe/formatStripeTimestamp.ts b/lib/stripe/formatStripeTimestamp.ts new file mode 100644 index 000000000..b8545a6e8 --- /dev/null +++ b/lib/stripe/formatStripeTimestamp.ts @@ -0,0 +1,5 @@ +/** + * Formats a Stripe unix-seconds timestamp as an ISO date, e.g. "2026-07-07". + */ +export const formatStripeTimestamp = (unixSeconds: number): string => + new Date(unixSeconds * 1000).toISOString().slice(0, 10); diff --git a/lib/stripe/formatUsd.ts b/lib/stripe/formatUsd.ts new file mode 100644 index 000000000..90067963c --- /dev/null +++ b/lib/stripe/formatUsd.ts @@ -0,0 +1,7 @@ +/** + * Formats a Stripe cents amount as a dollar string, e.g. 9900 → "$99.00". + */ +export const formatUsd = (cents: number): string => { + const sign = cents < 0 ? "-" : ""; + return `${sign}$${(Math.abs(cents) / 100).toFixed(2)}`; +}; diff --git a/lib/stripe/getCustomerEmail.ts b/lib/stripe/getCustomerEmail.ts new file mode 100644 index 000000000..33e6bd865 --- /dev/null +++ b/lib/stripe/getCustomerEmail.ts @@ -0,0 +1,22 @@ +import type Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; + +/** + * Resolves a Stripe customer id to its email. Subscription webhook events + * only carry the `cus_` id, so notification builders use this lookup. + * Returns null (never throws) for deleted customers, missing emails, or + * lookup failures — a notification must not fail the webhook for want of + * an email. + */ +export const getCustomerEmail = async (customerId: string): Promise => { + try { + const customer = (await stripeClient.customers.retrieve(customerId)) as + | Stripe.Customer + | Stripe.DeletedCustomer; + if (customer.deleted) return null; + return (customer as Stripe.Customer).email ?? null; + } catch (error) { + console.error("[getCustomerEmail]", { customerId, error }); + return null; + } +}; diff --git a/lib/stripe/getCustomerLifetimeValue.ts b/lib/stripe/getCustomerLifetimeValue.ts new file mode 100644 index 000000000..3fa8d0c30 --- /dev/null +++ b/lib/stripe/getCustomerLifetimeValue.ts @@ -0,0 +1,34 @@ +import Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; + +const PAGE_LIMIT = 100; + +/** + * Lifetime value of a Stripe customer in cents: the sum of all succeeded + * charges net of refunds. Charges cover both subscription payments and + * credits top-ups in one number — invoices alone would miss top-ups. + */ +export const getCustomerLifetimeValue = async (customerId: string): Promise => { + let totalCents = 0; + let startingAfter: string | undefined; + let hasMore = true; + + while (hasMore) { + const listParams: Stripe.ChargeListParams = { customer: customerId, limit: PAGE_LIMIT }; + if (startingAfter) listParams.starting_after = startingAfter; + + const page = await stripeClient.charges.list(listParams); + + for (const charge of page.data) { + if (charge.status !== "succeeded") continue; + totalCents += charge.amount - charge.amount_refunded; + } + + hasMore = page.has_more; + const lastId = page.data.at(-1)?.id; + if (!lastId) break; + startingAfter = lastId; + } + + return totalCents; +}; diff --git a/lib/stripe/processSubscriptionCreated.ts b/lib/stripe/processSubscriptionCreated.ts new file mode 100644 index 000000000..d32108e6e --- /dev/null +++ b/lib/stripe/processSubscriptionCreated.ts @@ -0,0 +1,26 @@ +import type Stripe from "stripe"; +import { buildSubscriptionSalesContext } from "@/lib/stripe/buildSubscriptionSalesContext"; +import { formatStripeTimestamp } from "@/lib/stripe/formatStripeTimestamp"; +import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; + +/** + * Webhook processor for `customer.subscription.created`: announces every + * new subscription in the admin Telegram chat — including trials, which + * are the "new card on file" moment sales outreach cares about. + */ +export async function processSubscriptionCreated(subscription: Stripe.Subscription): Promise { + const ctx = await buildSubscriptionSalesContext(subscription); + const trialing = subscription.status === "trialing"; + + const lines = [ + trialing ? "🆕 New subscription (trial) — new card on file" : "🆕 New subscription", + ctx.customerLine, + ctx.planLine, + ]; + if (trialing && subscription.trial_end) { + lines.push(`Trial ends: ${formatStripeTimestamp(subscription.trial_end)}`); + } + lines.push(ctx.lifetimeLine); + + await sendSalesNotification({ email: ctx.email, text: lines.join("\n") }); +} diff --git a/lib/stripe/processSubscriptionDeleted.ts b/lib/stripe/processSubscriptionDeleted.ts new file mode 100644 index 000000000..dd46797df --- /dev/null +++ b/lib/stripe/processSubscriptionDeleted.ts @@ -0,0 +1,20 @@ +import type Stripe from "stripe"; +import { buildSubscriptionSalesContext } from "@/lib/stripe/buildSubscriptionSalesContext"; +import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; + +/** + * Webhook processor for `customer.subscription.deleted`: the churn is + * final — the subscription has actually ended (period end after a + * scheduled cancellation, or an immediate cancel). + */ +export async function processSubscriptionDeleted(subscription: Stripe.Subscription): Promise { + const ctx = await buildSubscriptionSalesContext(subscription); + + const lines = ["❌ Subscription canceled — churn final", ctx.customerLine, ctx.planLine]; + const { feedback, reason } = subscription.cancellation_details ?? {}; + if (feedback) lines.push(`Feedback: ${feedback}`); + if (reason) lines.push(`Reason: ${reason}`); + lines.push(ctx.lifetimeLine); + + await sendSalesNotification({ email: ctx.email, text: lines.join("\n") }); +} diff --git a/lib/stripe/processSubscriptionTrialWillEnd.ts b/lib/stripe/processSubscriptionTrialWillEnd.ts new file mode 100644 index 000000000..8da237927 --- /dev/null +++ b/lib/stripe/processSubscriptionTrialWillEnd.ts @@ -0,0 +1,23 @@ +import type Stripe from "stripe"; +import { buildSubscriptionSalesContext } from "@/lib/stripe/buildSubscriptionSalesContext"; +import { formatStripeTimestamp } from "@/lib/stripe/formatStripeTimestamp"; +import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; + +/** + * Webhook processor for `customer.subscription.trial_will_end` (Stripe + * fires it 3 days before the trial converts): the proactive-outreach + * window before the customer decides whether to keep paying. + */ +export async function processSubscriptionTrialWillEnd( + subscription: Stripe.Subscription, +): Promise { + const ctx = await buildSubscriptionSalesContext(subscription); + + const lines = ["⏳ Trial ending soon — reach out now", ctx.customerLine, ctx.planLine]; + if (subscription.trial_end) { + lines.push(`Trial ends: ${formatStripeTimestamp(subscription.trial_end)}`); + } + lines.push(ctx.lifetimeLine); + + await sendSalesNotification({ email: ctx.email, text: lines.join("\n") }); +} diff --git a/lib/stripe/processSubscriptionUpdated.ts b/lib/stripe/processSubscriptionUpdated.ts new file mode 100644 index 000000000..b43af31de --- /dev/null +++ b/lib/stripe/processSubscriptionUpdated.ts @@ -0,0 +1,37 @@ +import type Stripe from "stripe"; +import { buildSubscriptionSalesContext } from "@/lib/stripe/buildSubscriptionSalesContext"; +import { formatStripeTimestamp } from "@/lib/stripe/formatStripeTimestamp"; +import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; + +/** + * Webhook processor for `customer.subscription.updated`. Only the + * `cancel_at_period_end` flip is a sales signal: false→true is churn + * scheduled (the human decision moment, days before the subscription + * actually ends — the window where outreach can still save the customer), + * true→false is a saved customer. Every other update (metadata, renewal, + * plan tweaks) stays silent. + */ +export async function processSubscriptionUpdated( + subscription: Stripe.Subscription, + previousAttributes: Partial | undefined, +): Promise { + const previous = previousAttributes?.cancel_at_period_end; + const current = subscription.cancel_at_period_end; + if (typeof previous !== "boolean" || previous === current) return; + + const ctx = await buildSubscriptionSalesContext(subscription); + + const lines = current + ? ["⚠️ Churn scheduled — cancels at period end", ctx.customerLine, ctx.planLine] + : ["💚 Cancellation withdrawn — customer saved", ctx.customerLine, ctx.planLine]; + + if (current && subscription.cancel_at) { + lines.push(`Cancels: ${formatStripeTimestamp(subscription.cancel_at)}`); + } + const { feedback, reason } = subscription.cancellation_details ?? {}; + if (current && feedback) lines.push(`Feedback: ${feedback}`); + if (current && reason) lines.push(`Reason: ${reason}`); + lines.push(ctx.lifetimeLine); + + await sendSalesNotification({ email: ctx.email, text: lines.join("\n") }); +} diff --git a/lib/stripe/stripeWebhookHandler.ts b/lib/stripe/stripeWebhookHandler.ts index 39270685c..acbd7e8a2 100644 --- a/lib/stripe/stripeWebhookHandler.ts +++ b/lib/stripe/stripeWebhookHandler.ts @@ -3,6 +3,10 @@ import type Stripe from "stripe"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { processCreditsTopupPaymentIntent } from "@/lib/stripe/processCreditsTopupPaymentIntent"; import { processCreditsTopupSession } from "@/lib/stripe/processCreditsTopupSession"; +import { processSubscriptionCreated } from "@/lib/stripe/processSubscriptionCreated"; +import { processSubscriptionDeleted } from "@/lib/stripe/processSubscriptionDeleted"; +import { processSubscriptionTrialWillEnd } from "@/lib/stripe/processSubscriptionTrialWillEnd"; +import { processSubscriptionUpdated } from "@/lib/stripe/processSubscriptionUpdated"; import { verifyStripeWebhookEvent } from "@/lib/stripe/verifyStripeWebhookEvent"; export async function stripeWebhookHandler(request: NextRequest): Promise { @@ -18,6 +22,17 @@ export async function stripeWebhookHandler(request: NextRequest): Promise | undefined, + ); + } else if (event.type === "customer.subscription.deleted") { + await processSubscriptionDeleted(event.data.object as Stripe.Subscription); } return NextResponse.json({ received: true }, { status: 200, headers: getCorsHeaders() }); diff --git a/lib/telegram/__tests__/sendSalesNotification.test.ts b/lib/telegram/__tests__/sendSalesNotification.test.ts new file mode 100644 index 000000000..23660ec67 --- /dev/null +++ b/lib/telegram/__tests__/sendSalesNotification.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { sendMessageMock, isTestEmailMock } = vi.hoisted(() => ({ + sendMessageMock: vi.fn(), + isTestEmailMock: vi.fn(), +})); + +vi.mock("@/lib/telegram/sendMessage", () => ({ sendMessage: sendMessageMock })); +vi.mock("@/lib/emails/isTestEmail", () => ({ isTestEmail: isTestEmailMock })); + +const { sendSalesNotification } = await import("@/lib/telegram/sendSalesNotification"); + +describe("sendSalesNotification", () => { + beforeEach(() => { + vi.clearAllMocks(); + isTestEmailMock.mockReturnValue(false); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + afterEach(() => vi.mocked(console.error).mockRestore()); + + it("sends the text via the Telegram sendMessage primitive", async () => { + await sendSalesNotification({ email: "fan@example.com", text: "💸 hello" }); + expect(sendMessageMock).toHaveBeenCalledWith("💸 hello"); + }); + + it("skips test emails", async () => { + isTestEmailMock.mockReturnValue(true); + await sendSalesNotification({ email: "sweetmantech@gmail.com", text: "x" }); + expect(sendMessageMock).not.toHaveBeenCalled(); + }); + + it("still sends when the email is unknown (null)", async () => { + await sendSalesNotification({ email: null, text: "x" }); + expect(sendMessageMock).toHaveBeenCalledWith("x"); + expect(isTestEmailMock).not.toHaveBeenCalled(); + }); + + it("never throws when Telegram fails — logs instead", async () => { + sendMessageMock.mockRejectedValue(new Error("telegram down")); + await expect( + sendSalesNotification({ email: "fan@example.com", text: "x" }), + ).resolves.toBeUndefined(); + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/lib/telegram/sendSalesNotification.ts b/lib/telegram/sendSalesNotification.ts new file mode 100644 index 000000000..36a0cf820 --- /dev/null +++ b/lib/telegram/sendSalesNotification.ts @@ -0,0 +1,28 @@ +import { sendMessage } from "./sendMessage"; +import { isTestEmail } from "@/lib/emails/isTestEmail"; + +interface SalesNotificationParams { + /** Customer email when known — used to filter internal/test accounts. */ + email: string | null; + /** Fully formatted message body. */ + text: string; +} + +/** + * Sends a sales-event message (new subscription, churn, payment) to the + * admin Telegram chat. Skips internal/test accounts and never throws — + * a Telegram failure must not turn a Stripe webhook delivery into a + * non-200 (Stripe would retry and duplicate work). + */ +export const sendSalesNotification = async ({ + email, + text, +}: SalesNotificationParams): Promise => { + if (email && isTestEmail(email)) return; + + try { + await sendMessage(text); + } catch (error) { + console.error("Error sending sales notification:", error); + } +};