From e60c540f7f2f21ee52d15d5cde07955dae9856b8 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 7 Jul 2026 20:03:18 -0500 Subject: [PATCH 1/2] feat(stripe): Telegram notifications for payments (invoice.paid + credits top-ups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notify the admin Telegram chat on every payment: subscription invoices via invoice.paid (>$0 — covers out-of-band/wire payments that invoice.payment_succeeded misses; $0 trial invoices stay silent), credits auto-recharges from purpose-stamped PaymentIntents, and manual Checkout top-ups from checkout.session.completed. First-vs-recurring is derived from prior lifetime value, since trial conversions arrive as billing_reason=subscription_cycle. Credit-granting paths untouched. Part of recoupable/chat#1858 (PR 2 of 2, stacked on PR 1 / api#766). Co-Authored-By: Claude Fable 5 --- .../stripe/__tests__/routeTestMocks.ts | 12 ++++ .../buildCustomerSalesContext.test.ts | 42 +++++++++++ .../notifyCreditsTopupPaymentIntent.test.ts | 67 ++++++++++++++++++ .../notifyCreditsTopupSession.test.ts | 61 ++++++++++++++++ .../__tests__/processInvoicePaid.test.ts | 70 +++++++++++++++++++ .../__tests__/stripeWebhookHandler.test.ts | 43 ++++++++++++ lib/stripe/buildCustomerSalesContext.ts | 32 +++++++++ lib/stripe/buildSubscriptionSalesContext.ts | 15 +--- lib/stripe/notifyCreditsTopupPaymentIntent.ts | 38 ++++++++++ lib/stripe/notifyCreditsTopupSession.ts | 28 ++++++++ lib/stripe/processInvoicePaid.ts | 33 +++++++++ lib/stripe/stripeWebhookHandler.ts | 7 ++ 12 files changed, 436 insertions(+), 12 deletions(-) create mode 100644 lib/stripe/__tests__/buildCustomerSalesContext.test.ts create mode 100644 lib/stripe/__tests__/notifyCreditsTopupPaymentIntent.test.ts create mode 100644 lib/stripe/__tests__/notifyCreditsTopupSession.test.ts create mode 100644 lib/stripe/__tests__/processInvoicePaid.test.ts create mode 100644 lib/stripe/buildCustomerSalesContext.ts create mode 100644 lib/stripe/notifyCreditsTopupPaymentIntent.ts create mode 100644 lib/stripe/notifyCreditsTopupSession.ts create mode 100644 lib/stripe/processInvoicePaid.ts diff --git a/app/api/webhooks/stripe/__tests__/routeTestMocks.ts b/app/api/webhooks/stripe/__tests__/routeTestMocks.ts index a5fca882..35b11d4b 100644 --- a/app/api/webhooks/stripe/__tests__/routeTestMocks.ts +++ b/app/api/webhooks/stripe/__tests__/routeTestMocks.ts @@ -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(), +})); diff --git a/lib/stripe/__tests__/buildCustomerSalesContext.test.ts b/lib/stripe/__tests__/buildCustomerSalesContext.test.ts new file mode 100644 index 00000000..7b5cb552 --- /dev/null +++ b/lib/stripe/__tests__/buildCustomerSalesContext.test.ts @@ -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"); + }); +}); diff --git a/lib/stripe/__tests__/notifyCreditsTopupPaymentIntent.test.ts b/lib/stripe/__tests__/notifyCreditsTopupPaymentIntent.test.ts new file mode 100644 index 00000000..b9e711de --- /dev/null +++ b/lib/stripe/__tests__/notifyCreditsTopupPaymentIntent.test.ts @@ -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(); + }); +}); diff --git a/lib/stripe/__tests__/notifyCreditsTopupSession.test.ts b/lib/stripe/__tests__/notifyCreditsTopupSession.test.ts new file mode 100644 index 00000000..d0d71565 --- /dev/null +++ b/lib/stripe/__tests__/notifyCreditsTopupSession.test.ts @@ -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(); + }); +}); diff --git a/lib/stripe/__tests__/processInvoicePaid.test.ts b/lib/stripe/__tests__/processInvoicePaid.test.ts new file mode 100644 index 00000000..3bb21046 --- /dev/null +++ b/lib/stripe/__tests__/processInvoicePaid.test.ts @@ -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"); + }); +}); diff --git a/lib/stripe/__tests__/stripeWebhookHandler.test.ts b/lib/stripe/__tests__/stripeWebhookHandler.test.ts index 63ebe389..78e6ace0 100644 --- a/lib/stripe/__tests__/stripeWebhookHandler.test.ts +++ b/lib/stripe/__tests__/stripeWebhookHandler.test.ts @@ -10,6 +10,9 @@ const { processSubscriptionTrialWillEndMock, processSubscriptionUpdatedMock, processSubscriptionDeletedMock, + processInvoicePaidMock, + notifyCreditsTopupPaymentIntentMock, + notifyCreditsTopupSessionMock, } = vi.hoisted(() => ({ verifyStripeWebhookEventMock: vi.fn(), processCreditsTopupSessionMock: vi.fn(), @@ -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", () => ({ @@ -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": "*" })), })); @@ -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()); + 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" }), diff --git a/lib/stripe/buildCustomerSalesContext.ts b/lib/stripe/buildCustomerSalesContext.ts new file mode 100644 index 00000000..1cfc268c --- /dev/null +++ b/lib/stripe/buildCustomerSalesContext.ts @@ -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 => { + const [email, lifetimeCents] = await Promise.all([ + knownEmail ? Promise.resolve(knownEmail) : getCustomerEmail(customerId), + getCustomerLifetimeValue(customerId), + ]); + + return { + email, + customerLine: email ? `Customer: ${email} (${customerId})` : `Customer: ${customerId}`, + lifetimeLine: `Lifetime value: ${formatUsd(lifetimeCents)}`, + lifetimeCents, + }; +}; diff --git a/lib/stripe/buildSubscriptionSalesContext.ts b/lib/stripe/buildSubscriptionSalesContext.ts index 14d18eb0..a9bb7ff3 100644 --- a/lib/stripe/buildSubscriptionSalesContext.ts +++ b/lib/stripe/buildSubscriptionSalesContext.ts @@ -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 { @@ -21,10 +20,7 @@ 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 = @@ -32,10 +28,5 @@ export const buildSubscriptionSalesContext = async ( ? `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 }; }; diff --git a/lib/stripe/notifyCreditsTopupPaymentIntent.ts b/lib/stripe/notifyCreditsTopupPaymentIntent.ts new file mode 100644 index 00000000..385d35f2 --- /dev/null +++ b/lib/stripe/notifyCreditsTopupPaymentIntent.ts @@ -0,0 +1,38 @@ +import type Stripe from "stripe"; +import { CREDIT_AUTO_RECHARGE_PURPOSE } from "@/lib/credits/const"; +import { buildCustomerSalesContext } from "@/lib/stripe/buildCustomerSalesContext"; +import { formatUsd } from "@/lib/stripe/formatUsd"; +import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; + +/** + * Notification hook for `payment_intent.succeeded`, deliberately outside + * the credit-granting guard in `processCreditsTopupPaymentIntent`: only + * auto-recharge PIs notify here. Manual top-ups notify from their + * checkout session (their PIs carry no purpose metadata), and bare PIs + * (subscription/checkout charges) notify via `invoice.paid` / + * `checkout.session.completed` — never from the PI path, or every + * subscription payment would notify twice. + */ +export async function notifyCreditsTopupPaymentIntent( + paymentIntent: Stripe.PaymentIntent, +): Promise { + if (paymentIntent.metadata?.purpose !== CREDIT_AUTO_RECHARGE_PURPOSE) return; + + const customerId = + typeof paymentIntent.customer === "string" + ? paymentIntent.customer + : paymentIntent.customer?.id; + if (!customerId) return; + + const ctx = await buildCustomerSalesContext(customerId); + + const lines = [ + "💳 Credits auto-recharge", + ctx.customerLine, + `Amount: ${formatUsd(paymentIntent.amount)}`, + ]; + if (paymentIntent.metadata.credits) lines.push(`Credits: ${paymentIntent.metadata.credits}`); + lines.push(ctx.lifetimeLine); + + await sendSalesNotification({ email: ctx.email, text: lines.join("\n") }); +} diff --git a/lib/stripe/notifyCreditsTopupSession.ts b/lib/stripe/notifyCreditsTopupSession.ts new file mode 100644 index 00000000..43cfb8b8 --- /dev/null +++ b/lib/stripe/notifyCreditsTopupSession.ts @@ -0,0 +1,28 @@ +import type Stripe from "stripe"; +import { CREDIT_TOPUP_PURPOSE } from "@/lib/stripe/creditsTopupPurpose"; +import { buildCustomerSalesContext } from "@/lib/stripe/buildCustomerSalesContext"; +import { formatUsd } from "@/lib/stripe/formatUsd"; +import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; + +/** + * Notification hook for `checkout.session.completed` credits top-ups, + * alongside (never inside) the `processCreditsTopupSession` grant. The + * session is the notify trigger for manual top-ups because their + * PaymentIntents carry no purpose metadata. + */ +export async function notifyCreditsTopupSession(session: Stripe.Checkout.Session): Promise { + if (session.metadata?.purpose !== CREDIT_TOPUP_PURPOSE) return; + if (session.payment_status !== "paid") return; + + const customerId = typeof session.customer === "string" ? session.customer : session.customer?.id; + if (!customerId) return; + + const ctx = await buildCustomerSalesContext(customerId, session.customer_details?.email); + + const lines = ["💳 Credits top-up", ctx.customerLine]; + if (session.amount_total != null) lines.push(`Amount: ${formatUsd(session.amount_total)}`); + if (session.metadata.credits) lines.push(`Credits: ${session.metadata.credits}`); + lines.push(ctx.lifetimeLine); + + await sendSalesNotification({ email: ctx.email, text: lines.join("\n") }); +} diff --git a/lib/stripe/processInvoicePaid.ts b/lib/stripe/processInvoicePaid.ts new file mode 100644 index 00000000..38d405f7 --- /dev/null +++ b/lib/stripe/processInvoicePaid.ts @@ -0,0 +1,33 @@ +import type Stripe from "stripe"; +import { buildCustomerSalesContext } from "@/lib/stripe/buildCustomerSalesContext"; +import { formatUsd } from "@/lib/stripe/formatUsd"; +import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; + +/** + * Webhook processor for `invoice.paid`: announces subscription payments + * (card or out-of-band/wire — `invoice.payment_succeeded` misses the + * latter). Skips $0 invoices, which Stripe issues at every trial start. + * "First payment" is derived from the customer's lifetime value before + * this charge, because a trial conversion arrives with + * `billing_reason=subscription_cycle`, not `subscription_create`. + */ +export async function processInvoicePaid(invoice: Stripe.Invoice): Promise { + if (!invoice.amount_paid || invoice.amount_paid <= 0) return; + + const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; + if (!customerId) return; + + const ctx = await buildCustomerSalesContext(customerId, invoice.customer_email); + const priorLifetimeCents = ctx.lifetimeCents - invoice.amount_paid; + const isFirst = priorLifetimeCents <= 0; + + const lines = [ + isFirst ? "💰 First subscription payment 🎉" : "💰 Subscription payment (recurring)", + ctx.customerLine, + `Amount: ${formatUsd(invoice.amount_paid)}`, + ]; + if (invoice.billing_reason) lines.push(`Billing reason: ${invoice.billing_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 acbd7e8a..c1448dca 100644 --- a/lib/stripe/stripeWebhookHandler.ts +++ b/lib/stripe/stripeWebhookHandler.ts @@ -1,8 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import type Stripe from "stripe"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { notifyCreditsTopupPaymentIntent } from "@/lib/stripe/notifyCreditsTopupPaymentIntent"; +import { notifyCreditsTopupSession } from "@/lib/stripe/notifyCreditsTopupSession"; import { processCreditsTopupPaymentIntent } from "@/lib/stripe/processCreditsTopupPaymentIntent"; import { processCreditsTopupSession } from "@/lib/stripe/processCreditsTopupSession"; +import { processInvoicePaid } from "@/lib/stripe/processInvoicePaid"; import { processSubscriptionCreated } from "@/lib/stripe/processSubscriptionCreated"; import { processSubscriptionDeleted } from "@/lib/stripe/processSubscriptionDeleted"; import { processSubscriptionTrialWillEnd } from "@/lib/stripe/processSubscriptionTrialWillEnd"; @@ -20,8 +23,12 @@ export async function stripeWebhookHandler(request: NextRequest): Promise Date: Tue, 7 Jul 2026 21:45:01 -0500 Subject: [PATCH 2/2] chore: trigger preview rebuild for webhook e2e test Co-Authored-By: Claude Fable 5