Read-only per-epic slice. The canonical source of truth is BRD.md — stories are addressed by US-ID, not by this page's line numbers.
Epic 12 — Refunds, credits & manual charges (derived view)
Read-only per-epic slice of
BRD.md§9, lines 4857–5182. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-12.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (6): US-12.1, US-12.2, US-12.3, US-12.4, US-12.5, US-12.6
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 12 — Refunds, credits & manual charges
<!-- traceability:start:BRD:Epic-12 --><!-- traceability:end:BRD:Epic-12 -->Prototype: Refund Processor · Credit Ledger · Proration · Chargebacks · Tax on Refund
Value: Merchants and subscribers can resolve billing issues without workarounds.
US-12.1: Refund a charge
<!-- traceability:start:US-12.1 --><!-- traceability:end:US-12.1 -->Prototype: Refund Processor
Phase: MVP · Priority: P0 · Effort: M · Persona: Support / Ops
As Support / Ops, I want to refund a past charge (full or partial), so that I can resolve customer service issues quickly.
Acceptance criteria:
- Given I open a subscription's charge history, When I click "Refund" on a succeeded charge, Then I enter amount (≤ charge amount) and reason, and the refund is issued via the processor adapter.
- Given the refund succeeds, When it completes, Then the related BC order's status is updated (partial/full refund) via BC Orders API and an Event row is written.
UX notes.
- Surface: subscription detail → charge history row → kebab menu → "Refund"
- Modal: amount (default = full), reason (dropdown + free text), "Also cancel subscription?" checkbox
- Confirmation: "Refund $X to ending 4242?" with typed confirm if > $500
Data contract.
- Our API: POST
/api/v1/charges/{id}/refundwith{amount_cents, reason} - Processor adapter:
refund({chargeId, amount, reason}) - BC API: PUT
/v2/orders/{id}to update status to "Refunded" or "Partially Refunded" - Our DB:
charges.status = refunded(full or partial);refund_amount_centsrecords the refunded amount — partial ⇔refund_amount_cents < amount_cents(no separatepartially_refundedstatus; synthesis #1633 / proposal #1632 ratified this over a high-riskchargestable-rebuild to extend the status CHECK), Event row - Events:
charge.refunded
Success metrics.
- Functional (target): refund succeeds end-to-end in ≥ 99% of cases (remaining 1% = processor-side issues)
- Operational (target): P95 refund latency < 5s
Dependencies.
- US-12.6 (tax handling on refund)
Non-functional.
- Idempotency: same refund request with same amount must not double-refund (idempotency key =
charge.id + amount_cents + reason_hash)
UI states.
<!-- ui-states US-12.1 -->surface: "Admin (React/BigDesign) exception-queue refund flow — the 'Refund customer' BigDesign Modal in apps/admin/src/pages/exceptions/ExceptionQueueList.tsx (lines 516-555), opened from a charge_failed exception row's expanded body (lines 460-464). Persona: Support / Ops. NORTH-STAR per BRD §US-12.1 is the subscription-detail charge-history row kebab -> 'Refund'; today the affordance lives only on the exception queue and only for charge_failed rows (see gaps)."
idle:
render: "When a charge_failed exception row is expanded, a secondary 'Refund customer' Button renders in the action row (lines 460-464). Activating it opens a BigDesign Modal(variant='dialog') titled by merchantAdmin.exceptions.refundModal.header holding body copy and a single free-text reason Textarea (lines 537-546). NORTH-STAR (BRD §US-12.1 UX): an amount field defaulting to the full charge, a reason dropdown + free text, and an 'Also cancel subscription?' checkbox — none rendered today (see gaps)."
primary_action: "'Refund' confirm action POSTs /api/v1/admin/charges/{id}/refund via refundCharge() (api-client exceptions.ts:64-82). The exception row id IS the charge id (listExceptions selects c.id, db.ts:1853), so the refund correctly targets the failed charge."
loading:
trigger: "POST /api/v1/admin/charges/{id}/refund while refunding === true (handleRefund, lines 236-250)."
render: "The modal's primary 'Refund' Button shows its isLoading spinner and is disabled (disabled: refunding, isLoading: refunding, lines 530-531); double-submit guarded. The reason Textarea is NOT disabled during the in-flight refund (lines 540-546) — minor; see gaps. No skeleton; the in-button spinner is the only loading affordance."
error:
surfaced_at: "Inline BigDesign Message(type='error') rendered inside the refund Modal below the reason Textarea (lines 547-554), scoped to this refund — never a toast."
render: "The thrown reason verbatim — 'HTTP <status>: <body>' from authedFetch (exceptions.ts:32-34) or 'NO_AUTH_TOKEN' (exceptions.ts:25-26)."
recovery: "On failure handleRefund's finally sets refunding=false (line 248), re-enabling the still-open modal with the typed reason preserved so the rep retries; or the Cancel action (lines 522-525) dismisses without refunding."
empty:
render: "Not a collection surface — the refund Modal carries no list. The parent queue's 'All clear' empty card (lines 354-384) governs the no-exceptions case; because the Refund affordance is gated to item.type === 'charge_failed' (line 460), a queue with no failed-charge rows surfaces no refund entry point at all."
inputs:
- field: "amount_cents"
control: "number"
label: "Refund amount — default = full charge amount (BRD §US-12.1 UX)"
validation: "NORTH-STAR: required, numeric, must be <= the original charge amount. NOT RENDERED today — refundCharge accepts an optional amount_cents (exceptions.ts:64-82) but the modal never collects it, so every refund is full-amount (see gaps)."
- field: "reason"
control: "text"
label: "Reason — BRD specifies a reason dropdown + free text; today a single free-text Textarea (lines 540-546)"
validation: "Optional; trimmed and sent as { reason } (handleRefund, line 241). Empty reason is accepted — confirm is only disabled while refunding (line 530), not on empty reason."
- field: "also_cancel_subscription"
control: "checkbox"
label: "Also cancel subscription? (BRD §US-12.1 UX)"
validation: "NOT RENDERED today — no cancel-on-refund affordance exists in the modal (see gaps)."
edge_status:
- status: "refund amount > $500 (high-value refund)"
affordance: "NORTH-STAR (BRD §US-12.1): a typed-confirm step ('Refund $X to ending 4242?') must gate the submit. NOT IMPLEMENTED — the modal confirms any amount with a single click (lines 527-533); see gaps."
- status: "processor rejects the refund / charge already refunded"
affordance: "The inline error Message (lines 547-554) surfaces the processor/HTTP error verbatim; the rep reads it and retries or cancels — the form stays open and re-enabled (line 248)."
- status: "non-charge_failed row (oos_at_renewal, chargeback, reconciliation_drift)"
affordance: "No Refund affordance renders (gated to charge_failed, line 460); the rep uses 'Open subscription' (lines 451-456) or 'Mark resolved' (lines 457-459) instead."
disabled_focus:
keyboard: "Every refund control is a real BigDesign component wrapping a native focusable element: the 'Refund customer' trigger (lines 460-464), the reason Textarea (lines 540-546), and the modal's Cancel / Refund actions (lines 521-533). The Modal(variant='dialog') traps focus while open and is Esc-dismissable via onClose (line 519); Tab order follows the modal DOM. No div-onClick dead-ends in the refund flow."
guard: "Double-submit is guarded by disabled+isLoading on the confirm Button (lines 530-531). BRD §US-12.1's typed-confirm for refunds > $500 is NOT implemented — a single click confirms any amount."
gaps: "(1) No amount input — refund is full-only though refundCharge supports amount_cents (exceptions.ts:64-82); the partial-refund AC is unmet. (2) No 'Also cancel subscription?' checkbox. (3) No typed-confirm for amounts > $500. (4) Reason is a bare Textarea, not the spec'd dropdown + free text. (5) Wrong trigger surface — BRD wants the charge-history kebab on the subscription detail; built only on the exception queue, charge_failed rows only. (6) The refund error Message has no role='alert'/aria-live and no focus move (lines 547-554), so a screen-reader rep is not notified of a failed refund."
US-12.2: Store credit as payment for next charge
<!-- traceability:start:US-12.2 --><!-- traceability:end:US-12.2 -->Prototype: Credit Ledger
Phase: P2 · Priority: P1 · Effort: L · Persona: Support / Ops / Subscriber
As Support / Ops, I want to issue store credit that applies to the subscriber's next charge(s), so that I compensate for issues without processing a refund.
Acceptance criteria:
- Given I issue $10 store credit to a subscriber, When the next charge runs, Then the charge amount is reduced by up to $10 and the credit balance decrements.
- Given the credit exceeds the charge amount, When it applies, Then the remainder rolls forward to the next cycle until consumed.
Data contract.
- New entity:
store_credits—{id, customer_id, balance_cents, expires_at, source, metadata} - On issuance:
store_creditsrow created + Event - On application at renewal: charge amount is computed, credit applied up to balance, remaining charged to PM; credit balance decremented
- Events:
credit.issued,credit.applied,credit.expired
UX notes.
- Merchant admin: "Issue credit" CTA on subscription detail
- Subscriber portal: credit balance displayed in the subscription summary
Success metrics.
- Functional: credits never double-apply; never apply to wrong subscription
- Product (target): credit-issued-to-credit-used conversion within 90 days ≥ 70%
Non-functional.
- Atomic credit decrement + charge decrement must be in the same transaction
- Expired credit notifications T-7 days
UI states.
<!-- ui-states US-12.2 -->surface: "NOT YET BUILT — forward-looking contract. Two surfaces: (1) Merchant Admin (React/BigDesign) 'Issue credit' form — a modal or inline panel on the admin subscription detail page, used by Support / Ops to issue store credit to a subscriber. (2) Subscriber portal (Svelte/Tailwind) — a read-only credit balance line in the subscription summary row, displayed when the subscriber has an active credit balance."
idle:
render: "Admin: the subscription detail billing section exposes an 'Issue credit' BigDesign Button (variant=secondary). Activated, it opens a modal titled 'Issue store credit' containing three fields: Amount (currency input), Reason (free-text textarea), and Expiry (optional date picker, default = never). A footer note explains that the credit will apply automatically at the next renewal up to the cycle charge amount, with any remainder rolling forward. Subscriber portal: when the subscriber's credit balance is > 0, a line 'Available credit: $X.XX' appears in the subscription summary card, beneath the next charge date. When balance = 0 or no credit row exists, the line is absent."
primary_action: "Admin: 'Issue credit' submit — POST /api/v1/admin/subscriptions/{id}/credits with {amount_cents, reason, expires_at}. Subscriber portal: read-only display — no subscriber action on the balance line itself."
loading:
trigger: "Admin form: POST /api/v1/admin/subscriptions/{id}/credits on submit. Subscriber portal: credit balance is loaded as part of the subscription detail GET."
render: "Admin: the 'Issue credit' submit button shows 'Issuing…' and is disabled; all form fields are disabled during the in-flight POST. Subscriber portal: the credit balance line shows a brief skeleton placeholder while the subscription detail loads; it disappears on load completion if the balance is zero."
error:
surfaced_at: "Admin: inline BigDesign Message(type='error') inside the issuance modal, below the form fields and above the action buttons (role=alert) — never a toast. Subscriber portal: if the credit balance fetch fails, the balance line is silently omitted (it is non-critical display) rather than rendering an error."
recovery: "Admin: the modal stays open and all fields remain populated with the typed values; the Support rep corrects the amount or reason and resubmits. Subscriber portal load failure: the subscriber can reload the page; the applied credit is also confirmed in their next-renewal confirmation email."
empty:
render: "Subscriber portal: a balance of zero means the 'Available credit' line does not appear — its absence is the correct empty state, not an error. Admin issuance form: the form always renders fresh and has no list-empty state to handle."
edge_status:
- status: "credit balance exceeds the upcoming charge amount"
affordance: "The subscriber portal balance line shows the full credit amount; after the charge applies it updates to show the remaining balance with copy 'Remaining credit: $X.XX — will apply at your next renewal'."
- status: "credit expired before any charge was applied"
affordance: "A credit.expired event is emitted; the subscriber portal removes the balance line (balance = 0). If the merchant configured a T-7 expiry reminder, the subscriber receives an email 7 days before expires_at warning that unused credit will lapse."
- status: "duplicate issuance suspected (same amount + reason issued within 60 seconds)"
affordance: "The API returns a conflict response; the admin modal surfaces an inline warning 'A credit with this amount and reason was just issued — confirm this is a separate issuance' with a 'Confirm duplicate' secondary action alongside the standard Cancel."
inputs:
- field: "amount_cents"
control: "number"
label: "'Amount' — store credit amount to issue in the store's display currency"
validation: "Required; positive number; max enforced server-side per merchant policy."
- field: "reason"
control: "text"
label: "'Reason' — free-text explanation for the credit issuance (e.g. 'Delayed shipment', 'Customer service goodwill')"
validation: "Optional; max 255 characters."
- field: "expires_at"
control: "date"
label: "'Expires' (optional) — the date after which unused credit is forfeited; leave blank for no expiry"
validation: "Optional; must be a future date if provided."
disabled_focus:
keyboard: "Admin: the 'Issue credit' trigger is a real BigDesign Button reachable via Tab. Inside the modal, Tab order follows: Amount input → Reason textarea → Expiry date picker → 'Issue credit' submit → Cancel. The modal traps focus while open and returns focus to the 'Issue credit' trigger on close or cancellation. Subscriber portal: the 'Available credit' line is read-only text — it is not interactive and requires no tab stop."
US-12.3: Skip cycle without refund
<!-- traceability:start:US-12.3 --><!-- traceability:end:US-12.3 -->Prototype: Proration
Phase: MVP · Persona: Support / Ops / Subscriber
As Support / Ops or a Subscriber, I want to skip an upcoming cycle, so that I don't generate a charge when the subscriber doesn't need delivery.
Acceptance criteria:
- Given I click "Skip next charge" on an upcoming scheduled charge, When I confirm, Then the charge is marked
abandonedwith reasonskippedand the next_charge_at advances by one interval.
UI states.
<!-- ui-states US-12.3 -->surface: "Two surfaces. (1) Storefront subscriber portal (Svelte) — the 'Skip next charge' card in ManagePanel.svelte (immediate apiClient.skipNextCharge via handleSkip, opened from a row's 'Manage'). (2) Admin (React/BigDesign) — the 'Skip next charge' bulk action in SubscriptionsList.tsx, surfaced once one or more rows are selected (applyBulkAction). Persona: Support / Ops / Subscriber."
idle:
render: "Storefront: the Manage grid's 'Skip next charge' card shows 'Move past <next charge date>'; after a skip it flips to 'Unskip next charge' ('Restore <date> delivery'). Admin: a per-row checkbox column; selecting rows reveals a bulk toolbar (data-testid subscriptions-bulk-toolbar) with a 'Bulk action' Select (Skip next charge / Pause / Cancel) and an 'Apply' button."
primary_action: "Storefront: click 'Skip next charge' (handleSkip) — immediate, no inline form. Admin: select rows, choose 'Skip next charge', click 'Apply' (applyBulkAction)."
loading:
storefront: "The skip card label shows 'Skipping...' and the card is disabled (busy) — no double-submit."
admin: "The 'Apply' button shows its BigDesign isLoading spinner (bulkBusy) and is disabled until a bulk action is chosen."
error:
surfaced_at: "Storefront: a role=alert paragraph (data-testid manage-error) directly under the Manage grid — skip is panel-less, so its failure surfaces here, not trapped in an inline form. Admin: a danger-coloured Text banner above the table, plus a per-submission result line ('<n> succeeded, <n> failed', danger when any failed)."
render: "The failure reason (err.message) for the storefront; the bulk succeeded/failed counts (bulkResult) for the admin."
recovery: "Storefront: retry 'Skip next charge'. Admin: re-select the rows that failed and 'Apply' again; selection clears on success."
empty:
render: "Storefront: not a list surface — skip acts on a single subscription. Admin: when the filtered list returns no rows, an explicit empty panel renders (merchantAdmin.subscriptions.empty.header + .empty.body) instead of a bare table."
cta: "Storefront: n/a (single-subscription action). Admin: adjust the search / status / plan filters to widen results."
edge_status:
- status: "active subscription with an upcoming charge"
affordance: "Storefront 'Skip next charge' / admin bulk 'Skip next charge' marks the charge abandoned (skipped) and advances next_charge_at one interval."
- status: "already skipped (storefront)"
affordance: "The card flips to 'Unskip next charge', which restores the skipped charge (reversible)."
- status: "admin bulk skip with partial failure"
affordance: "The result line reports how many succeeded vs failed; the rep re-selects and re-applies to the failed rows (which rows failed is not shown — see gaps)."
- status: "paused / cancelled"
affordance: "ManagePanel (and its Skip card) renders only for active subscriptions — resume or reactivate first. An admin bulk skip on a non-active row is rejected server-side and counts toward 'failed'."
disabled_focus:
keyboard: "Storefront: the 'Skip next charge' card is a real button (disabled while busy), reachable in tab order and activatable with Enter/Space — no div-onClick. Admin: the row Checkbox, the bulk-action Select, and 'Apply' are native BigDesign components in DOM tab order."
focus_move: "Storefront: on skip success the portal's polite toast (role=status, aria-live=polite) announces 'Next charge skipped...'. Admin: the result line updates in place; north-star moves focus to it."
guard: "Skip is reversible (storefront unskip; admin re-run), so it is a single intentional click with no typed-confirm; the destructive bulk action (cancel) is a separate option."
gaps: "No per-subscription Skip action exists in the admin detail view — SubscriptionAdminDetail.tsx exposes only Force retry / Cancel on behalf / Force cancel (handleForceRetry / handleCancelOnBehalf / handleForceCancel), so a rep skipping one subscription must return to the list and use the bulk action. The admin bulk result also surfaces only succeeded/failed COUNTS; the failedIds are computed (SubscriptionsList line 273) but never rendered, so the rep cannot see which subscriptions failed."
US-12.4: Merchant-initiated one-time charge
Phase: P2 · Persona: Support / Ops
As Support / Ops, I want to charge a subscriber an ad-hoc amount against their stored PM (e.g., add-on item, correction), so that I don't need a separate payment request.
Acceptance criteria:
- Given I click "Add manual charge" on a subscription, When I enter amount, description, and confirmation, Then the charge is executed immediately against the PM and a BC order is created (optionally).
- Given the subscriber has opt-in authorization for ad-hoc charges, When I execute, Then no additional auth is required; otherwise the charge is blocked and a pre-auth email is sent.
UI states.
<!-- ui-states US-12.4 -->surface: "Admin (React/BigDesign) CS-tools 'Force charge now' modal in CsActionsPanel (apps/admin/src/pages/cs-tools/CsActionsPanel.tsx). The 'Force charge now' Button (lines 383-392) opens a BigDesign Modal (variant=dialog, lines 424-481) that POSTs /api/v1/admin/cs/subscriptions/:id/force-charge via handleForceCharge (lines 290-313). Persona: Support / Ops."
idle:
render: "The 'CS override actions' Panel shows a 'Force charge now' secondary Button, disabled unless subscriptionStatus is active/past_due/trialing (isChargeable gate, lines 211 and 385); a Small note explains the gate when not chargeable (lines 416-420). Opening the modal shows explanatory Text, a required reason Textarea, and an optional amount-override Input."
primary_action: "'Queue force-charge' primary modal action, disabled until chargeReason is non-empty (line 438); 'Cancel' subtle action dismisses without charging."
loading:
trigger: "POST /api/v1/admin/cs/subscriptions/{id}/force-charge with {reason, amount_cents_override?}."
render: "While chargeBusy the primary action label switches to 'Queuing…' and is disabled (lines 435 and 438); no double-submit."
error:
surfaced_at: "An inline BigDesign Message(type=error) inside the modal, below the inputs (lines 466-473) — never a toast; it carries the raw API detail string from csPost."
recovery: "On failure setChargeBusy(false) leaves the modal open and re-enabled (line 311) with reason/amount intact so the rep edits and re-queues, or presses Cancel."
empty:
render: "Not a list surface — a single-subscription modal. On success the modal closes and a success confirmation renders at the top of the panel: a transient toast Message (lines 345-348) plus a persistent chargeResult Message ('Force-charge queued …', lines 351-362) the rep dismisses manually. The subscriptions-list empty state is owned by the admin list (US-21.x)."
edge_status:
- status: "subscription not chargeable (cancelled / paused)"
affordance: "the 'Force charge now' Button is disabled and a Small note explains 'Force-charge is only available for active, past_due, or trialing subscriptions.' (lines 416-420); the rep resolves the status first."
- status: "reason empty"
affordance: "the 'Queue force-charge' action stays disabled (line 438) until a reason is typed."
- status: "subscriber has NOT opted in for ad-hoc charges (BRD AC2)"
affordance: "north-star: the charge is BLOCKED and a pre-authorization email is sent; the rep sees a 'pending subscriber authorization' notice instead of a queued confirmation. NOT built — the modal queues unconditionally (gap)."
- status: "charge API failure (PM declined / no stored PM)"
affordance: "the inline error Message shows the reason; the rep retries, or onboards a payment method first."
inputs:
- field: "reason"
control: "textarea"
label: "'Reason (required)' BigDesign Textarea (lines 447-455) — gates the submit (line 438)."
- field: "amount_cents_override"
control: "number"
label: "'Amount override (cents, optional)' BigDesign Input type=number (lines 457-464); blank uses the plan amount."
disabled_focus:
keyboard: "The 'Force charge now' trigger, the reason Textarea, the amount Input, and the Cancel / Queue actions are real BigDesign components keyboard-reachable in tab order and Enter/Space activatable; the BigDesign Modal traps focus while open and restores it on close; native disabled removes the primary action from tab order while chargeBusy."
gaps: "BRD AC2 unmet: no opt-in / authorization guard is rendered — handleForceCharge (lines 290-313) POSTs unconditionally with no check for subscriber ad-hoc-charge consent and no pre-auth-email path; every force-charge proceeds. The inline error Message carries the raw csPost string and has no role=alert / aria-live, so a screen-reader rep gets no announcement on failure."
US-12.5: Chargeback dispute handling
<!-- traceability:start:US-12.5 --><!-- traceability:end:US-12.5 -->Prototype: Chargebacks
Phase: P2 · Persona: Support / Ops
Phase-2 scope confirmed: Hive #923 [Decision-Fast] ratified deferral on 2026-05-17. The
chargebackcharge-status enum andchargeback_receivedexception-queue type inapps/api/src/db.tsare forward-compatibility stubs only — no Phase 1 webhook handler routes chargebacks to the queue. State-derive marks this capabilityscope: phase-2; P1 audits should report DEFERRED, not NON-COMPLIANT. Implementation lands when P2 chargeback queue work begins.
As Support / Ops, I want chargebacks to flow into my exception queue with context, so that I can dispute or accept them efficiently.
Acceptance criteria:
- Given a chargeback is received (processor webhook), When we process it, Then the subscription is auto-paused, the charge is marked
disputed, and an email goes to the merchant contact with evidence-upload link.
UI states.
<!-- ui-states US-12.5 -->surface: "Admin (React/BigDesign) exception queue — chargeback rows in apps/admin/src/pages/exceptions/ExceptionQueueList.tsx. 'chargeback' is a registered ExceptionType (lines 32-38) and a filter Checkbox (TYPES, lines 54-61). Persona: Support / Ops. Per BRD §US-12.5 (Phase-2, Hive #923 deferral) chargebacks are a forward-compat stub; the live aggregator only emits a chargeback row when a charge's failure_code contains 'chargeback' (classifyType, db.ts:1874-1882) and no Phase-1 webhook routes real chargebacks there, so in practice no chargeback rows render today (see gaps)."
idle:
render: "A chargeback exception, when present, renders as a standard ExceptionRowEl (lines 389-433): severity color bar, the translated type label via FormattedMessage id merchantAdmin.exceptions.type.<type> (line 395), customer, impact and age columns. Expanding the row (setExpandedId, line 390) reveals detected-at, proposed remediation, and the action row (lines 449-466)."
primary_action: "On a chargeback row only two actions render: 'Open subscription' (lines 451-456) and 'Mark resolved' (lines 457-459). NORTH-STAR (BRD §US-12.5 AC): a dispute / accept choice plus an evidence-upload link, with the subscription auto-paused and the charge marked disputed — none rendered (see gaps)."
loading:
trigger: "GET /api/v1/exceptions on mount and whenever filters change (fetchExceptions, lines 129-147; useEffect deps [typeFilters, severityFilters, sortBy], lines 184-200). The resolve action POSTs /api/v1/admin/exceptions/{id}/resolve (resolveException, exceptions.ts:44-57)."
render: "While items === null && !error a plain 'Loading…' Text renders (merchantAdmin.exceptions.loading, lines 348-352) — no skeleton. During a resolve the modal confirm Button shows isLoading and disables (resolving, lines 489-490)."
error:
surfaced_at: "Two places: (1) a list-load failure renders a page-level BigDesign Message(type='warning', header merchantAdmin.exceptions.errorHeader) above the filter/list Layout (lines 280-288); (2) a resolve failure renders an inline Message(type='error') inside the resolve Modal (lines 506-513)."
render: "The thrown reason verbatim — 'HTTP <status>' from fetchExceptions (line 144) / authedFetch (exceptions.ts:32-34), or 'NO_AUTH_TOKEN' (fetchExceptions line 133)."
recovery: "List error: the rail filters stay interactive, so toggling any filter re-fires fetchExceptions (lines 184-200). Resolve error: handleResolve's finally sets resolving=false (line 232), re-enabling the modal so the rep edits the note and retries, or Cancel dismisses (lines 482-485)."
empty:
render: "When the queue has no rows (items && visible.length === 0) the shared 'All clear' empty card renders — a green success-check circle plus merchantAdmin.exceptions.empty.title and .empty.body (lines 354-384). Because no Phase-1 producer emits chargeback exceptions, the chargeback slice is effectively always empty today (see gaps)."
edge_status:
- status: "chargeback received (BRD §US-12.5 AC)"
affordance: "NORTH-STAR: the row exposes 'Dispute' and 'Accept' actions plus an evidence-upload link, the subscription auto-pauses and the charge is marked disputed. TODAY only generic 'Open subscription' + 'Mark resolved' render (lines 451-459); no dispute/accept/evidence affordance exists (see gaps)."
- status: "chargeback resolved generically"
affordance: "'Mark resolved' (lines 457-459) opens the resolve Modal, which requires a note (confirm disabled until resolveNote.trim(), line 489); on success the row is removed from the list (handleResolve, line 226)."
- status: "chargeback type filtered in the rail"
affordance: "The 'chargeback' type Checkbox (TYPES, lines 54-61; rendered lines 319-326) toggles the filter; with no chargeback rows produced, the filtered view falls through to the 'All clear' empty card (lines 354-384)."
disabled_focus:
keyboard: "The in-row actions ('Open subscription', 'Mark resolved') and the resolve Modal's note Textarea + Cancel/Confirm are real BigDesign Button/Textarea elements reachable in tab order; the Modal(variant='dialog') traps focus and is Esc-dismissable (onClose, line 478)."
guard: "Resolve requires a non-empty note (confirm disabled until resolveNote.trim(), line 489); resolve is non-destructive (it removes the queue item), so no typed-confirm."
gaps: "(1) No chargeback workflow — dispute/accept buttons, evidence-upload link, auto-pause and charge->disputed are all absent; only the generic resolve modal renders (P2-deferred per BRD §US-12.5 / Hive #923). (2) The live aggregator (classifyType, db.ts:1874-1882) only emits a chargeback row when a charge.failure_code contains 'chargeback'; no webhook producer routes real chargebacks, so the slice is empty in practice. (3) The row expander is a styled RowHeader div with onClick (line 390), not a native button — every queue row is keyboard-unfocusable/unexpandable. (4) The list-load failure uses a type='warning' Message (lines 280-288), under-weighting a hard fetch error."
US-12.6: Tax handling on refund
<!-- traceability:start:US-12.6 --><!-- traceability:end:US-12.6 -->Prototype: Tax on Refund
Phase: MVP · Persona: System
As the System, I want partial refunds to correctly prorate tax, so that merchant accounting stays accurate.
Acceptance criteria:
- Given a $100 charge with $7 tax is partially refunded $30, When the refund processes, Then the refund includes proportional tax (~$2.10) and the BC order reflects the adjusted tax.