-
Notifications
You must be signed in to change notification settings - Fork 9
feat(stripe): Telegram notifications for payments (invoice.paid + credits top-ups) #767
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"); | ||
| }); | ||
| }); |
| 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(); | ||
| }); | ||
| }); |
| 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(); | ||
| }); | ||
| }); |
| 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"); | ||
| }); | ||
| }); |
| 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), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Out-of-band Prompt for AI agents |
||
| ]); | ||
|
|
||
| return { | ||
| email, | ||
| customerLine: email ? `Customer: ${email} (${customerId})` : `Customer: ${customerId}`, | ||
| lifetimeLine: `Lifetime value: ${formatUsd(lifetimeCents)}`, | ||
| lifetimeCents, | ||
| }; | ||
| }; | ||
There was a problem hiding this comment.
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.succeededandcheckout.session.completednotification 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. Addconst res = await stripeWebhookHandler(makeReq())andexpect(res.status).toBe(200)to stay consistent with every other test in this file.Prompt for AI agents