Skip to content

fix(billing): delegate coupon wallet top-up to Stripe webhook for reliable payment processing#2730

Open
ygrishajev wants to merge 1 commit intomainfrom
feature/coupons
Open

fix(billing): delegate coupon wallet top-up to Stripe webhook for reliable payment processing#2730
ygrishajev wants to merge 1 commit intomainfrom
feature/coupons

Conversation

@ygrishajev
Copy link
Contributor

@ygrishajev ygrishajev commented Feb 13, 2026

Move wallet crediting from the synchronous applyCoupon flow to the invoice.payment_succeeded webhook handler. This leverages Stripe's built-in retry mechanism to ensure users receive credits even if the initial top-up call fails transiently.

Summary by CodeRabbit

  • New Features

    • Wallet top-ups now process invoice.payment_succeeded events and centralize transaction update + top-up flows.
    • Coupon application now follows an invoice-first lifecycle with explicit invoice items and an audit transaction record.
  • Bug Fixes / Reliability

    • Improved locking, idempotency, and error handling for invoice/payment processing; captures card and receipt details reliably.
    • Failed coupon/invoice flows are rolled back to prevent unintended credits.
  • Tests

    • Added extensive tests for invoice flows, edge cases, and idempotency.

@ygrishajev ygrishajev requested a review from a team as a code owner February 13, 2026 17:21
@ygrishajev ygrishajev changed the title fix(billing): delegate coupon wallet top-up to Stripe webhook for rel… fix(billing): delegate coupon wallet top-up to Stripe webhook for reliable payment processing Feb 13, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 13, 2026

📝 Walkthrough

Walkthrough

This PR adds invoice-driven processing for Stripe payments: a repository method to find transactions by invoice ID, webhook flows to top up wallets from invoice events with new helper methods, locking and update logic, expanded integration tests for invoice and locking scenarios, and refactors coupon application to create invoice items and pending transactions with rollback on error.

Changes

Cohort / File(s) Summary
Repository Enhancement
apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.ts
Added findByInvoiceId(invoiceId: string) to query StripeTransaction by stripeInvoiceId and return mapped StripeTransactionOutput or undefined.
Webhook Service Core Logic
apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts
Added tryToTopUpWalletFromInvoice(event) entry for invoice.payment_succeeded; added topUpWalletFromTransaction and updateTransactionAndTopUp helpers; refactored payment_intent.succeeded to delegate to centralized transaction flow; added locking (findOneByAndLock), updated transaction persistence (status, charge IDs, card info, receiptUrl, paymentIntentId), and new error logs.
Webhook Service Tests
apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts
Expanded tests to cover invoice-based top-ups, added mocks for findOneByAndLock, findByInvoiceId, findByPaymentIntentId; added createInvoicePaymentSucceededEvent helper; extensive success/failure/idempotency and missing-data scenarios; assertions for card/receipt fields and top-up invocation.
Stripe Service (Coupon Flow)
apps/api/src/billing/services/stripe/stripe.service.ts
Refactored applyCoupon to create a draft invoice, create an invoice item for coupon amount, create a pending StripeTransaction tied to the invoice, finalize the invoice, and avoid immediate wallet top-up; added rollback via voidInvoice on invoice-item failure.
Stripe Service Tests
apps/api/src/billing/services/stripe/stripe.service.spec.ts
Updated tests to expect invoiceItems.create calls, assert invoice item fields and voidInvoice on error, verify pending transaction creation (no immediate wallet top-up), and adjust success/error expectations accordingly.

Sequence Diagram(s)

sequenceDiagram
    participant Webhook as Stripe Webhook
    participant Service as StripeWebhookService
    participant Repo as StripeTransactionRepository
    participant Stripe as Stripe API
    participant Refill as RefillService
    participant DB as Database

    Webhook->>Service: invoice.payment_succeeded(event)
    Service->>Repo: findByInvoiceId(invoiceId)
    Repo->>DB: query stripeTransaction where stripeInvoiceId
    DB-->>Repo: transaction row (or none)
    Repo-->>Service: StripeTransactionOutput (or undefined)

    alt transaction found
        Service->>Stripe: retrieve charge (if chargeId present)
        Stripe-->>Service: charge details (card, receiptUrl)
        Service->>Service: topUpWalletFromTransaction(transaction, charge)
        Service->>Repo: findOneByAndLock(transactionId)
        Repo->>DB: lock & fetch transaction
        DB-->>Repo: locked transaction
        Repo-->>Service: locked transaction
        Service->>Repo: update transaction fields (status, charge ids, card info, receiptUrl, paymentIntentId)
        Repo->>DB: persist updates
        Service->>Refill: refillService.topUp(userId, amount)
        Refill->>DB: apply wallet credit
        DB-->>Refill: success
        Refill-->>Service: confirmation
    else no matching transaction
        Service-->>Service: log INVOICE_NO_MATCHING_TRANSACTION and return
    end
Loading
sequenceDiagram
    participant Client as Client
    participant StripeSvc as StripeService
    participant Stripe as Stripe API
    participant Repo as StripeTransactionRepository
    participant Webhook as Stripe Webhook

    Client->>StripeSvc: applyCoupon(userId, coupon)
    StripeSvc->>Stripe: create draft invoice (with discount)
    Stripe-->>StripeSvc: invoice
    StripeSvc->>Repo: create StripeTransaction(type: coupon_claim, status: pending, stripeInvoiceId)
    Repo-->>StripeSvc: StripeTransactionOutput
    StripeSvc->>Stripe: invoiceItems.create(amount, customer, invoice)
    alt item created
        Stripe-->>StripeSvc: invoice item
        StripeSvc->>Stripe: finalize invoice
        Stripe-->>Webhook: invoice.payment_succeeded (later)
        StripeSvc-->>Client: return success (pending)
    else item creation fails
        StripeSvc->>Stripe: voidInvoice(invoice)
        Stripe-->>StripeSvc: invoice voided
        StripeSvc-->>Client: throw error
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped from intent to invoice lane,

I placed an item, then waited for the rain,
Webhooks tapped the ledger's song,
Locked the row and righted the wrong,
Carrots queued — refill comes along! 🥕

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: moving coupon wallet top-up from synchronous applyCoupon to the Stripe webhook for reliable payment processing.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/coupons

No actionable comments were generated in the recent review. 🎉


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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/api/src/billing/services/stripe/stripe.service.ts (1)

403-432: ⚠️ Potential issue | 🟠 Major

Potential race condition: transaction record created after invoice finalization.

The invoice is finalized at line 411, which may trigger the invoice.payment_succeeded webhook. However, the transaction record with the stripeInvoiceId is created at lines 422-432, after finalization. If the webhook fires before the transaction insert completes, findByInvoiceId will return undefined and the wallet top-up will be silently skipped.

Consider creating the transaction record (in pending status) before finalizing the invoice, or ensure the webhook handler has retry logic for this case.

🔧 Proposed fix: Create transaction before invoice finalization
       await this.invoiceItems.create({
         amount: amountToAdd,
         customer: currentUser.stripeCustomerId,
         invoice: invoice.id,
         currency: "usd",
         description: "Akash Network Console"
       });

+      await this.stripeTransactionRepository.create({
+        userId: currentUser.id,
+        type: "coupon_claim",
+        status: "pending",
+        amount: amountToAdd,
+        currency: coupon.currency ?? "usd",
+        stripeCouponId: coupon.id,
+        stripePromotionCodeId: updateField === "promotion_code" ? updateId : undefined,
+        stripeInvoiceId: invoice.id,
+        description: `Coupon: ${coupon.name || coupon.id}`
+      });
+
       invoice = await this.invoices.finalizeInvoice(invoice.id);

       this.loggerService.info({
         event: "INVOICE_FINALIZED_AND_PAID",
         userId: currentUser.id,
         invoiceId: invoice.id,
         status: invoice.status,
         amountDue: invoice.amount_due,
         amountPaid: invoice.amount_paid
       });

-      await this.stripeTransactionRepository.create({
-        userId: currentUser.id,
-        type: "coupon_claim",
-        status: "pending",
-        amount: amountToAdd,
-        currency: coupon.currency ?? "usd",
-        stripeCouponId: coupon.id,
-        stripePromotionCodeId: updateField === "promotion_code" ? updateId : undefined,
-        stripeInvoiceId: invoice.id,
-        description: `Coupon: ${coupon.name || coupon.id}`
-      });

       return { coupon: couponOrPromotion, amountAdded: amountToAdd / 100 };

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts`:
- Around line 216-225: After acquiring the lock with
this.stripeTransactionRepository.findOneByAndLock in stripe-webhook.service.ts,
add a defensive null/undefined check for the returned transaction variable
before proceeding (e.g., before the transaction?.status check and any subsequent
call to updateById); if transaction is missing, log an event (e.g.,
"TRANSACTION_NOT_FOUND_AFTER_LOCK") with params.transactionId and return early
to avoid calling updateById on a non-existent record. Ensure you reference the
same repository method (findOneByAndLock) and the transaction variable so the
guard covers all code paths that assume a present transaction.

@codecov
Copy link

codecov bot commented Feb 13, 2026

Codecov Report

❌ Patch coverage is 80.70175% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 51.38%. Comparing base (fd94df9) to head (ea5c25a).

Files with missing lines Patch % Lines
.../services/stripe-webhook/stripe-webhook.service.ts 84.78% 7 Missing ⚠️
...tripe-transaction/stripe-transaction.repository.ts 0.00% 2 Missing and 1 partial ⚠️
.../api/src/billing/services/stripe/stripe.service.ts 87.50% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2730      +/-   ##
==========================================
- Coverage   52.12%   51.38%   -0.74%     
==========================================
  Files        1055     1020      -35     
  Lines       27972    27170     -802     
  Branches     6334     6250      -84     
==========================================
- Hits        14580    13961     -619     
+ Misses      12918    12735     -183     
  Partials      474      474              
Flag Coverage Δ *Carryforward flag
api 75.04% <80.70%> (+0.05%) ⬆️
deploy-web 37.18% <ø> (ø) Carriedforward from fd94df9
log-collector ?
notifications 87.94% <ø> (ø) Carriedforward from fd94df9
provider-console 81.48% <ø> (ø) Carriedforward from fd94df9
provider-proxy 84.35% <ø> (ø) Carriedforward from fd94df9
tx-signer ?

*This pull request uses carry forward flags. Click here to find out more.

Files with missing lines Coverage Δ
.../api/src/billing/services/stripe/stripe.service.ts 71.85% <87.50%> (-0.24%) ⬇️
...tripe-transaction/stripe-transaction.repository.ts 28.57% <0.00%> (-2.20%) ⬇️
.../services/stripe-webhook/stripe-webhook.service.ts 77.05% <84.78%> (+0.50%) ⬆️

... and 37 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…iable payment processing

Move wallet crediting from the synchronous applyCoupon flow to the
invoice.payment_succeeded webhook handler. This leverages Stripe's
built-in retry mechanism to ensure users receive credits even if the
initial top-up call fails transiently.
Copy link
Contributor

@baktun14 baktun14 left a comment

Choose a reason for hiding this comment

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

Let's make sure to test this thoroughly on beta before releasing to prod 🙏

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.

2 participants