feat(stripe): Telegram sales notifications for subscription lifecycle events#766
Conversation
… events Handle customer.subscription.created / trial_will_end / updated (cancel_at_period_end flips only) / deleted in the Stripe webhook and send admin Telegram messages with customer email, plan, cancellation feedback, and lifetime value (succeeded charges net of refunds). Part of recoupable/chat#1858 (PR 1 of 2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
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. |
|
Important Review skippedReview was skipped due to path filters ⛔ Files ignored due to path filters (3)
CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughAdds new Stripe helper modules (formatUsd, formatStripeTimestamp, getCustomerEmail, getCustomerLifetimeValue, buildSubscriptionSalesContext), four subscription lifecycle webhook processors (created, updated, deleted, trial_will_end), a Telegram sendSalesNotification helper, and wires these processors into the existing Stripe webhook dispatch handler. ChangesSubscription Sales Notification Pipeline
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Stripe
participant stripeWebhookHandler
participant Processor as SubscriptionProcessor
participant buildSubscriptionSalesContext
participant sendSalesNotification
participant Telegram
Stripe->>stripeWebhookHandler: subscription lifecycle event
stripeWebhookHandler->>Processor: dispatch(subscription, previous_attributes?)
Processor->>buildSubscriptionSalesContext: subscription
buildSubscriptionSalesContext-->>Processor: customerLine, planLine, lifetimeLine, email
Processor->>Processor: compose message text
Processor->>sendSalesNotification: email, text
alt email is test account
sendSalesNotification-->>Processor: skip
else
sendSalesNotification->>Telegram: sendMessage(text)
Telegram-->>sendSalesNotification: success or error
end
Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ 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:
Full signed-webhook e2e isn't possible pre-merge (requires the prod |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/stripe/getCustomerLifetimeValue.ts`:
- Around line 11-34: `getCustomerLifetimeValue` currently throws when
`stripeClient.charges.list` fails, which can bubble up through
`buildSubscriptionSalesContext` and break `stripeWebhookHandler`. Update this
helper to be non-throwing by wrapping the charges lookup loop in error handling,
logging the failure with enough context for `customerId`, and returning 0 on any
Stripe/listing error. Keep the existing accumulation logic in
`getCustomerLifetimeValue` unchanged for successful pages.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 6481813a-0554-4fc4-b0da-e7bb49bc9668
⛔ Files ignored due to path filters (12)
app/api/webhooks/stripe/__tests__/routeTestMocks.tsis excluded by!**/__tests__/**and included byapp/**lib/stripe/__tests__/buildSubscriptionSalesContext.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/formatStripeTimestamp.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/formatUsd.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/getCustomerEmail.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/getCustomerLifetimeValue.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/processSubscriptionCreated.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/processSubscriptionDeleted.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/processSubscriptionTrialWillEnd.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/processSubscriptionUpdated.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/stripeWebhookHandler.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/telegram/__tests__/sendSalesNotification.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**
📒 Files selected for processing (11)
lib/stripe/buildSubscriptionSalesContext.tslib/stripe/formatStripeTimestamp.tslib/stripe/formatUsd.tslib/stripe/getCustomerEmail.tslib/stripe/getCustomerLifetimeValue.tslib/stripe/processSubscriptionCreated.tslib/stripe/processSubscriptionDeleted.tslib/stripe/processSubscriptionTrialWillEnd.tslib/stripe/processSubscriptionUpdated.tslib/stripe/stripeWebhookHandler.tslib/telegram/sendSalesNotification.ts
| export const getCustomerLifetimeValue = async (customerId: string): Promise<number> => { | ||
| 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; | ||
| }; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if stripeWebhookHandler wraps notification processing in try/catch
rg -n -A10 'processSubscription|sendSalesNotification|buildSubscriptionSalesContext' lib/stripe/stripeWebhookHandler.tsRepository: recoupable/api
Length of output: 1994
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the notification pipeline and the helper functions mentioned in the review.
rg -n -A40 -B10 'getCustomerEmail|getCustomerLifetimeValue|buildSubscriptionSalesContext|sendSalesNotification|processSubscriptionCreated|processSubscriptionUpdated|processSubscriptionTrialWillEnd|processSubscriptionDeleted|Promise\.all' lib/stripeRepository: recoupable/api
Length of output: 50370
Make getCustomerLifetimeValue non-throwing
getCustomerEmail already degrades safely; this lookup should do the same. A transient charges.list failure bubbles out of buildSubscriptionSalesContext and makes stripeWebhookHandler return 500 for the event. Log the error and return 0 here.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/stripe/getCustomerLifetimeValue.ts` around lines 11 - 34,
`getCustomerLifetimeValue` currently throws when `stripeClient.charges.list`
fails, which can bubble up through `buildSubscriptionSalesContext` and break
`stripeWebhookHandler`. Update this helper to be non-throwing by wrapping the
charges lookup loop in error handling, logging the failure with enough context
for `customerId`, and returning 0 on any Stripe/listing error. Keep the existing
accumulation logic in `getCustomerLifetimeValue` unchanged for successful pages.
There was a problem hiding this comment.
2 issues found across 23 files
Confidence score: 3/5
- In
lib/stripe/buildSubscriptionSalesContext.ts, a failure in the lifetime-value API currently bubbles up and can return HTTP 500 to Stripe, which risks dropped/retried webhook deliveries and missed sales notifications — wrap the LTV call with a safe fallback (e.g.,0cents) before merging. - In
lib/telegram/__tests__/sendSalesNotification.test.ts,vi.clearAllMocks()can leave a priormockRejectedValueimplementation active, making later tests order-dependent and potentially masking real regressions — reset mock implementations inbeforeEachto keep the suite deterministic.
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/buildSubscriptionSalesContext.ts">
<violation number="1" location="lib/stripe/buildSubscriptionSalesContext.ts:26">
P2: Lifetime-value API failure drops the notification and returns 500 to Stripe. Wrap the LTV call so a transient Stripe error doesn't break webhook delivery — return 0 cents as fallback, consistent with the pattern `getCustomerEmail` already uses.</violation>
</file>
<file name="lib/telegram/__tests__/sendSalesNotification.test.ts">
<violation number="1" location="lib/telegram/__tests__/sendSalesNotification.test.ts:15">
P3: This suite can become order-dependent after the Telegram-failure case because `vi.clearAllMocks()` leaves `sendMessageMock.mockRejectedValue(...)` in place. Resetting mock implementations in `beforeEach` would keep later tests from inheriting the rejected `sendMessage` behavior.</violation>
</file>
Architecture diagram
sequenceDiagram
participant Stripe as Stripe Webhook
participant API as Next.js API Route
participant Handler as stripeWebhookHandler
participant Created as processSubscriptionCreated
participant Updated as processSubscriptionUpdated
participant Ctx as buildSubscriptionSalesContext
participant Email as getCustomerEmail
participant LTV as getCustomerLifetimeValue
participant Notify as sendSalesNotification
participant Test as isTestEmail
participant TG as Telegram Bot
Note over Stripe,TG: NEW: Subscription lifecycle notifications
Stripe->>API: POST /api/webhooks/stripe (signed event)
API->>Handler: dispatch event
Handler->>Handler: verifyStripeWebhookEvent (existing)
alt event.type == "customer.subscription.created"
Handler->>Created: processSubscriptionCreated(subscription)
Created->>Ctx: buildSubscriptionSalesContext(subscription)
Ctx->>+Email: getCustomerEmail(customerId)
Ctx->>+LTV: getCustomerLifetimeValue(customerId)
Email->>Stripe: customers.retrieve(customerId)
Stripe-->>Email: customer (or deleted/null)
alt customer deleted or no email
Email-->>Ctx: null
else
Email-->>Ctx: "fan@example.com"
end
LTV->>Stripe: charges.list(customer, paginated)
Stripe-->>LTV: succeeded charges net refunds
LTV-->>Ctx: total cents
Ctx-->>Created: {email, customerLine, planLine, lifetimeLine}
Note over Created: Formats message with trial info if applicable
Created->>Notify: sendSalesNotification({email, text})
Notify->>Test: isTestEmail(email)
alt email present and test account
Test-->>Notify: true → skip
else
Test-->>Notify: false → proceed
Notify->>TG: sendMessage(text)
alt Telegram fails
TG--xNotify: error
Notify->>Notify: console.error, swallow
else success
TG-->>Notify: OK
end
end
Notify-->>Created: void
else event.type == "customer.subscription.trial_will_end"
Handler->>Created: processSubscriptionTrialWillEnd(subscription)
Note over Created,Notify: Same pattern → proactive outreach alert
else event.type == "customer.subscription.updated"
Handler->>Updated: processSubscriptionUpdated(sub, previous_attributes)
alt cancel_at_period_end flipped false→true
Updated->>Updated: churn scheduled message
else flipped true→false
Updated->>Updated: cancellation withdrawn message
else no flip or no previous
Updated->>Updated: return early, no notification
end
Updated->>Ctx: buildSubscriptionSalesContext (only if flip detected)
Ctx->>Email: getCustomerEmail
Ctx->>LTV: getCustomerLifetimeValue
Ctx-->>Updated: context
Updated->>Notify: sendSalesNotification(...)
Notify->>Test: isTestEmail
Notify->>TG: sendMessage (or skip)
else event.type == "customer.subscription.deleted"
Handler->>Updated: processSubscriptionDeleted(subscription)
Note over Updated,Notify: Final churn message with cancellation_details
end
Handler-->>API: 200 {received: true}
API-->>Stripe: HTTP 200
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
|
||
| const [email, lifetimeCents] = await Promise.all([ | ||
| getCustomerEmail(customerId), | ||
| getCustomerLifetimeValue(customerId), |
There was a problem hiding this comment.
P2: Lifetime-value API failure drops the notification and returns 500 to Stripe. Wrap the LTV call so a transient Stripe error doesn't break webhook delivery — return 0 cents as fallback, consistent with the pattern getCustomerEmail already uses.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/stripe/buildSubscriptionSalesContext.ts, line 26:
<comment>Lifetime-value API failure drops the notification and returns 500 to Stripe. Wrap the LTV call so a transient Stripe error doesn't break webhook delivery — return 0 cents as fallback, consistent with the pattern `getCustomerEmail` already uses.</comment>
<file context>
@@ -0,0 +1,41 @@
+
+ const [email, lifetimeCents] = await Promise.all([
+ getCustomerEmail(customerId),
+ getCustomerLifetimeValue(customerId),
+ ]);
+
</file context>
|
|
||
| describe("sendSalesNotification", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); |
There was a problem hiding this comment.
P3: This suite can become order-dependent after the Telegram-failure case because vi.clearAllMocks() leaves sendMessageMock.mockRejectedValue(...) in place. Resetting mock implementations in beforeEach would keep later tests from inheriting the rejected sendMessage behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/telegram/__tests__/sendSalesNotification.test.ts, line 15:
<comment>This suite can become order-dependent after the Telegram-failure case because `vi.clearAllMocks()` leaves `sendMessageMock.mockRejectedValue(...)` in place. Resetting mock implementations in `beforeEach` would keep later tests from inheriting the rejected `sendMessage` behavior.</comment>
<file context>
@@ -0,0 +1,45 @@
+
+describe("sendSalesNotification", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ isTestEmailMock.mockReturnValue(false);
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
</file context>
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. |
There was a problem hiding this comment.
0 issues found across 3 files (changes from recent commits).
Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.
Re-trigger cubic
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: temporary second Stripe webhook endpoint (
All 5 expected Telegram messages confirmed received in the admin chat by @sweetmantech; the silent case stayed silent. All 6 events show Cleanup done: temp webhook endpoint deleted, branch-scoped env var removed, test customer deleted (subscription was trial-only — $0 charged). One finding for the fleet: the preview environment's Verbatim Telegram transcript (admin chat, 2026-07-07 21:34 local, posted by @sweetmantech): Every field correct: customer email + id, plan, trial-end date, cancellation feedback/reason, and lifetime value. The metadata-only update produced no message (silent-guard verified). |
Part of recoupable/chat#1858 (PR 1 of 2 — see the PR matrix there).
What
Extends the existing Stripe webhook receiver (
app/api/webhooks/stripe) to send admin-Telegram sales notifications for the subscription lifecycle:customer.subscription.createdcustomer.subscription.trial_will_endcustomer.subscription.updatedcancel_at_period_endfalse→true flip / 💚 Cancellation withdrawn on true→false; all other updates stay silentcustomer.subscription.deletedcancellation_detailsfeedback/reasonEvery message carries lifetime customer value (succeeded charges net of refunds, via paginated
charges.list).New units (one exported function per file):
getCustomerLifetimeValue,getCustomerEmail(deleted-customer tolerant, never throws),formatUsd,formatStripeTimestamp,buildSubscriptionSalesContext,sendSalesNotification(skipsisTestEmailaccounts, swallows Telegram failures so a notification can never turn a webhook delivery into a non-200), and four event processors wired intostripeWebhookHandler.Why
On 2026-07-05 a Pro-trial customer scheduled cancellation one day before their first charge and nothing anywhere told us they had ever signed up. Full context, live-data evidence, and architecture decisions: recoupable/chat#1858.
Notes for review
cancel_at_period_endflip (notdeleted) is the primary churn signal — deletion fires at period end, potentially weeks after the decision..devURL) is already live — these events currently no-op with a 200 until this merges.invoice.paid, top-ups) are PR 2, stacked on this branch.Test plan
lib/stripe,lib/telegram,app/api/webhookswith zero env vars set.tsc --noEmit: no errors in touched files (pre-existing unrelated test-file errors onmainremain).🤖 Generated with Claude Code
Summary by cubic
Adds admin Telegram sales notifications for Stripe subscription lifecycle events to surface trials, scheduled churn, and saved customers without blocking webhook delivery.
New Features
customer.subscription.created,trial_will_end,updated(onlycancel_at_period_endflips), anddeletedinstripeWebhookHandler.buildSubscriptionSalesContext,getCustomerEmail,getCustomerLifetimeValue(succeeded charges net of refunds, paginated),formatUsd,formatStripeTimestamp;sendSalesNotificationskipsisTestEmailaccounts and never throws so webhooks stay 200.Refactors
Written for commit 9d52d00. Summary will update on new commits.
Summary by CodeRabbit