Fix duplicate billing deduction/refund from FE retry + missing BE rate limit
Jira Story: TF-3514 · Tasks: TF-3483 (FE) · TF-3484 (BE) — both linked via "Work item split", direction currently backwards, pending fix (see bucket README) · Epic: TF-3477 · Priority: Highest — P0
Overview
When Contact Service returned a 5xx (server error) response, the FE HTTP client's automatic retry logic re-issued the same billing-related request (deduction/refund) with no idempotency guard. Two failure paths converged into duplicate charges:
- The original request actually succeeded server-side (deduction/refund processed), but the response failed to reach the client in time (timeout, connection drop, gateway error) — a false-negative 5xx — and the retry fired anyway.
- A retry fired before the previous request/transaction had fully completed, causing a race at the backend.
Because the retried request is functionally identical (same payload, no unique request id), the backend processed it as an independent new transaction — duplicate deduction or duplicate refund, and a ledger/billing record inconsistent with the user's actual actions.
Fix, split FE/BE (one root cause, two halves):
- FE (TF-3483): remove the naive automatic retry-on-5xx for billing-related Contact Service calls — a 5xx no longer silently re-fires the same deduction/refund request.
- BE (TF-3484): add a rate limiter on the billing-related Contact Service API, so a burst of near-simultaneous identical requests (from a client retry, a double-click, or otherwise) can't double-process at the backend even if the FE guard is ever bypassed.
Both were completed 2026-07-09/2026-07-10, ahead of this bucket being documented — this PRD backfills the record.
Scope Changes
- Backend — rate limiter on the billing-related Contact Service endpoint(s).
- Frontend — retry-interceptor change removing automatic retry on 5xx for billing calls.
8. User Stories + Acceptance Criteria
| User Story | Importance | Mockup / Technical Notes | Acceptance Criteria |
|---|---|---|---|
| [BILLRETRY-S01] — No duplicate charge from a false-negative 5xx retry As a customer, I want a single billing action (deduction or refund) to be processed exactly once even if the network response to it is lost or delayed, so that I am never charged or refunded twice for one action. | Must Have | FE: remove automatic retry-on-5xx for billing-related Contact Service calls (TF-3483). BE: rate limiter on the billing-related endpoint guards against duplicate near-simultaneous processing regardless of client behavior (TF-3484). | — Happy Path — • AC-1: Given a billing-related request (deduction/refund) that returns a 5xx, when the FE previously would have auto-retried, then it no longer re-issues the same request automatically. • AC-2: Given a burst of near-simultaneous identical billing requests reaching the backend, when the rate limiter is active, then only one is processed and the rest are rejected/throttled rather than double-processed. — Guard-rail — • NEG-1: Given a genuine transient 5xx with no prior successful processing, when the user or a legitimate retry path re-submits deliberately, then the request is still processed normally (the fix removes automatic silent retry, not the ability to resubmit). • ERR-1: Given the rate limiter rejects a request, when that happens, then the FE surfaces a clear error rather than silently dropping it. |
Verification
Only the FE half is code-corroborated. Per the 2026-07-14 code-evidence.mjs check (all 6
squad repos): TF-3483 (FE) is merged in qontak-customer-fe (PR #453,
1bfeca7). TF-3484 (BE) has no merged-code trace in any of the 6 CDP repos
(contact-service, hub-chat, qontak-customer-fe, qontak.com, mobile-qontak-crm,
mobile-qontak-chat) despite being marked Done in Jira since 2026-07-10 — a genuine
code-evidence-gap, not yet reflected in the sprint-resync pass 6 findings (that pass's engine
run scoped repos by RFC-mention and didn't check this one). Flagged for DRI confirmation:
either the BE rate limiter merged to a repo outside this squad's registered code_repos (update
code-repos.json), it shipped via a config/infra change with no application-code diff, or the
board status is stale and the fix hasn't actually merged — each has a different follow-up. Until
resolved, treat A2 as half-verified: the incident's FE mitigation is confirmed live, the BE
mitigation is not.