Summary
When weather forces an event to be cancelled, admins need to refund every payment. Add a bulk refund feature to the web admin event menu.
Behavior:
- Refund only (no dropping players/cancelling registrations)
- Skip already-refunded payments silently
- Refund subtotal only (not Stripe transaction fees)
- Continue processing if individual refunds fail
- Only refund confirmed payments (
confirmed=1, paymentCode starts with pi_)
Stripe: No batch refund API exists. Loop through payments calling POST /v1/refunds per PaymentIntent individually (standard pattern, well within rate limits).
Implementation Plan
Step 1: Domain Types
Modify packages/domain/src/types/register/refund.ts — add:
export interface BulkRefundPaymentPreview {
paymentId: number
playerName: string
feeCount: number
refundAmount: number
registrationFeeIds: number[]
}
export interface BulkRefundPreview {
eventId: number
payments: BulkRefundPaymentPreview[]
totalRefundAmount: number
skippedCount: number
}
export interface BulkRefundResult {
paymentId: number
success: boolean
error?: string
}
export interface BulkRefundResponse {
refundedCount: number
failedCount: number
skippedCount: number
totalRefundAmount: number
results: BulkRefundResult[]
}
Step 2: Repository — find payments by event
Modify apps/api/src/registration/repositories/payments.repository.ts — add method:
findConfirmedPaymentsByEventWithDetails(eventId) — query payment joined with registrationFee, registrationSlot, player where payment.eventId = eventId AND payment.confirmed = 1 AND paymentCode starts with pi_. Group by payment ID, return array with playerName.
Step 3: Service — preview + execute
Modify apps/api/src/registration/services/refund.service.ts — add two methods:
getBulkRefundPreview(eventId)
- Call
findConfirmedPaymentsByEventWithDetails(eventId)
- Filter to payments with at least one paid fee (
isPaid = 1)
- Build
BulkRefundPreview with per-payment details
- Skip payments with no paid fees (increment
skippedCount)
processBulkRefunds(eventId, issuerId)
- Call
getBulkRefundPreview(eventId) to get list
- If no refundable payments, return early with zeros
- Loop through preview payments, calling per-payment refund logic individually in try/catch
- On success: add to refunded count
- On failure: log error, add to failed results, continue
- Return
BulkRefundResponse with results per payment
Note: Don't reuse processRefunds() directly since it throws on first error. Extract per-payment logic or duplicate.
Step 4: Controller — two endpoints
Modify apps/api/src/registration/controllers/admin-registration.controller.ts — add:
GET :eventId/bulk-refund-preview → refundService.getBulkRefundPreview(eventId) → BulkRefundPreview
POST :eventId/bulk-refund → refundService.processBulkRefunds(eventId, issuerId) → BulkRefundResponse
Step 5: Next.js API proxy routes
Create apps/web/app/api/registration/bulk-refund-preview/route.ts — GET, query param eventId
Create apps/web/app/api/registration/bulk-refund/route.ts — POST, query param eventId
Follow drop-players/route.ts pattern.
Step 6: Bulk Refund Page
Create apps/web/app/events/[eventId]/refunds/page.tsx
Two-phase page using useReducer:
- Preview phase (on mount): fetch preview, show table (player name, fee count, refund amount), total, "Refund All" button
- Result phase: POST bulk-refund, show spinner, then results (refunded/failed counts, total amount, per-payment failures)
Step 7: Event Hub LinkCard
Modify apps/web/app/events/[eventId]/page.tsx — add:
<LinkCard
title="Bulk Refunds"
description="Refund all payments for a cancelled event."
href={`/events/${eventId}/refunds`}
disabled={false}
icon={"💰"}
/>
Step 8: Tests
Add tests for getBulkRefundPreview and processBulkRefunds:
- Preview with mix of paid/unpaid fees
- Preview with no payments
- Bulk refund continues on individual failure
- Bulk refund with no refundable payments returns zeros
Files Changed
| Action |
File |
| Modify |
packages/domain/src/types/register/refund.ts |
| Modify |
apps/api/src/registration/repositories/payments.repository.ts |
| Modify |
apps/api/src/registration/services/refund.service.ts |
| Modify |
apps/api/src/registration/controllers/admin-registration.controller.ts |
| Create |
apps/web/app/api/registration/bulk-refund-preview/route.ts |
| Create |
apps/web/app/api/registration/bulk-refund/route.ts |
| Create |
apps/web/app/events/[eventId]/refunds/page.tsx |
| Modify |
apps/web/app/events/[eventId]/page.tsx |
| Create |
apps/api/src/registration/services/__tests__/refund.service.test.ts |
Verification
docker compose up -d --build
- Navigate to an event with confirmed payments in admin UI
- Click "Bulk Refunds" card → verify preview shows correct payments/amounts
- Click "Refund All" → verify Stripe refunds created (check Stripe dashboard)
- Revisit preview → verify previously-refunded payments now skipped
pnpm --filter api test
pnpm --filter web test
Summary
When weather forces an event to be cancelled, admins need to refund every payment. Add a bulk refund feature to the web admin event menu.
Behavior:
confirmed=1,paymentCodestarts withpi_)Stripe: No batch refund API exists. Loop through payments calling
POST /v1/refundsper PaymentIntent individually (standard pattern, well within rate limits).Implementation Plan
Step 1: Domain Types
Modify
packages/domain/src/types/register/refund.ts— add:Step 2: Repository — find payments by event
Modify
apps/api/src/registration/repositories/payments.repository.ts— add method:findConfirmedPaymentsByEventWithDetails(eventId)— querypaymentjoined withregistrationFee,registrationSlot,playerwherepayment.eventId = eventIdANDpayment.confirmed = 1ANDpaymentCodestarts withpi_. Group by payment ID, return array withplayerName.Step 3: Service — preview + execute
Modify
apps/api/src/registration/services/refund.service.ts— add two methods:getBulkRefundPreview(eventId)findConfirmedPaymentsByEventWithDetails(eventId)isPaid = 1)BulkRefundPreviewwith per-payment detailsskippedCount)processBulkRefunds(eventId, issuerId)getBulkRefundPreview(eventId)to get listBulkRefundResponsewith results per paymentNote: Don't reuse
processRefunds()directly since it throws on first error. Extract per-payment logic or duplicate.Step 4: Controller — two endpoints
Modify
apps/api/src/registration/controllers/admin-registration.controller.ts— add:GET :eventId/bulk-refund-preview→refundService.getBulkRefundPreview(eventId)→BulkRefundPreviewPOST :eventId/bulk-refund→refundService.processBulkRefunds(eventId, issuerId)→BulkRefundResponseStep 5: Next.js API proxy routes
Create
apps/web/app/api/registration/bulk-refund-preview/route.ts— GET, query parameventIdCreate
apps/web/app/api/registration/bulk-refund/route.ts— POST, query parameventIdFollow
drop-players/route.tspattern.Step 6: Bulk Refund Page
Create
apps/web/app/events/[eventId]/refunds/page.tsxTwo-phase page using
useReducer:Step 7: Event Hub LinkCard
Modify
apps/web/app/events/[eventId]/page.tsx— add:Step 8: Tests
Add tests for
getBulkRefundPreviewandprocessBulkRefunds:Files Changed
packages/domain/src/types/register/refund.tsapps/api/src/registration/repositories/payments.repository.tsapps/api/src/registration/services/refund.service.tsapps/api/src/registration/controllers/admin-registration.controller.tsapps/web/app/api/registration/bulk-refund-preview/route.tsapps/web/app/api/registration/bulk-refund/route.tsapps/web/app/events/[eventId]/refunds/page.tsxapps/web/app/events/[eventId]/page.tsxapps/api/src/registration/services/__tests__/refund.service.test.tsVerification
docker compose up -d --buildpnpm --filter api testpnpm --filter web test