Skip to content

fix(billing): make top-up charges idempotent across client retries - #3449

Merged
baktun14 merged 4 commits into
mainfrom
fix/billing-idempotent-topup-charges
Jul 21, 2026
Merged

fix(billing): make top-up charges idempotent across client retries#3449
baktun14 merged 4 commits into
mainfrom
fix/billing-idempotent-topup-charges

Conversation

@baktun14

@baktun14 baktun14 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Why

Customers were charged 2-3x for a single credit purchase (Jul 15-16 clusters: same customer, same amount, distinct successful PaymentIntents minutes apart). POST /v1/stripe/transactions/confirm created a fresh stripe_transactions row and a fresh PaymentIntent (confirm: true) on every delivery with no idempotency key, so a user retrying a slow-but-actually-successful charge fired a second independent charge, and each PaymentIntent's webhook credited the wallet again.

Fixes CON-684

What

Client (deploy-web)

  • AddCreditsForm mints a crypto.randomUUID() attempt key per purchase attempt and sends it as idempotencyKey. The key survives unknown-outcome failures (network errors, 4xx/5xx, the polling-timeout path from the incident, 3DS failures) and remounts (sessionStorage, matched on user + amount + payment method, 24h max age to match Stripe's replay window). It rotates only when polling confirms settlement, a definitive 402 decline or 409 idempotency_key_mismatch arrives, or the purchase params change.
  • A replayed already-credited attempt (transactionStatus === "succeeded") polls from the pre-charge baseline so it settles against the already-credited balance instead of timing out.
  • PaymentPollingProvider exposes the terminal poll outcome; a poll that exhausts without confirming settlement is treated as unknown, not complete: the form releases its in-flight state but keeps the attempt key and skips onDone, so the parent success screen never shows for an unconfirmed charge and a retry replays the same attempt.
  • 409s show "Your payment is still being processed." copy.

API

  • New nullable stripe_idempotency_key column + partial unique index (migration 0032, auto-applies at boot; verified on a live boot against a fresh database).
  • Controller namespaces the client key as topup_<userId>_<clientKey> (uuid-validated, cross-user collision-proof, disjoint from WalletBalanceReloadCheck.* and card_validation_*).
  • StripeService.createPaymentIntent keyed path: find-or-create the row by key (insert race resolved via conflict-do-nothing + winner re-fetch), short-circuit rows the webhook already settled (succeeded/refunded), and when the row already records a PaymentIntent, retrieve it and act on its live status instead of ever creating a second one (succeeded/processing/requires_capture resume, requires_action returns the live client_secret, declined synthesizes a 402 with the real decline reason).
  • All row status writes on this path go through updateByIdUnlessSettled (atomic UPDATE ... WHERE status NOT IN ('succeeded','refunded')), so a slow request can never downgrade a status the webhook wrote first. succeeded and now requires_capture are deferred to the webhook, protecting the exactly-once crediting guard.
  • Reused key with a changed amount: validated before every replay branch (settled short-circuit and recorded-intent resume included) and rejected for topup_ keys with a definitive 409 idempotency_key_mismatch — distinct from the retryable in-progress 409 — so a replay can never report success for a different amount than was charged; wallet auto-reload keys charge the recorded amount instead, because pg-boss redeliveries reuse the job id but recompute live amounts. Stripe's own params-mismatch (StripeIdempotencyError) maps to the same code.
  • When the settled-status guard suppresses a request-path write (updateByIdUnlessSettled returns undefined because a webhook settled the row mid-request), the response is derived from the settled row instead of the request's stale view, so an already-credited charge is reported as succeeded rather than pending/failed.
  • Stripe's idempotency_key_in_use (concurrency loser; arrives as invalid_request_error in stripe-node 19, detected via error.code) maps to 409 payment_in_progress without touching the row.
  • Keyless callers keep today's behavior byte-identically (old bundles keep working; idempotencyKey is optional).
  • Observability: PAYMENT_INTENT_KEY_REUSED, PAYMENT_INTENT_REPLAY_SHORT_CIRCUIT, PAYMENT_INTENT_KEY_IN_USE, PAYMENT_INTENT_KEY_AMOUNT_MISMATCH.

Migration note: additive nullable column + partial unique index on a NULL-heavy column; no table rewrite, safe to apply on production without blocking.

Quirk fixed along the way: createError(status, msg, { errorCode }) puts props on the error object while the hono handler only serialized error.data?.errorCode, so every billing error collapsed to a status-derived code (conflict, unknown_error). The handler now falls back to the errorCode/errorType properties, so the curated billing codes (card_declined, payment_in_progress, idempotency_key_mismatch, …) actually reach clients; the client still handles conflict and the bare 409 status as fallbacks.

Accepted residuals (documented in the plan):

  • A polling timeout no longer completes the flow nor rotates the key: the outcome is treated as unknown, the attempt stays replayable, and the provider snackbar frames it as still-processing.
  • If the create call dies before the PI id is recorded, the webhook never lands, and the key is only reused after Stripe prunes it (>24h), one extra PI is possible.
  • Multi-tab purchases are intentionally two attempts.

Tests: service keyed-path specs (replay, retrieve-guard per live status, amount-mismatch policy, key-in-use, guarded failed writes), repository integration specs against real Postgres (find-or-create race via Promise.all, settled-guard no-ops), controller namespacing specs, error-mapping specs, a functional test that nock-matches the Idempotency-Key header Stripe receives end-to-end over HTTP plus a replay short-circuit functional test, and 12 AddCreditsForm scenarios capturing actual keys across retries, remounts, 3DS failure, and rotations.

Ops follow-up (not in this PR): identify and refund the Jul 15-16 duplicates (read-only SQL in the plan doc); refunding the duplicate PI reconciles the double credit via the existing handleChargeRefunded clawback. Follow-up once old bundles turn over: consider making idempotencyKey required.

Summary by CodeRabbit

  • New Features
    • Added idempotency-key support for credit purchases to prevent duplicate charges across retries and remounts.
    • Reused prior payment attempts when matching; included payment status details in confirmation responses.
  • Bug Fixes
    • Improved handling of “still processing” outcomes for HTTP 409, including updated user guidance.
    • Added clear messaging for idempotency-key mismatches and prevented already-settled transactions from being overwritten.
    • Enhanced payment polling results and snackbars for success vs timeout scenarios.

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.95122% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.44%. Comparing base (8f9a58c) to head (daa0ab5).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...tripe-transaction/stripe-transaction.repository.ts 73.33% 3 Missing and 1 partial ⚠️
...ts/billing-usage/AddCreditsForm/AddCreditsForm.tsx 98.30% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3449      +/-   ##
==========================================
- Coverage   72.90%   72.44%   -0.47%     
==========================================
  Files        1133     1069      -64     
  Lines       29492    27720    -1772     
  Branches     7395     7048     -347     
==========================================
- Hits        21502    20082    -1420     
+ Misses       7035     6723     -312     
+ Partials      955      915      -40     
Flag Coverage Δ *Carryforward flag
api 86.27% <95.83%> (+0.43%) ⬆️
deploy-web 62.82% <98.52%> (+0.42%) ⬆️
log-collector ?
notifications 91.44% <ø> (ø)
provider-console 81.38% <ø> (ø) Carriedforward from 8f9a58c
provider-inventory ?
provider-proxy 86.42% <ø> (ø) Carriedforward from 8f9a58c
tx-signer 86.72% <ø> (ø)

*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 58.25% <100.00%> (+2.37%) ⬆️
...as/stripe-transaction/stripe-transaction.schema.ts 83.33% <ø> (ø)
...ling/services/stripe-error/stripe-error.service.ts 87.23% <100.00%> (+4.27%) ⬆️
.../api/src/billing/services/stripe/stripe.service.ts 83.08% <100.00%> (+8.08%) ⬆️
...s/hono-error-handler/hono-error-handler.service.ts 74.07% <100.00%> (ø)
.../PaymentPollingProvider/PaymentPollingProvider.tsx 98.29% <100.00%> (+9.10%) ⬆️
apps/deploy-web/src/queries/usePaymentQueries.ts 100.00% <100.00%> (ø)
apps/deploy-web/src/utils/stripeErrorHandler.ts 88.09% <ø> (ø)
...ts/billing-usage/AddCreditsForm/AddCreditsForm.tsx 96.19% <98.30%> (+1.41%) ⬆️
...tripe-transaction/stripe-transaction.repository.ts 54.41% <73.33%> (+5.35%) ⬆️

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

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9788b6b2-1521-4b28-872a-1050d0b23bdb

📥 Commits

Reviewing files that changed from the base of the PR and between cb9b750 and daa0ab5.

📒 Files selected for processing (28)
  • apps/api/drizzle/0032_massive_wolverine.sql
  • apps/api/drizzle/meta/0032_snapshot.json
  • apps/api/drizzle/meta/_journal.json
  • apps/api/src/billing/controllers/stripe/stripe.controller.spec.ts
  • apps/api/src/billing/controllers/stripe/stripe.controller.ts
  • apps/api/src/billing/http-schemas/stripe.schema.ts
  • apps/api/src/billing/model-schemas/stripe-transaction/stripe-transaction.schema.ts
  • apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.integration.ts
  • apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.ts
  • apps/api/src/billing/routes/stripe-transactions/stripe-transactions.router.ts
  • apps/api/src/billing/services/stripe-error/stripe-error.service.spec.ts
  • apps/api/src/billing/services/stripe-error/stripe-error.service.ts
  • apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts
  • apps/api/src/billing/services/stripe/stripe.service.spec.ts
  • apps/api/src/billing/services/stripe/stripe.service.ts
  • apps/api/src/core/services/hono-error-handler/hono-error-handler.service.spec.ts
  • apps/api/src/core/services/hono-error-handler/hono-error-handler.service.ts
  • apps/api/test/functional/stripe-transactions-confirm.spec.ts
  • apps/api/test/seeders/database-stripe-transaction.seeder.ts
  • apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.spec.tsx
  • apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.tsx
  • apps/deploy-web/src/context/PaymentPollingProvider/PaymentPollingProvider.spec.tsx
  • apps/deploy-web/src/context/PaymentPollingProvider/PaymentPollingProvider.tsx
  • apps/deploy-web/src/queries/usePaymentQueries.ts
  • apps/deploy-web/src/utils/stripeErrorHandler.spec.ts
  • apps/deploy-web/src/utils/stripeErrorHandler.ts
  • apps/deploy-web/tests/unit/setup.ts
  • packages/http-sdk/src/stripe/stripe.types.ts
🚧 Files skipped from review as they are similar to previous changes (20)
  • apps/api/src/billing/routes/stripe-transactions/stripe-transactions.router.ts
  • packages/http-sdk/src/stripe/stripe.types.ts
  • apps/deploy-web/src/queries/usePaymentQueries.ts
  • apps/api/src/billing/controllers/stripe/stripe.controller.spec.ts
  • apps/api/test/seeders/database-stripe-transaction.seeder.ts
  • apps/api/src/billing/services/stripe-error/stripe-error.service.spec.ts
  • apps/api/drizzle/0032_massive_wolverine.sql
  • apps/api/src/billing/model-schemas/stripe-transaction/stripe-transaction.schema.ts
  • apps/api/drizzle/meta/_journal.json
  • apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.ts
  • apps/api/drizzle/meta/0032_snapshot.json
  • apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.integration.ts
  • apps/deploy-web/src/utils/stripeErrorHandler.ts
  • apps/api/test/functional/stripe-transactions-confirm.spec.ts
  • apps/api/src/billing/controllers/stripe/stripe.controller.ts
  • apps/deploy-web/src/utils/stripeErrorHandler.spec.ts
  • apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.tsx
  • apps/deploy-web/tests/unit/setup.ts
  • apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.spec.tsx
  • apps/api/src/billing/services/stripe/stripe.service.ts

📝 Walkthrough

Walkthrough

Adds end-to-end Stripe payment idempotency handling across API and web: client keys are namespaced, persisted with de-duplication, replayed safely with amount validation, and surfaced through API routing, service orchestration, repository atomicity, and web retry/polling flows with updated error messaging.

Changes

Stripe payment idempotency

Layer / File(s) Summary
Database schema and type contracts
apps/api/drizzle/..., apps/api/src/billing/model-schemas/..., apps/api/src/billing/http-schemas/..., packages/http-sdk/...
Adds nullable stripeIdempotencyKey column with partial unique index, updates generated snapshots and journal, extends HTTP and SDK types with idempotencyKey parameter and transactionStatus response field.
Transaction de-duplication and guarded updates
apps/api/src/billing/repositories/..., apps/api/test/seeders/..., apps/api/src/billing/services/stripe-webhook/...
Implements atomic find-or-create behavior via findOrCreateByIdempotencyKey, conditional updates via updateByIdUnlessSettled that preserve settled statuses, and seeder/test fixture support.
Idempotent Stripe service flow and error mapping
apps/api/src/billing/services/stripe/..., apps/api/src/billing/services/stripe-error/...
Refactors payment-intent creation to enforce idempotency, replay recorded intents, validate amount consistency, handle Stripe idempotency_key_in_use conflicts, and map new error codes (payment_in_progress, idempotency_key_mismatch).
API controller, routing, and functional integration
apps/api/src/billing/controllers/..., apps/api/src/billing/routes/..., apps/api/src/core/services/hono-error-handler/..., apps/api/test/functional/...
Wires namespaced keys through controller and router, enhances HTTP error handling to serialize errorCode/errorType metadata, and verifies end-to-end idempotency with functional tests.
Web payment attempt persistence and retry logic
apps/deploy-web/src/components/billing-usage/AddCreditsForm/..., apps/deploy-web/src/queries/...
Generates and persists attempt UUIDs per user+amount+method, reuses keys across retries/remounts within 24h with rotation rules, computes wallet-balance-adjusted polling baselines for replayed charges, and forwards keys through mutations.
Polling outcome tracking and error messaging
apps/deploy-web/src/context/PaymentPollingProvider/..., apps/deploy-web/src/utils/..., apps/deploy-web/tests/unit/setup.ts
Tracks terminal polling outcomes (success/timeout), renders variant-specific processing notifications, maps HTTP 409 and payment_in_progress errors to user-friendly messages, and adds Node crypto polyfill for jsdom UUID support.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

Suggested reviewers: stalniy

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/billing-idempotent-topup-charges

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Checkov (3.3.8)
apps/api/drizzle/meta/0032_snapshot.json

Traceback (most recent call last):
File "/usr/local/bin/checkov", line 2, in
from checkov.main import Checkov
ModuleNotFoundError: No module named 'checkov'

apps/api/drizzle/meta/_journal.json

Traceback (most recent call last):
File "/usr/local/bin/checkov", line 2, in
from checkov.main import Checkov
ModuleNotFoundError: No module named 'checkov'

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
apps/api/test/functional/stripe-transactions-confirm.spec.ts (1)

50-80: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Explicitly assert that replay sends no PaymentIntent POST.

The row-count assertion proves database deduplication, not that Stripe was never contacted. Add a /v1/payment_intents interceptor and assert it remains unused.

Proposed assertion
+      const createPaymentIntent = nock("https://api.stripe.com").post("/v1/payment_intents").reply(500);
+
       const response = await confirmPayment(token, { userId: user.userId!, paymentMethodId, amount: 20, idempotencyKey: clientKey });
 
       expect(response.status).toBe(200);
+      expect(createPaymentIntent.isDone()).toBe(false);

As per path instructions, “Verify meaningful assertions, not just snapshot coverage.”

🤖 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 `@apps/api/test/functional/stripe-transactions-confirm.spec.ts` around lines 50
- 80, Update the replay test around confirmPayment to register a nock
interceptor for the Stripe POST endpoint /v1/payment_intents and retain its
request handle. After confirming the already-credited transaction, assert that
this interceptor was not used, alongside the existing response and repository
assertions.

Source: Path instructions

🤖 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 `@apps/api/src/billing/services/stripe/stripe.service.spec.ts`:
- Around line 248-256: Update the idempotency-parameter mismatch handling in the
payment-intent creation flow, including the paths covered by the tests around
createPaymentIntent, to return a distinct definitive 4xx error/code such as
idempotency_key_mismatch. Apply this consistently to both amount mismatches and
Stripe-reported parameter mismatches, rather than
PAYMENT_IN_PROGRESS_ERROR_MESSAGE or a raw Stripe error, while preserving the
no-Stripe-call and no-row-update behavior.

In `@apps/api/src/billing/services/stripe/stripe.service.ts`:
- Around line 245-275: Move `#ensureReusedKeyAmountConsistency` immediately after
the isNew early return so every reused transaction is validated before settled
and recorded-PaymentIntent replay branches. Update the permanent amount-mismatch
handling in the reused-key flow to return a non-retryable failure rather than
classifying the transaction as still processing, while preserving normal replay
behavior for matching amounts.
- Around line 299-330: Update the payment-status handling in the surrounding
Stripe service method, including the success, requires-action, and
default/decline paths, to inspect the result of updateByIdUnlessSettled(). When
it returns undefined, re-fetch the transaction and use its settled status and
persisted outcome instead of returning stale transaction data or throwing the
stale decline; preserve the existing behavior when the update succeeds.

In
`@apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.tsx`:
- Line 371: Update the polling flow surrounding clearAttempt so exhaustion is
distinguished from confirmed payment success; propagate an explicit
success/timeout outcome to the caller, invoke onDone only for confirmed
settlement, and clear the idempotency key only on success so timed-out payments
retain it for safe retry handling.
- Around line 91-92: Update ATTEMPT_MAX_AGE_MS used by the AddCreditsForm
payment-attempt flow so unresolved attempts are retained for at least Stripe’s
24-hour idempotency window instead of expiring after one hour. Preserve key
reuse until that age threshold or a confirmed terminal payment state, whichever
comes first.

---

Nitpick comments:
In `@apps/api/test/functional/stripe-transactions-confirm.spec.ts`:
- Around line 50-80: Update the replay test around confirmPayment to register a
nock interceptor for the Stripe POST endpoint /v1/payment_intents and retain its
request handle. After confirming the already-credited transaction, assert that
this interceptor was not used, alongside the existing response and repository
assertions.
🪄 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: 73edb3d9-8bd3-480b-99aa-47397ed109ed

📥 Commits

Reviewing files that changed from the base of the PR and between f090e31 and cb9b750.

📒 Files selected for processing (26)
  • apps/api/drizzle/0032_massive_wolverine.sql
  • apps/api/drizzle/meta/0032_snapshot.json
  • apps/api/drizzle/meta/_journal.json
  • apps/api/src/billing/controllers/stripe/stripe.controller.spec.ts
  • apps/api/src/billing/controllers/stripe/stripe.controller.ts
  • apps/api/src/billing/http-schemas/stripe.schema.ts
  • apps/api/src/billing/model-schemas/stripe-transaction/stripe-transaction.schema.ts
  • apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.integration.ts
  • apps/api/src/billing/repositories/stripe-transaction/stripe-transaction.repository.ts
  • apps/api/src/billing/routes/stripe-transactions/stripe-transactions.router.ts
  • apps/api/src/billing/services/stripe-error/stripe-error.service.spec.ts
  • apps/api/src/billing/services/stripe-error/stripe-error.service.ts
  • apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.integration.ts
  • apps/api/src/billing/services/stripe/stripe.service.spec.ts
  • apps/api/src/billing/services/stripe/stripe.service.ts
  • apps/api/test/functional/stripe-transactions-confirm.spec.ts
  • apps/api/test/seeders/database-stripe-transaction.seeder.ts
  • apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.spec.tsx
  • apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.tsx
  • apps/deploy-web/src/context/PaymentPollingProvider/PaymentPollingProvider.spec.tsx
  • apps/deploy-web/src/context/PaymentPollingProvider/PaymentPollingProvider.tsx
  • apps/deploy-web/src/queries/usePaymentQueries.ts
  • apps/deploy-web/src/utils/stripeErrorHandler.spec.ts
  • apps/deploy-web/src/utils/stripeErrorHandler.ts
  • apps/deploy-web/tests/unit/setup.ts
  • packages/http-sdk/src/stripe/stripe.types.ts

Comment thread apps/api/src/billing/services/stripe/stripe.service.spec.ts
Comment thread apps/api/src/billing/services/stripe/stripe.service.ts Outdated
Comment thread apps/api/src/billing/services/stripe/stripe.service.ts
Comment thread apps/deploy-web/src/components/billing-usage/AddCreditsForm/AddCreditsForm.tsx Outdated
baktun14 added 4 commits July 21, 2026 10:52
The confirm endpoint created a fresh transaction row and PaymentIntent on
every delivery, so a user retrying a slow but successful charge paid twice
and the webhook credited the wallet twice. The client now mints an attempt
key (kept through unknown-outcome failures, remounts via sessionStorage,
and polling timeouts; rotated only on completion, a definitive 402 decline,
or param changes) and the API finds or creates the transaction row by that
key, short-circuits settled rows, resumes a recorded PaymentIntent from its
live status instead of creating a second one, never downgrades
webhook-written statuses, and maps concurrent key reuse to a 409.
The 30s balance-polling timeout showed a warning ("Payment processing
timeout / refresh the page") that reads as a failure when the charge is
usually still settling server-side. That framing invited the retry that
produced the duplicate top-ups in CON-684. Reword per-variant to an info
"still processing / no need to pay again" notice so a slow-but-successful
charge is not mistaken for a failure.
- reject reused keys whose amount changed before every replay branch with
  a definitive 409 idempotency_key_mismatch instead of payment-in-progress
- respond from the settled row when a webhook wins the race against the
  request-path status update
- retain client attempt keys for Stripe's full 24h replay window
- treat polling exhaustion as unknown outcome: keep the attempt key and
  defer onDone until settlement is confirmed
… reach clients

The hono error handler only read error.data.errorCode, which createError
never populates, so every billing 409 collapsed to the status-derived
"conflict" code and clients could not tell an in-progress replay from a
definitive idempotency_key_mismatch. Falls back to the errorCode/errorType
properties createError actually sets.
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