Skip to content

Bulk refund all payments for cancelled event #81

Description

@finleysg

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-previewrefundService.getBulkRefundPreview(eventId)BulkRefundPreview
  • POST :eventId/bulk-refundrefundService.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:

  1. Preview phase (on mount): fetch preview, show table (player name, fee count, refund amount), total, "Refund All" button
  2. 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

  1. docker compose up -d --build
  2. Navigate to an event with confirmed payments in admin UI
  3. Click "Bulk Refunds" card → verify preview shows correct payments/amounts
  4. Click "Refund All" → verify Stripe refunds created (check Stripe dashboard)
  5. Revisit preview → verify previously-refunded payments now skipped
  6. pnpm --filter api test
  7. pnpm --filter web test

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions