Disputes Dashboard
The single source of truth for how every number on the payment-disputes dashboard is defined, calculated, and sourced — written for operations staff and the engineers who maintain it.
https://cx-dashboard-iota.vercel.app
This dashboard tracks every payment dispute (chargeback / claim) Revibe receives across four gateways — Tamara, Tabby, Stripe, and Checkout.com — from the moment it opens until it closes, so the CX team can see what needs a reply, how much money is at stake, and how well we are winning.
▶1 Overview
The dashboard has four tabs backed by one dataset. A daily automated job pulls disputes from each gateway's API, standardizes their statuses, and writes one row per dispute to a Google Sheet (the “Linked Disputes” tab). Every page reads that sheet in the browser. Two of the pages additionally call a serverless endpoint that joins each dispute's order number to live order and claim data straight from the production database.
Numbers fall into two families. Dispute facts (status, amount, dates, gateway, reason) come from the sheet and are as fresh as the last sync. Order & claim context (was it shipped? was it refunded? which claim?) is fetched live from the database on demand, so it is always current but depends on the database being reachable.
Two scoping rules matter throughout. First, the Live Action Items and Operations panels on the Main tab always reflect all disputes and ignore the sidebar filters; everything else on that tab respects the filters. Second, money can be shown in each dispute's original currency or converted to a single currency (USD / AED) using live FX rates.
2026-07-08T14:10:58Z). Day-level counts can therefore shift by one day for disputes created near midnight in a different timezone. (Exact per-gateway tz normalization: unconfirmed.)▶2 Data & Refresh
Where the numbers come from
| Layer | Source | Used for |
|---|---|---|
| Dispute rows | Google Sheet “Linked Disputes” tab (gid 803955238), read as gviz CSV and parsed with PapaParse | Every tab — status, amount, dates, gateway, reason, days-left |
| Order & claim context | Serverless POST /api/dispute-links → MySQL (production RDS), read-only, bound parameters | Linked Analysis, Linked Details, and the Line-Items detail modal |
| FX rates | open.er-api.com/v6/latest/USD (base USD) | Currency conversion on Main, Line Items, Daily, Linked Analysis |
| The sheet itself | A daily GitHub Actions Python job pulls each gateway API, standardizes statuses, and writes the sheet | Upstream of everything above |
Refresh & caching
- Sheet freshness: the sync job runs 3× a day (08:30 / 13:30 / 18:30 UAE) and can be triggered on demand. Open disputes are re-audited every run until they reach a terminal status.
- Browser cache: pages cache the parsed data in IndexedDB — key
revibe_dsp_rows_v2for sheet rows,revibe_dsp_linked_v2for enriched order/claim rows — with a 24-hour TTL. - When it refetches: on a hard reload (F5) or the “Refresh Data” button it always refetches; on in-app navigation it serves the cache if still valid. Detected via the Navigation Timing API.
- Fallback: if the live order/claim enrichment fails, pages render the disputes without DB detail and show an amber warning pill with a Retry action. Partial results are never written to cache.
Refresh Data (top bar) just reloads the dashboard from the last saved sheet — instant, but shows only what the last sync already fetched.
Sync Now (green button, bottom-left of every disputes page) actually re-pulls from the gateways: it re-checks all open disputes and grabs newly-created ones (last 7 days), then writes the sheet. It runs in the background and takes a few minutes; when it finishes, hit Refresh Data to see the new numbers.
The “Last synced” stamp next to Sync Now tells you how fresh the data is — so if a count differs from the gateway portal, it's sync timing, not missing data.
≈ USD under an original-currency figure are indicative conversions; they appear only once FX rates have loaded. If rates fail to load, money is summed in original currencies (labelled “mixed currency” where relevant).▶3 Standardized Status Pipeline
Every gateway uses its own status vocabulary. The sync job maps them all to one Standardized Status — the value in column H of the sheet and the basis for nearly every metric. open statuses are still in play and keep getting re-audited; terminal statuses are closed and final.
| Status | State | Meaning |
|---|---|---|
| Not Replied | open | Dispute is open and we have never responded. Tamara: an open dispute with no challenge from us yet. |
| Awaiting Our Reply | open | We engaged and the gateway wants more from us — ball back in our court. Tamara: “Awaiting merchant response” / “Challenged by merchant” (and awaiting_merchant_response). Distinct from never-touched “Not Replied”. |
| Under Review | open | Our evidence is submitted and the gateway is deciding. Also the catch-all for any unknown raw status. |
| Won | terminal | Resolved in our favour without a fight (or auto-won). |
| Won (Fought) | terminal | Won after we submitted a defence. |
| Lost | terminal | Resolved against us. |
| Lost (Fought) | terminal | Lost after we submitted a defence. |
| Accepted | terminal | We chose to accept the dispute / refund the customer. |
| Expired | terminal | Deadline lapsed with no reply and the gateway auto-refunded the customer (e.g. Tabby auto-refund). Same money outcome as Accepted, but because we ran out of time. |
OPEN_SET = Not Replied · Awaiting Our Reply · Under Review. CLOSED_SET / TERMINAL = Won · Won (Fought) · Lost · Lost (Fought) · Accepted · Expired. “Wins” = Won + Won (Fought). “Losses” for money purposes = Lost + Lost (Fought) + Expired + Accepted. “Action required by us” = Not Replied + Awaiting Our Reply — these are the two “Needs our reply” cards on Main and the “Needs our reply” side of the Daily Breakdown, and they cover every Tamara case where the portal is awaiting merchant response (including “challenged by merchant”).▶4 Tabs & Metrics
The dashboard has four navigable tabs — Main Dashboard, Linked Disputes (an Analysis view and a Detail-Rows view), and Daily Breakdown — plus a shared Line Items drill-down that every clickable number opens. Each metric below follows the same block: what it is → how it's calculated → example → source → caveat.
Example values are an illustrative snapshot taken 2026-07-14 (~8,910 disputes in the sheet); the live dashboard always shows current numbers.
Tab · Main Dashboard — index.html · “Disputes Dashboard”
A filterable overview in three bands — Live Action Items, Financial Picture, Performance — plus an Operations row and four charts. Live Action Items and Operations ignore the sidebar filters (whole dataset); everything else respects them.
Band 1 — Live Action Items (unfiltered)
- What it is
- Un-replied disputes that are at or near their reply deadline.
- Calculated
- Rows where
Standardized Status = Not Replied(or Original Statusevidence_required/unanswered) and not “Awaiting Our Reply”, thenDays left/SLA ≤ 3. If Days-left is blank, fallback:Date Createdage ≥ 4 days. - Example
- 2 disputes
- Source
- Sheet columns
Standardized Status,Days left/SLA,Date Created;renderDashboard(). - Caveat
- “Awaiting Our Reply” is deliberately excluded (we already engaged). Daily Breakdown's “Expiring ≤3” uses a looser rule — see Known Quirks.
- What it is
- Open disputes we have not yet responded to.
- Calculated
- Count + per-currency amount of rows with
Standardized Status = Not Replied(or originalevidence_required/unanswered), excluding Awaiting Our Reply. - Example
- 92 disputes · SAR 67,453 · ZAR 4,299 …
- Source
- Sheet
Standardized Status,Amount,Currency;renderDashboard().
- What it is
- Disputes we engaged where the gateway now wants more evidence from us.
- Calculated
- Rows with
Standardized Status = Awaiting Our Reply; count + per-currency amount. - Example
- 2 disputes · AED 0
- Source
- Sheet
Standardized Status;renderDashboard().
- What it is
- Disputes with the gateway for a decision after our evidence.
- Calculated
- Rows with
Standardized Status = Under Review(orevidence_under_review); count + per-currency amount. - Example
- 331 disputes · SAR 618,184 · AED 19,876 …
- Source
- Sheet
Standardized Status;renderDashboard().
Band 2 — Financial Picture (respects filters)
- What it is
- Total money under dispute in the current filter, plus the case count.
- Calculated
- Sum of
Amountover all filtered rows, grouped per display currency (each row's own currency, or a single currency if USD/AED is selected). Count = number of filtered rows. - Example
- SAR 163,471 · AED 10,817 · 149 disputes
- Source
- Sheet
Amount,Currency;renderDashboard().
- What it is
- Net money position: won value minus absorbed (lost/accepted) value.
- Calculated
- Per currency:
+Amountfor Won / Won (Fought);−Amountfor Lost / Lost (Fought) / Expired / Accepted. Open disputes don't count. Green if positive, red if negative. - Example
- SAR +4,810 · AED +1,990
- Source
- Sheet
Standardized Status,Amount; footer reads “Total Won GMV − Absorbed GMV”.
- What it is
- Total money lost, broken into three buckets.
- Calculated
- Headline = sum of
Amountwhere status is Lost, Lost (Fought), Expired, or Accepted. Sub-tiles: Expired = Expired only · Lost (fought) = Lost + Lost (Fought) · Accepted = Accepted only. - Example
- SAR 2,408 · AED 419
- Source
- Sheet
Standardized Status,Amount; sub-tiles link into Line Items. - Caveat
- The “Expired” sub-tile is wired to the element id
lossNotRepliedfor historical reasons; it correctly counts Expired, not Not-Replied.
Band 3 — Performance (respects filters)
- What it is
- Share of closed disputes that we won.
- Calculated
round(wins / closed × 100)where wins = Won + Won (Fought), and closed = wins + Lost + Lost (Fought) + Expired + Accepted. Open disputes are excluded from the denominator.- Example
- 71%
- Source
- Sheet
Standardized Status; sub-tiles “Won” and “Won (Fought)” show winning GMV per currency. - Caveat
- Because Accepted and Expired count as closed-not-won, a high accept rate lowers Win Rate — as intended.
- What it is
- Per-gateway win rate, closed volume, and won/lost GMV.
- Calculated
- For each Gateway: Win Rate % = round(won/closed×100) (green ≥ 50, else red); Closed Cases = wins+losses+accepted; Won GMV = winning amounts per currency; Lost GMV = loss/accepted amounts per currency. Same status classification as the KPIs.
- Example
- Tabby (KSA) — 78% · childcare 214 closed · … (illustrative)
- Source
- Sheet
Gateway,Standardized Status,Amount.
Band 4 — Operations (unfiltered)
- What it is
- Average days from opened to resolved for closed disputes, overall and per gateway family.
- Calculated
- For terminal rows with valid
Date CreatedandResolved Date, where resolved ≥ created and duration ≤ 400 days (outlier cap):days = (Resolved − Created)/1 day; overall = mean (1 decimal). Per-family means for Tamara / Tabby / Stripe / Checkout with a sample count. - Example
- Overall 6.4 days · Tabby 4.1 · Tamara 9.0 (illustrative)
- Source
- Sheet
Date Created,Resolved Date,Standardized Status;renderOps(). - Caveat
- Resolved Date exists only for Tamara & Tabby; Stripe/Checkout have no close timestamp, so they contribute little or nothing here.
- What it is
- How old the open pile is, in four age buckets.
- Calculated
- For open rows (Not Replied / Awaiting Our Reply / Under Review), age =
(now − Date Created)/1 day, bucketed: 0–7 (≤7) · 8–30 (≤30) · 31–90 (≤90) · 90+ (>90). Total = count of open rows. - Example
- 0–7: 61 · 8–30: 210 · 31–90: 118 · 90+: 36 (illustrative)
- Source
- Sheet
Standardized Status,Date Created;renderOps().
Charts (respect filters; Chart.js)
- “Financial Loss Over Time (Gateways)” — grouped bars of filtered
Amountper time bucket per gateway. Bucket = per-day when Date Range = This Week, otherwise per-week (Monday start). - “Financial Loss Over Time (Category Breakdown)” — same buckets split into Not Replied (=Expired amounts), Denied (=Lost / Lost (Fought)), Accepted/Refunded.
- “Portal Distribution” — doughnut of filtered row count per gateway.
- “Value by Reason Category” — horizontal bars of filtered row count per reason category (tooltip shows % of total).
- What it is
- ▲/▼ change on four KPIs versus the previous equal-length period.
- Calculated
- When Comparison Mode is on and a bounded date range (Week/Month/Custom) is set, the previous window is
[start − (end−start), start). Both periods are reduced to USD-normalized scalars (count, closed, wins, win rate, disputed USD, lost USD). Badges: count / disputed / money-lost as % change (lower = green/favourable); win rate as points (pp) change (higher = green). Flat if < 0.5. - Example
- Count ▲5% · Disputed ▼12% · Lost ▼92% · Win rate ▼12 pp
- Source
computeCompareStats()+renderCompareDeltas(); needs FX loaded for the money deltas (else shows “FX loading…”).
Tab · Linked Disputes — Analysis — linked-analysis.html
Aggregates closed disputes joined to live order status, claim type, and refund state — to judge whether each Accepted or Lost outcome actually made sense and whether Revibe truly lost money. Only rows that resolve to an outcome of Accepted / Won / Lost appear here.
KPI band
- What they are
- Headline counts and money for the filtered closed set.
- Calculated
- Closed Cases = row count. Linked Claims = % of rows whose claim type ≠ “No Claim” (sub-line shows the count). Accepted GMV / Won GMV = summed GMV of Accepted / Won rows. Double Exposure = GMV of “risky” rows: an Accepted case that shouldn't have been accepted, or a Lost case where Revibe truly lost money.
- Example
- Closed 1,240 · Linked 63% · Won GMV SAR 88,120 (illustrative)
- Source
- Sheet GMV/amount column + live
/api/dispute-links(order status, claim, refund); currency = the group's most common currency, FX-converted. - Caveat
- GMV uses the first present of
gmv_local/gross_sale/GMV/Amount; exact physical column depends on the sheet.
Accepted Decision Logic (3 cards)
- What it is
- Whether each Accepted dispute was the right call, checked against order status, cancel reason, and refund state.
- Calculated
- Per Accepted row: if it has a real claim (Return/RTO/Cancellation/Compensation) → Correct; else if already refunded → Should Not Accept; else if the order isn't Cancelled → Should Not Accept; else if the cancel reason is missing/unusable → Needs Reason Review; else → Correct. Rule of thumb: cancelled order + valid cancel reason + no refund = correct accept.
- Example
- Correct 310 · Review 44 · Should-not 12 (illustrative)
- Source
classifyAcceptedDecision(); each card links into Linked Detail Rows filtered to that slice.
Lost Decision Logic (two rows of 3 cards)
- What it is
- For Lost/Expired disputes: did losing make sense, and did Revibe actually lose product money?
- Calculated
- Lose Decision → Made Sense To Lose / Loss Needs Review / Should Not Lose. Money Impact → Revibe Lost Money / No Product Money Lost / Money Impact Review. Both weigh claim type, refund state, and whether the order was Delivered/Shipped vs Cancelled. Rule of thumb: delivered/shipped + no refund/RTO = money lost; a refund means the product was recovered.
- Example
- Made sense 180 · Revibe lost money SAR 21,400 (illustrative)
- Source
classifyLostDecision()+classifyMoneyImpact(); cards link into Detail Rows.
- What it is
- Top 18 combinations of gateway × order status × claim type × refund, with outcome counts.
- Calculated
- Group filtered rows by
gateway|orderStatus|claimType|refund; per group show Accepted / Won / Lost counts, total GMV, and Closed count. Sorted by GMV, top 18. - Source
- Sheet + live enrichment; columns Gateway, Order, Claim, Refund, Accepted, Won, Lost, GMV, Closed.
- What it is
- Four top-6 leaderboards by GMV.
- Calculated
- Accepted by Cancellation Reason, Accepted by Order Status (Accepted rows grouped by reason / order status); Refund Exposure (refunded rows grouped by outcome/claim type); Claim Coverage (all filtered rows grouped by claim type). Each shows GMV + case count.
- Source
- Sheet + live enrichment.
Tab · Linked Disputes — Detail Rows — linked-details.html
The row-by-row table behind the Analysis view — one line per closed dispute with its live order/claim/refund detail. Opened pre-filtered by clicking any Analysis card. No aggregates; money is shown in each row's own currency (no FX toggle here). Rows are sorted risky-first, then by GMV.
| Column | Meaning / source |
|---|---|
| Risk | “Exposure” (red) or “Normal” — the same risk rule as Double Exposure |
| Dispute | Sheet Dispute ID |
| Gateway | Sheet Gateway |
| Order | Sheet order_number / live shopify_order_id |
| Order Status | Live shipment_status → Cancelled / Delivered / Shipped / Other / Unknown |
| Claim | Live best-claim type + claim_id |
| Reason | Cancel/claim reason |
| Refund | Live has_refund → Yes / No / Unknown (red if Yes) |
| Refund Amt | Live refund_to_cx_amount in the claim's currency |
| Outcome | Sheet Standardized Status → Accepted / Won / Lost |
| GMV | Sheet amount column, row's own currency |
Tab · Daily Breakdown — daily.html
A day-by-day pivot of dispute flow per portal. Choose a view (Open backlog by created day · Resolved by resolved day · Net flow), a window (7/14/30/60 days), and a measure (count or USD amount). “Split by region” separates Tamara/Tabby into UAE vs KSA.
- What it is
- One portal's disputes on one day.
- Calculated
- Open view buckets open rows by
Date Createdday into sub-rows Not Replied / Awaiting Our Reply / Under Review. Resolved view buckets byResolved Dateday into Won / Lost / Expired / Accepted. Net = opened that day − resolved that day (red = backlog grew). Measure: count = 1 per row; amount =Amountconverted to USD before summing. - Example
- Tabby (KSA) · Under Review · 14-07 → 6 (illustrative)
- Source
- Sheet
Standardized Status,Date Created,Resolved Date,Amount,Currency. - Caveat
- The Older column collects everything older than the visible window so column sums reconcile with the Total. Until FX loads, the amount view sums mixed currencies (caption says so).
- What it is
- Four at-a-glance stats over the open pile.
- Calculated
- Open disputes = count of open rows. Avg age (open) = mean (now − Created) days. Oldest open = max age. Expiring ≤3 days = open rows with
Days left/SLA ≤ 3(+ % of open). - Source
- Sheet
Standardized Status,Date Created,Days left/SLA. - Caveat
- This “Expiring ≤3” uses only Days-left on open rows — looser than the Main tab's Expiring Soon card (see Known Quirks).
Clicking any cell opens Line Items filtered by gw, status (or resolvedBucket), and the exact day/resolvedDay.
Shared · Line Items — list.html · “Disputes Line Items”
The paginated raw table every clickable number opens (50 rows/page). Filters come entirely from the URL. Clicking a row opens a detail modal that pulls live order & claim data.
| Column | Source / notes |
|---|---|
| Dispute ID | Sheet Dispute ID (+ copy button) |
| Order # | Sheet order_number |
| Status & Expire | Sheet Standardized Status, colored, with an “Expiring soon / ⏳ Nd left” badge for near-deadline Not-Replied rows |
| Gateway | Sheet Gateway |
| Amount | Sheet Amount + Currency, with a ≈ USD sub-line |
| Reason | Sheet Reason |
| Days Left | Sheet Days left/SLA, shown as max(0,n)d; red ≤0, yellow ≤3 |
| Date Created | Sheet Date Created (date only) |
| Customer | Sheet Customer Email (truncated) |
- What it is
- Full context for one dispute.
- Calculated
- Left column “Dispute” = sheet fields (gateway, status, amount, reason, days-left, created, customer, order #). Right column “Order & Claims (live from DB)” = a live
POST /api/dispute-linkscall for that order: order number, Shopify order, order/shipment status, payment method, cancel reason, refund flag, and each claim's type/status/stage/refund/compensation. - Source
- Sheet row + live
/api/dispute-links. - Caveat
- If the DB is unreachable or the order isn't found, the right column shows a lookup-failed / no-match message; the left (sheet) column always renders.
▶5 Filters & Controls
The Main tab's sidebar drives every filtered metric on it (not the Live Action Items or Operations panels).
| Control | Options | Effect |
|---|---|---|
| Date Range | All Time · This Week (−7d) · This Month (−1mo) · Custom Range | Filters on Date Created. A bounded range is required for Comparison Mode. |
| Gateway | All + the six gateway variants | Exact match on Gateway. |
| Case Type | All · Open Cases · Closed Cases | Open = OPEN_SET; Closed = CLOSED_SET. |
| Specific Status | All + each Standardized Status | Exact status; selecting “Won (Fought)” also catches bare “Won”. |
| Reason Category | All + 6 categories | Uses Reason Category, falling back to a keyword mapping of Reason. |
| Comparison Mode | on/off | Adds ▲/▼ deltas vs the previous equal-length period (needs a bounded date range). |
| Currency | Original · USD · AED | Display only — converts figures via live FX; does not change which rows are counted. |
| Data Source | Live Sheet / Static | Both currently point to the same sheet tab. |
The Linked Analysis and Detail Rows pages have their own sidebar (Gateway, Order Status, Claim Type, Outcome, Time Range, Reason Category, Search) plus a currency toggle on Analysis. Line Items takes all its filters from URL parameters — filter, gateway, range/start/end, reasonCategory, live, and the precise drill-down set gw/status/day/resolvedDay/resolvedBucket.
▶6 Access & Roles
All dispute pages are gated behind Firebase authentication — a user must be logged in to the CX portal to view them. There are no in-dashboard role tiers today: any authenticated viewer sees the same data and controls. The pages are read-only; the live database endpoint accepts only lookups and runs with bound parameters, so nothing on the dashboard can modify orders, claims, or disputes. (Finer-grained roles: not implemented.)
▶7 Known Data Quirks
Honest notes so you can trust the rest of the numbers.
- “Expiring” is defined two ways. The Main tab's Expiring Soon card and the Line-Items expiring filter require a Not-Replied status and Days-left ≤ 3 (with an age fallback). The Daily Breakdown Expiring ≤3 stat uses only Days-left ≤ 3 on open rows, so it can be higher. Use the Main tab card as the authoritative count.
- Two panels ignore the filters. Live Action Items and Operations always reflect the whole dataset; the rest of the Main tab respects the sidebar. This is intentional (they are “live pulse” panels) but surprises people comparing numbers.
- Resolved Date is gateway-limited. Only Tamara and Tabby record a close timestamp, so Avg Resolution Time and the Daily “Resolved” view under-represent Stripe/Checkout.
- Expired vs Accepted. Both mean the customer was refunded and both count as money lost; Expired specifically means we ran out of time (never replied) and the gateway auto-refunded. Tamara has no dedicated “Expired” mapping today, so a lapsed Tamara case can read as Lost.
- Some legacy references don't match an order. A minority of older Tabby payment-reference formats never resolve to a current order in the live lookup — those rows show disputes without DB detail. Expected, not a bug.
- Days-left is clamped at 0. Past-deadline disputes show
0d(and are stored as 0), never a negative countdown. - Some Days-left values are estimates. Stripe and Checkout return a real evidence deadline; Tabby exposes one via history. Tamara's API returns no deadline, so open Tamara disputes use an assumed 4-day window (created + 4d) as the countdown. Stripe falls back to an assumed 30-day window only when it doesn't return its own
due_by. These assumed windows are team-average SLAs, not gateway-provided — treat Tamara/Stripe-fallback Days-left as approximate. - Amounts can read as 0. A few disputes (seen on Tabby) carry a 0 amount from the gateway; they still appear in counts but contribute nothing to money totals.
- Claim data flows through pre-joined sheet columns. On the Linked pages an internal
claimsMapmerge path is currently inert; claim type/refund come from the columns written during enrichment. No user-visible effect, but relevant if you extend the code. - Currency mixing. Aggregated money on the Linked pages is labelled in the group's most common currency; small cross-currency blends are possible. Use the Original/USD/AED toggle where precision matters.
▶8 Changelog / What's New
Newest first. Add new entries at the top in the same format: ### YYYY-MM-DD — short title then bullets.
2026-07-14 — Comparison Mode, USD daily amounts, expiring & layout fixes
- Comparison Mode now renders real ▲/▼ deltas (count, disputed, money lost, win rate) vs the previous equal-length period.
- Daily Breakdown's “disputed amount” view now converts every row to USD before summing (was a mixed-currency total).
- Bare “Lost” disputes now count in Win Rate, Money Lost, Net P&L, and the gateway table (were previously excluded, inflating win rate).
- “Awaiting Our Reply” is excluded from Expiring Soon (card + list) — expiring now tracks only disputes we haven't replied to yet.
- Days-left is clamped to 0 in the UI and the sync (no negative countdowns).
- Fixed horizontal page overflow on the Line-Items list (and hardened Daily) so the table scrolls inside its panel.
- Line-Items copy-ID button no longer opens the detail modal.
Earlier — architecture
- Moved to a single “Linked Disputes” sheet plus live order/claim enrichment via
/api/dispute-links(previously baked into the sheet). - Added the shared top-nav tabs, the Daily Breakdown pivot, the drill-down Line-Items detail modal, and the Operations row (avg resolution + backlog aging).
- Switched browser caching to IndexedDB (localStorage quota was exceeded at ~8,900 rows).