Skip to content

Comments

feat(billing): add server-side transaction resolution to simplify client polling#2791

Merged
ygrishajev merged 1 commit intomainfrom
feature/billing
Feb 20, 2026
Merged

feat(billing): add server-side transaction resolution to simplify client polling#2791
ygrishajev merged 1 commit intomainfrom
feature/billing

Conversation

@ygrishajev
Copy link
Contributor

@ygrishajev ygrishajev commented Feb 19, 2026

Why

Currently clients poll for balance changes to determine when a payment or coupon has been processed. Comparing balances is unreliable — concurrent transactions or timing issues can cause incorrect results. By exposing transaction status and allowing the server to poll for terminal state, the client can rely on a definitive status rather than balance diffs.

What

  • Add awaitResolved flag to payment and coupon request schemas
  • Add resolveTransaction method using cockatiel retry + timeout to poll DB for terminal transaction status (4s initial delay, 500ms interval, 60s timeout)
  • Expose transactionId and transactionStatus in payment and coupon responses
  • On timeout, return last known transaction status instead of failing
  • Throw 404 immediately if transaction not found (should never happen)
  • Add unit tests for resolveTransaction

Summary by CodeRabbit

  • New Features

    • Payment and coupon endpoints now return transactionId and transactionStatus for clearer transaction visibility.
    • Optional awaitResolved flag lets clients request resolved (real-time) transaction status before the response returns.
    • Transaction status enum expanded to represent intermediate and terminal states (e.g., created, pending, requires_action, succeeded, failed, refunded, canceled).
  • Tests

    • Expanded tests for transaction creation, polling/resolution, terminal states, 3DS flows, and coupon application.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 19, 2026

No actionable comments were generated in the recent review. 🎉


📝 Walkthrough

Walkthrough

Controller and service changes propagate Stripe transaction metadata (transactionId and transactionStatus) through payment and coupon flows; a new resolveTransaction flow polls transactions with backoff/timeout when awaitResolved is requested, returning a terminal status to callers.

Changes

Cohort / File(s) Summary
Schema Definitions
apps/api/src/billing/http-schemas/stripe.schema.ts
Added TransactionStatusSchema enum. Added optional awaitResolved to confirm/apply request schemas. Response schemas now include transactionId and optional transactionStatus.
Controller Implementation & Tests
apps/api/src/billing/controllers/stripe/stripe.controller.ts, apps/api/src/billing/controllers/stripe/stripe.controller.spec.ts
confirmPayment and applyCoupon responses extended to include transactionId and transactionStatus. awaitResolved handling added to return resolved terminal status when requested. Tests added/updated to assert transaction fields and resolve behavior.
Service Implementation & Tests
apps/api/src/billing/services/stripe/stripe.service.ts, apps/api/src/billing/services/stripe/stripe.service.spec.ts
Introduced resolveTransaction public method, TERMINAL_TRANSACTION_STATUSES, and a cockatiel-based executor with delay/backoff/timeout (4s initial delay, 60s timeout). Coupon and payment-intent flows now return transactionId and transactionStatus; tests updated to cover terminal, polling, 404, and failed-terminal cases.
Test Seeders
apps/api/test/seeders/database-stripe-transaction.seeder.ts
Added generateDatabaseStripeTransaction helper to produce realistic StripeTransactionOutput test records with optional overrides.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Controller as StripeController
    participant Service as StripeService
    participant Repo as TransactionRepository
    participant StripeAPI as Stripe API
    participant Executor as resolveTransactionExecutor

    Client->>Controller: applyCoupon(code, awaitResolved=true)
    Controller->>Service: applyCoupon(user, code, awaitResolved)
    Service->>Repo: create transaction record -> returns transactionId
    Service->>StripeAPI: call Stripe (apply coupon / create intent)
    StripeAPI-->>Service: immediate result (may be pending)

    alt awaitResolved = true
        Service->>Executor: resolveTransaction(transactionId)
        Executor->>Repo: fetch transaction status (poll)
        Repo-->>Executor: current status
        loop until terminal or timeout
            alt not terminal
                Executor->>Executor: wait / backoff / retry
                Executor->>Repo: fetch transaction status
                Repo-->>Executor: current status
            else terminal
                Executor-->>Service: final status
            end
        end
    end

    Service-->>Controller: { ..., transactionId, transactionStatus }
    Controller-->>Client: response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through code to chase each id,
Polling softly till the status cried,
Coupons, intents, and resolve's patient art,
Now transaction truths are set apart,
Hop—transactions finish with a happy heart 🌿

