Skip to content

feat(stripe): Telegram notifications for payments (invoice.paid + credits top-ups)#767

Merged
sweetmantech merged 2 commits into
mainfrom
feat/stripe-sales-telegram-payments
Jul 8, 2026
Merged

feat(stripe): Telegram notifications for payments (invoice.paid + credits top-ups)#767
sweetmantech merged 2 commits into
mainfrom
feat/stripe-sales-telegram-payments

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Part of recoupable/chat#1858 (PR 2 of 2). Stacked on #766 — merge that first; this PR's base is feat/stripe-sales-telegram-lifecycle so the diff shows only PR 2 changes, and GitHub will retarget it to main when #766 merges.

What

Telegram admin notifications for every payment, with exactly one notification per payment:

Payment Trigger Guard
Subscription payment (card or out-of-band/wire) invoice.paid amount_paid > 0 — $0 trial-start invoices stay silent
Credits auto-recharge payment_intent.succeeded metadata.purpose === "credits_auto_recharge", hooked outside the credit-granting guard (which skips these by design — credits are granted in-thread)
Manual Checkout top-up checkout.session.completed session metadata.purpose === "credits_topup" — the session is the trigger because these PIs carry empty metadata
Bare PIs (subscription/checkout charges) never notified from the PI path, so subscription payments can't double-notify
  • invoice.paid over invoice.payment_succeeded: the latter never fires for invoices marked paid out-of-band (e.g. the $5,000/mo send_invoice subscription paid by wire).
  • First-vs-recurring derives from prior lifetime value (lifetimeCents - amount_paid <= 0), because a trial conversion arrives as billing_reason=subscription_cycle, not subscription_create.
  • Refactor: buildSubscriptionSalesContext now composes the new shared buildCustomerSalesContext (DRY with the payment notifiers).
  • Credit-granting logic (processCreditsTopupPaymentIntent / processCreditsTopupSession) untouched.

Test plan

  • TDD red→green per unit; 203 tests pass across lib/stripe, lib/telegram, app/api/webhooks with zero env vars set.
  • Covered traps: $0 invoice silent, bare-metadata PI silent, manual-topup PI silent (session notifies instead), unpaid session silent.
  • 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

    • Notify on invoice.paid when amount_paid > 0 (covers out-of-band/wire). $0 trial invoices stay silent.
    • Notify credits auto-recharge on payment_intent.succeeded when metadata.purpose === "credits_auto_recharge".
    • Notify manual credits top-up on checkout.session.completed when metadata.purpose === "credits_topup" and payment_status === "paid" (sessions trigger; their PIs lack purpose metadata).
    • Classify first vs recurring from prior lifetime value; include amount, credits (when present), lifetime value, and billing_reason when available.
  • Refactors

    • Added buildCustomerSalesContext and used it in buildSubscriptionSalesContext and payment notifiers (skips email lookup when provided).
    • Hooked notifiers in stripeWebhookHandler alongside credit grants. Bare PaymentIntents never notify to prevent double pings. Credit-granting paths unchanged.

Written for commit 953412d. Summary will update on new commits.

Review in cubic

@cursor

cursor Bot commented Jul 8, 2026

Copy link
Copy Markdown

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.

@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 8, 2026 2:46am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 31 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 90f12ded-a650-4f97-8f85-2f40ea16bc8a

📥 Commits

Reviewing files that changed from the base of the PR and between d6d44e9 and 953412d.

