feat(stripe): Telegram notifications for payments (invoice.paid + credits top-ups)#767
Conversation
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
Next review available in: 31 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (6)
📒 Files selected for processing (6)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Verification 2026-07-07:
Post-merge live check: a real auto-recharge and the next $99 invoice should each produce exactly one Telegram message with lifetime value. |
There was a problem hiding this comment.
5 issues found across 14 files
Confidence score: 2/5
- In
lib/stripe/stripeWebhookHandler.ts,processCreditsTopupSessioncan grant credits beforenotifyCreditsTopupSessionfails, and the shared retry path can then re-run the grant and credit the same manual top-up more than once. This creates a concrete over-credit/data-integrity risk if merged as-is—separate idempotency boundaries (or persist a post-grant marker) so notification lookup failures cannot replay the grant before merging. lib/stripe/buildCustomerSalesContext.tscan build out-of-bandinvoice.paidcontext fromgetCustomerLifetimeValue(customerId)(Stripe charges only), which can misstate lifetime value and first-vs-recurring classification in notifications. Merging as-is risks incorrect customer/commercial messaging—align the context source with invoice type and include the same revenue components used by the classifier.- In
lib/stripe/processInvoicePaid.ts, usingctx.lifetimeCentsthat includes prior credits top-ups can label a customer’s first subscription invoice as recurring, which is user-visible and confusing. Gate first/recurring on subscription-specific history (not total spend) before merging. lib/stripe/__tests__/stripeWebhookHandler.test.tsdoes not assert HTTP 200 for some success-path notification tests, so regressions that now return 500 can slip through unnoticed. Add explicit status assertions in those cases to de-risk future webhook changes.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="lib/stripe/stripeWebhookHandler.ts">
<violation number="1" location="lib/stripe/stripeWebhookHandler.ts:26">
P1: A manual credits top-up can be credited more than once if the notification context lookup fails after the grant. The handler now awaits `notifyCreditsTopupSession` after `processCreditsTopupSession` in the same retry-producing `try` block, so a transient Stripe lookup error in the notifier returns 500 and makes Stripe replay the already-processed `checkout.session.completed` event. Consider isolating notification failures from the credit-granting webhook response, or making the credit grant idempotent before adding retryable work after it.</violation>
</file>
<file name="lib/stripe/buildCustomerSalesContext.ts">
<violation number="1" location="lib/stripe/buildCustomerSalesContext.ts:23">
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.</violation>
</file>
<file name="lib/stripe/__tests__/stripeWebhookHandler.test.ts">
<violation number="1" location="lib/stripe/__tests__/stripeWebhookHandler.test.ts:175">
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.</violation>
</file>
<file name="lib/stripe/processInvoicePaid.ts">
<violation number="1" location="lib/stripe/processInvoicePaid.ts:21">
P2: A customer who bought credits before converting to a paid subscription will have their first subscription invoice labeled as recurring. `ctx.lifetimeCents` includes credits top-ups as well as subscription charges, so subtracting only `invoice.amount_paid` does not isolate prior subscription payments. Consider deriving this from prior paid invoices for the same subscription (or another subscription-specific signal) so the notification header matches the subscription lifecycle.</violation>
</file>
<file name="lib/stripe/notifyCreditsTopupPaymentIntent.ts">
<violation number="1" location="lib/stripe/notifyCreditsTopupPaymentIntent.ts:11">
P3: The comment describes manual top-up PaymentIntents as having no purpose metadata, but `createCreditsStripeSession` stamps the Checkout PaymentIntent with `purpose: "credits_topup"` plus `paymentMethod: "checkout"`. The code still stays silent because this notifier only accepts `credits_auto_recharge`; updating the comment would avoid misleading future changes to the PI guard.</violation>
</file>
Architecture diagram
sequenceDiagram
participant Stripe as Stripe Webhook
participant Webhook as stripeWebhookHandler
participant InvoiceP as processInvoicePaid
participant TopupPI as notifyCreditsTopupPaymentIntent
participant TopupSess as notifyCreditsTopupSession
participant Ctx as buildCustomerSalesContext
participant DB as DB (email + LTV)
participant Telegram as sendSalesNotification
Note over Stripe,Telegram: Three payment notification flows – exacty one fires per payment
rect rgb(245,245,245)
Note over Stripe,Telegram: Flow 1: Subscription payment (invoice.paid)
Stripe->>Webhook: event type "invoice.paid"
Webhook->>InvoiceP: processInvoicePaid(invoice)
InvoiceP->>InvoiceP: amount_paid <= 0?
alt amount_paid <= 0 ($0 trial invoice)
InvoiceP-->>Webhook: silent
else amount_paid > 0
InvoiceP->>Ctx: buildCustomerSalesContext(customerId, customer_email)
Ctx->>DB: getCustomerEmail(customerId) (if no knownEmail)
Ctx->>DB: getCustomerLifetimeValue(customerId)
DB-->>Ctx: email, lifetimeCents
Ctx-->>InvoiceP: { email, lifetimeCents, customerLine, lifetimeLine }
InvoiceP->>InvoiceP: priorLifetimeCents = lifetimeCents - amount_paid
alt priorLifetimeCents <= 0
Note over InvoiceP: First subscription payment
InvoiceP-->>Telegram: "💰 First subscription payment 🎉"
else priorLifetimeCents > 0
Note over InvoiceP: Recurring payment
InvoiceP-->>Telegram: "💰 Subscription payment (recurring)"
end
Telegram-->>InvoiceP: notification sent
end
InvoiceP-->>Webhook: done
end
rect rgb(245,245,255)
Note over Stripe,Telegram: Flow 2: Credits auto-recharge (payment_intent.succeeded)
Stripe->>Webhook: event type "payment_intent.succeeded"
Webhook->>Webhook: processCreditsTopupPaymentIntent (credit grant – untouched)
Webhook->>TopupPI: notifyCreditsTopupPaymentIntent(pi)
TopupPI->>TopupPI: metadata.purpose === "credits_auto_recharge"?
alt purpose !== "credits_auto_recharge"
TopupPI-->>Webhook: silent (manual top-up or bare PI)
else purpose === "credits_auto_recharge"
TopupPI->>TopupPI: customerId exists?
alt no customer
TopupPI-->>Webhook: silent
else customer exists
TopupPI->>Ctx: buildCustomerSalesContext(customerId)
Ctx->>DB: getCustomerEmail, getCustomerLifetimeValue
DB-->>Ctx:
Ctx-->>TopupPI: context
TopupPI-->>Telegram: "💳 Credits auto-recharge"
Telegram-->>TopupPI: sent
end
end
TopupPI-->>Webhook: done
end
rect rgb(255,245,245)
Note over Stripe,Telegram: Flow 3: Manual credits top-up (checkout.session.completed)
Stripe->>Webhook: event type "checkout.session.completed"
Webhook->>Webhook: processCreditsTopupSession (credit grant – untouched)
Webhook->>TopupSess: notifyCreditsTopupSession(session)
TopupSess->>TopupSess: metadata.purpose === "credits_topup"?
alt purpose !== "credits_topup"
TopupSess-->>Webhook: silent
else purpose === "credits_topup"
TopupSess->>TopupSess: payment_status === "paid"?
alt not paid
TopupSess-->>Webhook: silent
else paid
TopupSess->>TopupSess: customerId exists?
alt no customer
TopupSess-->>Webhook: silent
else customer exists
TopupSess->>Ctx: buildCustomerSalesContext(customerId, customer_details.email)
Ctx->>DB: getCustomerLifetimeValue (email skipped)
DB-->>Ctx:
Ctx-->>TopupSess: context
TopupSess-->>Telegram: "💳 Credits top-up"
Telegram-->>TopupSess: sent
end
end
end
TopupSess-->>Webhook: done
end
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| try { | ||
| if (event.type === "checkout.session.completed") { | ||
| await processCreditsTopupSession(event.data.object as Stripe.Checkout.Session); | ||
| await notifyCreditsTopupSession(event.data.object as Stripe.Checkout.Session); |
There was a problem hiding this comment.
P1: A manual credits top-up can be credited more than once if the notification context lookup fails after the grant. The handler now awaits notifyCreditsTopupSession after processCreditsTopupSession in the same retry-producing try block, so a transient Stripe lookup error in the notifier returns 500 and makes Stripe replay the already-processed checkout.session.completed event. Consider isolating notification failures from the credit-granting webhook response, or making the credit grant idempotent before adding retryable work after it.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/stripe/stripeWebhookHandler.ts, line 26:
<comment>A manual credits top-up can be credited more than once if the notification context lookup fails after the grant. The handler now awaits `notifyCreditsTopupSession` after `processCreditsTopupSession` in the same retry-producing `try` block, so a transient Stripe lookup error in the notifier returns 500 and makes Stripe replay the already-processed `checkout.session.completed` event. Consider isolating notification failures from the credit-granting webhook response, or making the credit grant idempotent before adding retryable work after it.</comment>
<file context>
@@ -20,8 +23,12 @@ export async function stripeWebhookHandler(request: NextRequest): Promise<NextRe
try {
if (event.type === "checkout.session.completed") {
await processCreditsTopupSession(event.data.object as Stripe.Checkout.Session);
+ await notifyCreditsTopupSession(event.data.object as Stripe.Checkout.Session);
} else if (event.type === "payment_intent.succeeded") {
await processCreditsTopupPaymentIntent(event.data.object as Stripe.PaymentIntent);
</file context>
| await notifyCreditsTopupSession(event.data.object as Stripe.Checkout.Session); | |
| try { | |
| await notifyCreditsTopupSession(event.data.object as Stripe.Checkout.Session); | |
| } catch (error) { | |
| console.error("[stripeWebhookHandler] credits top-up notification failed", { | |
| eventId: event.id, | |
| error, | |
| }); | |
| } |
| ): Promise<CustomerSalesContext> => { | ||
| const [email, lifetimeCents] = await Promise.all([ | ||
| knownEmail ? Promise.resolve(knownEmail) : getCustomerEmail(customerId), | ||
| getCustomerLifetimeValue(customerId), |
There was a problem hiding this comment.
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>
| if (!customerId) return; | ||
|
|
||
| const ctx = await buildCustomerSalesContext(customerId, invoice.customer_email); | ||
| const priorLifetimeCents = ctx.lifetimeCents - invoice.amount_paid; |
There was a problem hiding this comment.
P2: A customer who bought credits before converting to a paid subscription will have their first subscription invoice labeled as recurring. ctx.lifetimeCents includes credits top-ups as well as subscription charges, so subtracting only invoice.amount_paid does not isolate prior subscription payments. Consider deriving this from prior paid invoices for the same subscription (or another subscription-specific signal) so the notification header matches the subscription lifecycle.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/stripe/processInvoicePaid.ts, line 21:
<comment>A customer who bought credits before converting to a paid subscription will have their first subscription invoice labeled as recurring. `ctx.lifetimeCents` includes credits top-ups as well as subscription charges, so subtracting only `invoice.amount_paid` does not isolate prior subscription payments. Consider deriving this from prior paid invoices for the same subscription (or another subscription-specific signal) so the notification header matches the subscription lifecycle.</comment>
<file context>
@@ -0,0 +1,33 @@
+ if (!customerId) return;
+
+ const ctx = await buildCustomerSalesContext(customerId, invoice.customer_email);
+ const priorLifetimeCents = ctx.lifetimeCents - invoice.amount_paid;
+ const isFirst = priorLifetimeCents <= 0;
+
</file context>
| verifyStripeWebhookEventMock.mockResolvedValue({ | ||
| event: event("payment_intent.succeeded", pi), | ||
| }); | ||
| await stripeWebhookHandler(makeReq()); |
There was a problem hiding this comment.
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>
| * 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 |
There was a problem hiding this comment.
P3: The comment describes manual top-up PaymentIntents as having no purpose metadata, but createCreditsStripeSession stamps the Checkout PaymentIntent with purpose: "credits_topup" plus paymentMethod: "checkout". The code still stays silent because this notifier only accepts credits_auto_recharge; updating the comment would avoid misleading future changes to the PI guard.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/stripe/notifyCreditsTopupPaymentIntent.ts, line 11:
<comment>The comment describes manual top-up PaymentIntents as having no purpose metadata, but `createCreditsStripeSession` stamps the Checkout PaymentIntent with `purpose: "credits_topup"` plus `paymentMethod: "checkout"`. The code still stays silent because this notifier only accepts `credits_auto_recharge`; updating the comment would avoid misleading future changes to the PI guard.</comment>
<file context>
@@ -0,0 +1,38 @@
+ * 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
</file context>
| * checkout session (their PIs carry no purpose metadata), and bare PIs | |
| * checkout session (their PIs carry `credits_topup` metadata), and bare PIs |
4d7b044 to
0d609eb
Compare
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
…dits top-ups) 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 <noreply@anthropic.com>
0d609eb to
e60c540
Compare
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Live end-to-end verification 2026-07-08, against preview Method: same recipe as #766 — temporary Stripe webhook endpoint (
All six events Cleanup done: temp endpoint deleted, branch env var removed. Ongoing prod confirmation: the next organic payment should produce exactly one message. |
Part of recoupable/chat#1858 (PR 2 of 2). Stacked on #766 — merge that first; this PR's base is
feat/stripe-sales-telegram-lifecycleso the diff shows only PR 2 changes, and GitHub will retarget it tomainwhen #766 merges.What
Telegram admin notifications for every payment, with exactly one notification per payment:
invoice.paidamount_paid > 0— $0 trial-start invoices stay silentpayment_intent.succeededmetadata.purpose === "credits_auto_recharge", hooked outside the credit-granting guard (which skips these by design — credits are granted in-thread)checkout.session.completedmetadata.purpose === "credits_topup"— the session is the trigger because these PIs carry empty metadatainvoice.paidoverinvoice.payment_succeeded: the latter never fires for invoices marked paid out-of-band (e.g. the $5,000/mosend_invoicesubscription paid by wire).lifetimeCents - amount_paid <= 0), because a trial conversion arrives asbilling_reason=subscription_cycle, notsubscription_create.buildSubscriptionSalesContextnow composes the new sharedbuildCustomerSalesContext(DRY with the payment notifiers).processCreditsTopupPaymentIntent/processCreditsTopupSession) untouched.Test plan
lib/stripe,lib/telegram,app/api/webhookswith zero env vars set.tsc --noEmit: no errors in touched files. Preview + live verification results will be posted as PR comments.🤖 Generated with Claude Code
Summary by cubic
Adds Telegram admin notifications for every payment: subscription invoices and credits top-ups. One ping per payment (including wire-paid invoices); no duplicates.
New Features
invoice.paidwhenamount_paid > 0(covers out-of-band/wire). $0 trial invoices stay silent.payment_intent.succeededwhenmetadata.purpose === "credits_auto_recharge".checkout.session.completedwhenmetadata.purpose === "credits_topup"andpayment_status === "paid"(sessions trigger; their PIs lack purpose metadata).billing_reasonwhen available.Refactors
buildCustomerSalesContextand used it inbuildSubscriptionSalesContextand payment notifiers (skips email lookup when provided).stripeWebhookHandleralongside credit grants. Bare PaymentIntents never notify to prevent double pings. Credit-granting paths unchanged.Written for commit 953412d. Summary will update on new commits.