🚥 Pre-merge checks | ✅ 2 | ❌ 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 (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding server-side transaction resolution to reduce client polling complexity.
Description check ✅ Passed The description follows the template with clear Why and What sections explaining the motivation and implementation details.

✏️ 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/billing

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

@ygrishajev ygrishajev force-pushed the feature/billing branch 2 times, most recently from 7f03110 to 5224610 Compare February 19, 2026 17:02
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

🧹 Nitpick comments (2)
apps/api/src/billing/services/stripe/stripe.service.ts (1)

489-513: The lastTransaction closure pattern is subtle but correct.

The implementation captures lastTransaction in the outer scope and updates it inside the executor callback (line 500). This allows returning the last known state on timeout. However, this pattern could be confusing to future maintainers.

Consider adding a brief comment explaining this intentional side-effect:

Suggested clarification
   async resolveTransaction(transactionId: string): Promise<StripeTransactionOutput> {
     await wait(4_000);

+    // Track last fetched transaction to return on timeout (updated inside executor)
     let lastTransaction: StripeTransactionOutput | undefined;

     try {
       lastTransaction = await this.resolveTransactionExecutor.execute(async () => {
         const transaction = await this.stripeTransactionRepository.findById(transactionId);

         assert(transaction, 404, "Transaction not found");

+        // Side-effect: update outer variable for timeout fallback
         lastTransaction = transaction;

         return transaction;
       });
     } catch (error) {
       if (!(error instanceof TaskCancelledError)) {
         throw error;
       }
+      // On timeout, fall through to return lastTransaction
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/billing/services/stripe/stripe.service.ts` around lines 489 -
513, In resolveTransaction, the outer-scoped variable lastTransaction is
intentionally mutated inside the resolveTransactionExecutor.execute callback to
allow returning the last known state on TaskCancelledError/timeouts; add a
concise inline comment above the declaration of lastTransaction (and/or above
the execute call) explaining that this closure-side effect is deliberate: it
captures the last fetched transaction inside the executor so that, if the task
is cancelled, the method can assert and return the most recent transaction
instead of undefined; reference resolveTransaction, lastTransaction, and
resolveTransactionExecutor.execute in the comment for clarity.
apps/api/src/billing/controllers/stripe/stripe.controller.ts (1)

117-122: Consider documenting the potential request duration for awaitResolved.

When awaitResolved is true, the request will block for at least 4 seconds (initial delay) and potentially up to 64+ seconds (4s delay + 60s timeout). This could cause HTTP timeouts on some clients or load balancers.

Consider:

  1. Documenting this behavior in API docs/schema
  2. Ensuring downstream infrastructure (load balancers, proxies) supports long-running requests for these endpoints
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/billing/controllers/stripe/stripe.controller.ts` around lines
117 - 122, The endpoint's awaitResolved flag causes the controller to call
this.stripe.resolveTransaction which blocks for an initial ~4s and can extend to
~64s+ (4s delay + 60s timeout); update the API schema/docs for the parameter
awaitResolved and the relevant Stripe controller method to state the expected
blocking duration and timeout behavior, and add a developer note advising
clients and infra (load balancers/proxies) must support long-running requests or
use an asynchronous pattern (webhooks/polling); additionally, confirm any public
API contract/code comments around resolveTransaction reflect these timing
constraints so callers are aware.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/api/src/billing/services/stripe/stripe.service.spec.ts`:
- Around line 1416-1463: Replace all `as any` casts in the resolveTransaction
tests by constructing properly typed Transaction objects (import or define the
Transaction/StripeTransaction type used by stripeTransactionRepository and use
those types for pendingTransaction, succeededTransaction, terminalTransaction,
failedTransaction) and pass them to
stripeTransactionRepository.findById.mockResolvedValue / mockResolvedValueOnce;
in the "throws 404 when transaction is not found" test, assert the rejection is
a 404 by expecting the specific error shape or instance (e.g., await
expect(...).rejects.toMatchObject({ status: 404 }) or
expect(...).rejects.toThrowError(/404|Not Found/) depending on the error type
thrown by resolveTransaction) instead of the generic rejects.toThrow(), and
update imports/types as needed to compile.

---

Nitpick comments:
In `@apps/api/src/billing/controllers/stripe/stripe.controller.ts`:
- Around line 117-122: The endpoint's awaitResolved flag causes the controller
to call this.stripe.resolveTransaction which blocks for an initial ~4s and can
extend to ~64s+ (4s delay + 60s timeout); update the API schema/docs for the
parameter awaitResolved and the relevant Stripe controller method to state the
expected blocking duration and timeout behavior, and add a developer note
advising clients and infra (load balancers/proxies) must support long-running
requests or use an asynchronous pattern (webhooks/polling); additionally,
confirm any public API contract/code comments around resolveTransaction reflect
these timing constraints so callers are aware.

In `@apps/api/src/billing/services/stripe/stripe.service.ts`:
- Around line 489-513: In resolveTransaction, the outer-scoped variable
lastTransaction is intentionally mutated inside the
resolveTransactionExecutor.execute callback to allow returning the last known
state on TaskCancelledError/timeouts; add a concise inline comment above the
declaration of lastTransaction (and/or above the execute call) explaining that
this closure-side effect is deliberate: it captures the last fetched transaction
inside the executor so that, if the task is cancelled, the method can assert and
return the most recent transaction instead of undefined; reference
resolveTransaction, lastTransaction, and resolveTransactionExecutor.execute in
the comment for clarity.

@codecov
Copy link

codecov bot commented Feb 19, 2026

Codecov Report

❌ Patch coverage is 96.77419% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 52.91%. Comparing base (fd91ace) to head (0e3a003).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
.../api/src/billing/services/stripe/stripe.service.ts 95.23% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2791      +/-   ##
==========================================
- Coverage   53.64%   52.91%   -0.73%     
==========================================
  Files        1019      984      -35     
  Lines       23591    22783     -808     
  Branches     5754     5662      -92     
==========================================
- Hits        12655    12056     -599     
+ Misses       9531     9333     -198     
+ Partials     1405     1394      -11     
Flag Coverage Δ *Carryforward flag
api 77.32% <96.77%> (+0.39%) ⬆️
deploy-web 35.93% <ø> (ø) Carriedforward from fd91ace
log-collector ?
notifications 85.56% <ø> (ø) Carriedforward from fd91ace
provider-console 81.48% <ø> (ø) Carriedforward from fd91ace
provider-proxy 82.41% <ø> (ø) Carriedforward from fd91ace
tx-signer ?

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

Files with missing lines Coverage Δ
...rc/billing/controllers/stripe/stripe.controller.ts 29.89% <100.00%> (+29.89%) ⬆️
apps/api/src/billing/http-schemas/stripe.schema.ts 100.00% <100.00%> (ø)
.../api/src/billing/services/stripe/stripe.service.ts 73.18% <95.23%> (+1.33%) ⬆️

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

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.

🧹 Nitpick comments (1)
apps/api/src/billing/services/stripe/stripe.service.spec.ts (1)

1471-1472: Pre-existing any types in setup function.

The setup function contains any types (paymentMethodValidation?: any[] at line 1471 and let lastUser: any at line 1509) which violate coding guidelines. While these are pre-existing and not introduced by this PR, consider addressing them in a follow-up.

As per coding guidelines: "Never use type any or cast to type any. Always define the proper TypeScript types."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/billing/services/stripe/stripe.service.spec.ts` around lines
1471 - 1472, The tests use the unsafe type annotations paymentMethodValidation?:
any[] in the setup function and let lastUser: any; replace these with concrete
types: define or import the appropriate types (e.g., PaymentMethodValidation[],
Stripe.PaymentMethod[], User, Partial<User> or TestUser) and annotate
paymentMethodValidation and lastUser accordingly; update the setup function
signature and the declaration of lastUser to use those types and adjust any
usages in stripe.service.spec.ts to satisfy the stronger types (create minimal
test interfaces if needed and import existing domain types instead of using
any).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/api/src/billing/services/stripe/stripe.service.spec.ts`:
- Around line 1471-1472: The tests use the unsafe type annotations
paymentMethodValidation?: any[] in the setup function and let lastUser: any;
replace these with concrete types: define or import the appropriate types (e.g.,
PaymentMethodValidation[], Stripe.PaymentMethod[], User, Partial<User> or
TestUser) and annotate paymentMethodValidation and lastUser accordingly; update
the setup function signature and the declaration of lastUser to use those types
and adjust any usages in stripe.service.spec.ts to satisfy the stronger types
(create minimal test interfaces if needed and import existing domain types
instead of using any).

…ent polling

Introduce `awaitResolved` flag on payment and coupon endpoints so the
client can rely on the server to poll for terminal transaction status
instead of implementing its own balance-based polling logic.
@ygrishajev ygrishajev added this pull request to the merge queue Feb 20, 2026
Merged via the queue into main with commit 57159ce Feb 20, 2026
53 of 54 checks passed
@ygrishajev ygrishajev deleted the feature/billing branch February 20, 2026 08:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants