← All stories

BRANCH · ef-026-refunds-ach

Refunds & ACH Withdrawal

EF-026 Persona: Organizer (finance) Stage: Closeout / settlement Roots in: admin-shell-access

Organizer issues refunds (full or partial), reviews settlement, and initiates ACH withdrawal of net proceeds. Three timing surfaces matter: instantaneous (refund-button click), 1-3 banking days (ACH settlement), and 30-90 days (chargeback dispute window). The story locks honest disclosure of each timing window and the contracts that prevent silent double-refund / over-withdrawal. Tier-3 tightening: refund transaction history now references ui-audit-log-viewer for the audit display foundation.

Preconditions

  • Organizer is signed in with role ≥ organizer (finance permission for ACH withdrawal).
  • Tenant has Stripe Connect or equivalent payout configured.
  • Event has at least one confirmed paid registration with a non-disputed charge.
  • Inherits EF-017's PaymentIntent + webhook contracts.

Happy path

  1. Organizer opens the registration's detail panel.

    From the guest list, click a confirmed-paid registration. Detail panel shows charge ID, amount paid, refund balance available, refund history, and a "Refund" button (only visible if refund_balance > 0 AND no open dispute).

  2. Click Refund opens a refund modal.

    Modal: amount input (defaults to full balance), reason selector ("requested by customer", "duplicate charge", "fraudulent", "other"), notes (organizer-only, audit-logged). Uses ui-destructive-confirmation pattern with type-to-confirm for full-refund cases (type the registration ID to confirm).

  3. Submit creates a Stripe refund.

    POST /v1/admin/registrations/:id/refunds with Idempotency-Key. Server creates a Stripe refund object referencing the original charge. Stripe returns the refund object; our DB stores refund row (refund_id, amount_cents, reason, organizer_id, created_at). Audit log row written.

  4. Webhook confirms refund.

    Stripe sends charge.refunded webhook. We update the refund row's status from pending → succeeded AND decrement the registration's refund_balance. If refund_balance reaches zero, the registration's status pill flips to refunded; if partial, it stays confirmed with a "partially refunded" sub-label.

  5. Organizer reviews settlement.

    "Funds" tab shows: Gross sales (cents), Stripe fees (cents), Refunds (cents), Net (cents), Available for withdrawal (cents). The "Available" amount lags actual gross by Stripe's payout schedule (typically 2-7 banking days from the original charge — varies by tenant Stripe config). Tooltip explicitly states "Funds become available [N] business days after the original transaction."

  6. Organizer initiates ACH withdrawal.

    "Withdraw" button. Modal shows: amount (capped at Available), bank account (pre-configured via Stripe Connect, last-4 only displayed), expected settlement date (1-3 banking days from initiation), idempotency-key per attempt. Confirm via ui-destructive-confirmation with type-to-confirm of the dollar amount.

  7. Withdrawal request acknowledged.

    POST creates a Stripe Payout (or equivalent), stores a payout row with status=pending. Webhook later transitions to in_transit and finally paid. UI shows ui-async-job-tracker style: pending → in_transit → paid (or failed, reversed, canceled). The UI never claims funds have arrived; it always says "expected by [date]" until the paid webhook lands.

Failure modes

Refund exceeds balance

Trigger: registration paid $200, already $50 refunded. Organizer attempts another $200 refund.

Server returns 422 REFUND_EXCEEDS_BALANCE. UI surfaces "You've already refunded $50; available to refund: $150." The refund modal updates its amount cap accordingly. Harness: existing $50 partial refund, attempt $200 refund, assert 422 with that error code, assert remaining-balance display is correct.

Refund attempted on disputed charge

Trigger: cardholder filed a chargeback; charge has dispute.created webhook on file. Organizer clicks Refund anyway.

Refund button is disabled when an open dispute exists; tooltip "Cannot refund — chargeback in progress. Resolve via Stripe dashboard." Server-side: even if the button is bypassed, the refund attempt returns 422 DISPUTE_OPEN with a link to the dispute record. Harness: charge with open dispute, assert button has aria-disabled=true, attempt refund via API, assert 422.

Idempotent refund retry

Trigger: organizer hits Submit, network blip, hits Submit again with the same modal still open.

Idempotency-Key is generated per modal-open and reused per submit. Server (and Stripe) dedupe — only one refund object created. Harness: dispatch refund POST twice within 1s with same Idempotency-Key, assert exactly 1 Stripe refund created, assert exactly 1 audit log row.

Partial refund balance arithmetic

Trigger: $200 paid; $30 refunded; $50 refunded; $100 refunded. Cumulative refunded = $180.

refund_balance_cents = 20000 - 3000 - 5000 - 10000 = 2000. Each refund row is independent (own ID, amount, reason). The registration's "remaining refundable" field is a computed sum. Harness: 3 successful partial refunds, assert remaining = $20.

Refund webhook delayed → UI shows pending state

Trigger: refund POST succeeds; webhook delayed by 60+ seconds.

UI shows refund row in "pending" state until webhook arrives. Organizer sees "Refund initiated. Will appear on the customer's statement within 5-10 business days." Refund balance decreases optimistically (server-side accounting), but the row's final state is gated by webhook. If webhook fails permanently, an alert fires for ops. Harness: stub webhook delay 60s, assert UI shows pending, then transitions to succeeded after webhook.

Withdrawal exceeds available balance

Trigger: organizer types $5000 but Available is $2000 (because $3000 is still in pending Stripe rolling-reserve window).

Withdrawal modal validates client-side (amount ≤ Available); type-to-confirm on the dollar amount catches typos. Server validates again. Excess request returns 422 WITHDRAWAL_EXCEEDS_AVAILABLE with "Available: $2000." Harness: stub Available=2000, attempt 5000, assert 422.

Withdrawal during pending dispute

Trigger: a registration has an open chargeback dispute. The disputed charge's amount is currently in Available.

Server-side: Available balance excludes amounts tied to open disputes (Stripe holds them in reserve). Withdrawal can't accidentally pull disputed funds. UI shows a separate "On hold (disputes): $X" line in the funds breakdown. Harness: stub a $500 dispute, assert Available decreases by $500, assert "On hold" line shows $500.

Withdrawal idempotency on retry

Trigger: organizer clicks Withdraw, network blip during the confirm step, retries.

Idempotency-Key per modal session. Same key forwarded to Stripe. Only one Payout created. Harness: dispatch withdrawal POST twice with same key, assert exactly 1 Stripe payout.

Withdrawal failed-at-Stripe (insufficient funds, account issue)

Trigger: Stripe rejects the payout (insufficient available balance — race condition with another webhook, OR account issue like missing tax info).

Webhook payout.failed. Payout row status → failed with reason. UI shows a failure banner with the reason and a "Retry" CTA (reuses the same modal). The funds remain available; nothing was deducted. Harness: stub payout.failed webhook, assert payout row failed, assert funds still available.

Withdrawal reversed mid-transit (rare — bank rejection)

Trigger: payout was in_transit, then reversed by the receiving bank (closed account, name mismatch).

Webhook payout.failed after in_transit. Payout row status → failed with reason "bank_rejected." UI shows a banner with explicit "The bank rejected this transfer. Update your bank details and retry." Funds return to Available. Audit log row written. Harness: stub the in_transit → failed sequence, assert UI shows reversed state.

Permission gating

Trigger: support-role user (no finance permission) opens the Funds tab.

Funds tab visible (read-only). Refund button hidden. Withdraw button hidden. Server-side: any POST to /refunds or /payouts from a non-finance role returns 403. Harness: support role attempts refund, assert 403, assert no audit row created.

ACH timing disclosure honesty

Trigger: organizer initiates withdrawal, expects funds same-day.

Modal shows "Expected settlement: [date]" computed from Stripe's payout schedule + ACH banking days, NOT same-day. Tooltip: "ACH transfers take 1-3 banking days. Funds may take longer to appear depending on your bank." Don't show optimistic timing. Harness: render withdrawal modal, assert displayed settlement date is ≥ 1 banking day in the future.

Stable test attributes

data-testWherePurpose
registration-detail-panelPer-registration detailShows charge + refund history
refund-buttonPer-registrationDisabled when balance=0 OR open dispute
refund-modalRefund flowInherits ui-destructive-confirmation
refund-amount-inputRefund modalCapped client-side at remaining balance
refund-balance-displayModal + detail panel"Available to refund: $X"
refund-history-listDetail panelEach prior refund (ui-data-table mini)
refund-pending-bannerVisible during webhook delay"Refund initiated; will appear..." copy
funds-tabEvent adminSettlement breakdown
funds-grossFunds tabGross sales
funds-feesFunds tabStripe fees
funds-refundsFunds tabTotal refunded
funds-on-hold-disputesFunds tabVisible only when open disputes exist
funds-availableFunds tabWithdrawable amount
funds-netFunds tabNet of fees + refunds
withdraw-buttonFunds tabHidden for non-finance roles
withdraw-modalWithdrawal flowIncludes type-to-confirm of the amount
withdraw-expected-settlementWithdrawal modalComputed banking-days date
withdraw-history-listFunds tabPast payouts (ui-data-table)
withdraw-failed-bannerVisible after payout.failed"Bank rejected" + Retry CTA
withdraw-trackerPer-payoutui-async-job-tracker showing pending/in_transit/paid

Agent test plan

Probe list
- refund-button-disabled-when-disputed: charge with open dispute, button has aria-disabled
- refund-exceeds-balance-422: prior $50 refund on $200, attempt $200 refund, assert 422
- refund-idempotent: dispatch twice with same key, exactly 1 Stripe refund + 1 audit row
- partial-refund-arithmetic: 3 partials totaling $180 of $200, remaining = $20
- refund-pending-then-succeeded: stub webhook delay 60s, UI pending → succeeded
- withdraw-exceeds-available-422: Available $2000, attempt $5000, assert 422
- withdraw-during-dispute-excludes-disputed-amount: $500 dispute, Available decreased by $500
- withdraw-idempotent: dispatch twice with same key, exactly 1 Stripe payout
- withdraw-failed-graceful: stub payout.failed, banner visible, funds returned to Available
- withdraw-reversed-mid-transit: stub in_transit → failed, banner shows reversed state
- non-finance-role-no-refund: support role attempts refund, assert 403
- non-finance-role-no-withdraw-button: support role on Funds tab, withdraw-button not visible
- ach-timing-disclosure: withdrawal modal shows settlement date ≥ 1 banking day out
- audit-log-refund: refund creates event_audit_log with reason + organizer_id
- audit-log-payout: payout creates event_audit_log with stripe_payout_id
- type-to-confirm-on-full-refund: full refund requires typing registration ID
- type-to-confirm-on-withdraw: withdrawal requires typing dollar amount