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
-
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).
-
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).
-
Submit creates a Stripe refund.
POST
/v1/admin/registrations/:id/refundswithIdempotency-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. -
Webhook confirms refund.
Stripe sends
charge.refundedwebhook. We update the refund row's status frompending → succeededAND decrement the registration's refund_balance. If refund_balance reaches zero, the registration's status pill flips torefunded; if partial, it staysconfirmedwith a "partially refunded" sub-label. -
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."
-
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.
-
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-test | Where | Purpose |
|---|---|---|
registration-detail-panel | Per-registration detail | Shows charge + refund history |
refund-button | Per-registration | Disabled when balance=0 OR open dispute |
refund-modal | Refund flow | Inherits ui-destructive-confirmation |
refund-amount-input | Refund modal | Capped client-side at remaining balance |
refund-balance-display | Modal + detail panel | "Available to refund: $X" |
refund-history-list | Detail panel | Each prior refund (ui-data-table mini) |
refund-pending-banner | Visible during webhook delay | "Refund initiated; will appear..." copy |
funds-tab | Event admin | Settlement breakdown |
funds-gross | Funds tab | Gross sales |
funds-fees | Funds tab | Stripe fees |
funds-refunds | Funds tab | Total refunded |
funds-on-hold-disputes | Funds tab | Visible only when open disputes exist |
funds-available | Funds tab | Withdrawable amount |
funds-net | Funds tab | Net of fees + refunds |
withdraw-button | Funds tab | Hidden for non-finance roles |
withdraw-modal | Withdrawal flow | Includes type-to-confirm of the amount |
withdraw-expected-settlement | Withdrawal modal | Computed banking-days date |
withdraw-history-list | Funds tab | Past payouts (ui-data-table) |
withdraw-failed-banner | Visible after payout.failed | "Bank rejected" + Retry CTA |
withdraw-tracker | Per-payout | ui-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