⛔ Files ignored due to path filters (6)
  • app/api/webhooks/stripe/__tests__/routeTestMocks.ts is excluded by !**/__tests__/** and included by app/**
  • lib/stripe/__tests__/buildCustomerSalesContext.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/notifyCreditsTopupPaymentIntent.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/notifyCreditsTopupSession.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/processInvoicePaid.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/stripeWebhookHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (6)
  • lib/stripe/buildCustomerSalesContext.ts
  • lib/stripe/buildSubscriptionSalesContext.ts
  • lib/stripe/notifyCreditsTopupPaymentIntent.ts
  • lib/stripe/notifyCreditsTopupSession.ts
  • lib/stripe/processInvoicePaid.ts
  • lib/stripe/stripeWebhookHandler.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/stripe-sales-telegram-payments

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Verification 2026-07-07:

Check Documented Actual
TDD red before green, every unit 4 new-module test files + handler dispatch cases failed before impl; all green after
Unit suites lib/stripe + lib/telegram + app/api/webhooks 203 tests passed, zero env vars set
Trigger-table traps $0 invoice / bare PI / manual-topup PI / unpaid session all silent covered by dedicated tests, green
tsc --noEmit no errors in touched files 0
Preview (built from 4d7b044) route deployed, signature check intact https://api-a46wcxvga-recoup.vercel.app/api/webhooks/stripe → unsigned POST = 400 {"error":"Missing stripe-signature header"}

Post-merge live check: a real auto-recharge and the next $99 invoice should each produce exactly one Telegram message with lifetime value.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 14 files

Confidence score: 2/5

  • In lib/stripe/stripeWebhookHandler.ts, processCreditsTopupSession can grant credits before notifyCreditsTopupSession fails, 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.ts can build out-of-band invoice.paid context from getCustomerLifetimeValue(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, using ctx.lifetimeCents that 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.ts does 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
Loading

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());

Copy link
Copy Markdown

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.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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
* checkout session (their PIs carry no purpose metadata), and bare PIs
* checkout session (their PIs carry `credits_topup` metadata), and bare PIs

@sweetmantech sweetmantech force-pushed the feat/stripe-sales-telegram-payments branch from 4d7b044 to 0d609eb Compare July 8, 2026 02:18
@cursor

cursor Bot commented Jul 8, 2026

Copy link
Copy Markdown

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>
@sweetmantech sweetmantech force-pushed the feat/stripe-sales-telegram-payments branch from 0d609eb to e60c540 Compare July 8, 2026 02:40
@sweetmantech sweetmantech changed the base branch from feat/stripe-sales-telegram-lifecycle to main July 8, 2026 02:40
@cursor

cursor Bot commented Jul 8, 2026

Copy link
Copy Markdown

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>
@cursor

cursor Bot commented Jul 8, 2026

Copy link
Copy Markdown

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.

@sweetmantech sweetmantech merged commit ed5ff6c into main Jul 8, 2026
6 checks passed
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Live end-to-end verification 2026-07-08, against preview api-4wskir3jn (branch head, code identical to merged ed5ff6c9):

Method: same recipe as #766 — temporary Stripe webhook endpoint (we_1TqlTR…) pointed at the preview, branch-scoped STRIPE_WEBHOOK_SECRET, then six real past payment events officially resent by Stripe (notification-only code paths; no credits or charges moved).

Real event replayed Expected Result
$5.46 auto-recharge PI (purpose=credits_auto_recharge, 2026-07-07) 💳 auto-recharge msg w/ credits + LTV ✅ delivered, Telegram received
$99 invoice.paid, billing_reason=subscription_cycle (2026-06-29) 💰 First subscription payment (prior-LTV labeling, not billing_reason) ✅ Telegram received, correctly labeled first
$5,000 invoice.paid (2026-06-23) 💰 First payment w/ $5,000 LTV ✅ Telegram received
$0 trial-start invoice.paid (2026-07-08) silence ($0 guard) ✅ no message, 200
Bare payment_intent.succeeded for the same $99 payment silence (no double-notify) ✅ no message, 200
Auto-recharge PI on internal account (isTestEmail) silence (internal filter) ✅ no message, 200

All six events pending_webhooks: 0; exactly 3 Telegram messages received, confirmed by @sweetmantech. One payment = one notification, verified against the exact double-notify and missed-manual-topup traps from chat#1858.

Cleanup done: temp endpoint deleted, branch env var removed. Ongoing prod confirmation: the next organic payment should produce exactly one message.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant