From 2487092f640f632b4e0984835ec736eadf6e992e Mon Sep 17 00:00:00 2001 From: Stuart Finley Date: Sat, 31 Jan 2026 12:51:35 +0000 Subject: [PATCH 01/12] [bulk-refund-prd 1] Add bulk refund types to domain package Added BulkRefundPaymentPreview, BulkRefundPreview, BulkRefundResult, BulkRefundResponse, BulkRefundProgressEvent interfaces to refund.ts. Types exported via existing barrel exports. Co-Authored-By: Claude Opus 4.5 --- packages/domain/src/types/register/refund.ts | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/domain/src/types/register/refund.ts b/packages/domain/src/types/register/refund.ts index b17a68fd..a490fd2a 100644 --- a/packages/domain/src/types/register/refund.ts +++ b/packages/domain/src/types/register/refund.ts @@ -13,3 +13,41 @@ export interface RefundRequest { paymentId: number registrationFeeIds: number[] } + +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[] +} + +export interface BulkRefundProgressEvent { + status: "processing" | "complete" | "error" + current: number + total: number + playerName?: string + error?: string + result?: BulkRefundResponse +} From 3965b2096e618bb7bdb9ac9a7d83046f666425db Mon Sep 17 00:00:00 2001 From: Stuart Finley Date: Sat, 31 Jan 2026 12:55:04 +0000 Subject: [PATCH 02/12] [bulk-refund-prd 2] Add repository method to find confirmed payments by event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added PaymentWithPlayerDetails type to database types - Added findConfirmedPaymentsByEventWithDetails() to PaymentsRepository - Joins payment → registrationFee → registrationSlot → player - Filters by eventId, confirmed=1, paymentCode LIKE 'pi_%' - Groups results by paymentId with player name Co-Authored-By: Claude Opus 4.5 --- apps/api/src/database/types.ts | 12 ++++ .../repositories/payments.repository.ts | 60 ++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/apps/api/src/database/types.ts b/apps/api/src/database/types.ts index 868b0c72..50b162d0 100644 --- a/apps/api/src/database/types.ts +++ b/apps/api/src/database/types.ts @@ -223,6 +223,18 @@ export type PaymentRowWithDetails = WithCompositions< { paymentDetails: RegistrationFeeRow[] } > +export type PaymentWithPlayerDetails = { + paymentId: number + paymentCode: string + paymentAmount: string + playerName: string + fees: { + registrationFeeId: number + amount: string + isPaid: number + }[] +} + // ============================================================================= // Common Composition Patterns - Scores // ============================================================================= diff --git a/apps/api/src/registration/repositories/payments.repository.ts b/apps/api/src/registration/repositories/payments.repository.ts index ac0dd9d4..ff5c9632 100644 --- a/apps/api/src/registration/repositories/payments.repository.ts +++ b/apps/api/src/registration/repositories/payments.repository.ts @@ -1,4 +1,4 @@ -import { eq, inArray } from "drizzle-orm" +import { and, eq, inArray, like } from "drizzle-orm" import { Inject, Injectable } from "@nestjs/common" @@ -6,6 +6,8 @@ import { DrizzleService, payment, PaymentRowWithDetails, + PaymentWithPlayerDetails, + player, refund, registrationFee, registrationSlot, @@ -70,6 +72,62 @@ export class PaymentsRepository { } } + async findConfirmedPaymentsByEventWithDetails( + eventId: number, + ): Promise { + const results = await this.drizzle.db + .select({ + paymentId: payment.id, + paymentCode: payment.paymentCode, + paymentAmount: payment.paymentAmount, + playerFirstName: player.firstName, + playerLastName: player.lastName, + registrationFeeId: registrationFee.id, + feeAmount: registrationFee.amount, + isPaid: registrationFee.isPaid, + }) + .from(payment) + .innerJoin(registrationFee, eq(payment.id, registrationFee.paymentId)) + .innerJoin(registrationSlot, eq(registrationFee.registrationSlotId, registrationSlot.id)) + .innerJoin(player, eq(registrationSlot.playerId, player.id)) + .where( + and( + eq(payment.eventId, eventId), + eq(payment.confirmed, 1), + like(payment.paymentCode, "pi_%"), + ), + ) + + // Group by payment ID + const paymentsMap = new Map() + for (const row of results) { + const existing = paymentsMap.get(row.paymentId) + if (existing) { + existing.fees.push({ + registrationFeeId: row.registrationFeeId, + amount: row.feeAmount, + isPaid: row.isPaid, + }) + } else { + paymentsMap.set(row.paymentId, { + paymentId: row.paymentId, + paymentCode: row.paymentCode, + paymentAmount: row.paymentAmount, + playerName: `${row.playerFirstName} ${row.playerLastName}`, + fees: [ + { + registrationFeeId: row.registrationFeeId, + amount: row.feeAmount, + isPaid: row.isPaid, + }, + ], + }) + } + } + + return Array.from(paymentsMap.values()) + } + async createPayment(data: PaymentInsert): Promise { const [result] = await this.drizzle.db.insert(payment).values(data) return Number(result.insertId) From b2a03b37a2be965a83e42aefdaea496032ac952f Mon Sep 17 00:00:00 2001 From: Stuart Finley Date: Sat, 31 Jan 2026 12:58:03 +0000 Subject: [PATCH 03/12] [bulk-refund-prd 3] Add getBulkRefundPreview service method Calls findConfirmedPaymentsByEventWithDetails, filters to payments with paid fees (isPaid=1), builds BulkRefundPreview with per-payment details. Payments with no paid fees increment skippedCount. Co-Authored-By: Claude Opus 4.5 --- .../registration/services/refund.service.ts | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/api/src/registration/services/refund.service.ts b/apps/api/src/registration/services/refund.service.ts index 8d7dbf2b..256c7995 100644 --- a/apps/api/src/registration/services/refund.service.ts +++ b/apps/api/src/registration/services/refund.service.ts @@ -1,7 +1,12 @@ import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common" import { inArray } from "drizzle-orm" -import { Refund, RefundRequest } from "@repo/domain/types" +import { + BulkRefundPaymentPreview, + BulkRefundPreview, + Refund, + RefundRequest, +} from "@repo/domain/types" import { DrizzleService, refund, registrationFee, toDbString } from "../../database" import { StripeService } from "../../stripe/stripe.service" @@ -130,4 +135,39 @@ export class RefundService { } await this.paymentsRepository.confirmRefund(row.id) } + + /** Get bulk refund preview for an event. */ + async getBulkRefundPreview(eventId: number): Promise { + const confirmedPayments = + await this.paymentsRepository.findConfirmedPaymentsByEventWithDetails(eventId) + + const payments: BulkRefundPaymentPreview[] = [] + let skippedCount = 0 + + for (const p of confirmedPayments) { + const paidFees = p.fees.filter((f) => f.isPaid === 1) + if (paidFees.length === 0) { + skippedCount++ + continue + } + + const refundAmount = paidFees.reduce((sum, f) => sum + parseFloat(f.amount), 0) + payments.push({ + paymentId: p.paymentId, + playerName: p.playerName, + feeCount: paidFees.length, + refundAmount, + registrationFeeIds: paidFees.map((f) => f.registrationFeeId), + }) + } + + const totalRefundAmount = payments.reduce((sum, p) => sum + p.refundAmount, 0) + + return { + eventId, + payments, + totalRefundAmount, + skippedCount, + } + } } From 3f973cecb5d6bf9e3da885836dbf12049318b649 Mon Sep 17 00:00:00 2001 From: Stuart Finley Date: Sat, 31 Jan 2026 13:01:10 +0000 Subject: [PATCH 04/12] [bulk-refund-prd 4] Add bulk refund progress tracker New BulkRefundProgressTracker class for SSE streaming: - startTracking(eventId) creates Subject, returns it - getProgressObservable(eventId) returns Observable if running - emitProgress(eventId, current, total, playerName) sends processing event - completeOperation(eventId, result) sends complete event, cleans up - errorOperation(eventId, error) sends error event, cleans up - Auto-cleanup after 5min timeout Co-Authored-By: Claude Opus 4.5 --- apps/api/src/registration/index.ts | 1 + .../src/registration/registration.module.ts | 3 + .../services/bulk-refund-progress-tracker.ts | 122 ++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 apps/api/src/registration/services/bulk-refund-progress-tracker.ts diff --git a/apps/api/src/registration/index.ts b/apps/api/src/registration/index.ts index 2f771cb9..e81296ac 100644 --- a/apps/api/src/registration/index.ts +++ b/apps/api/src/registration/index.ts @@ -11,6 +11,7 @@ export { PaymentsService } from "./services/payments.service" export { RegistrationService } from "./services/registration.service" export { CleanupService } from "./services/cleanup.service" export { RefundService } from "./services/refund.service" +export { BulkRefundProgressTracker } from "./services/bulk-refund-progress-tracker" export { RegistrationRepository } from "./repositories/registration.repository" export { PaymentsRepository } from "./repositories/payments.repository" export { RegistrationCleanupCron } from "./cron/registration-cleanup.cron" diff --git a/apps/api/src/registration/registration.module.ts b/apps/api/src/registration/registration.module.ts index cfc7b68c..81d52f5f 100644 --- a/apps/api/src/registration/registration.module.ts +++ b/apps/api/src/registration/registration.module.ts @@ -21,6 +21,7 @@ import { RefundService, PlayerService, CleanupService, + BulkRefundProgressTracker, } from "./" @Module({ @@ -39,6 +40,7 @@ import { ], providers: [ AdminRegistrationService, + BulkRefundProgressTracker, CleanupService, PaymentsRepository, PaymentsService, @@ -52,6 +54,7 @@ import { ], exports: [ AdminRegistrationService, + BulkRefundProgressTracker, CleanupService, PaymentsService, PlayerService, diff --git a/apps/api/src/registration/services/bulk-refund-progress-tracker.ts b/apps/api/src/registration/services/bulk-refund-progress-tracker.ts new file mode 100644 index 00000000..5f93ec9d --- /dev/null +++ b/apps/api/src/registration/services/bulk-refund-progress-tracker.ts @@ -0,0 +1,122 @@ +import { Observable, Subject } from "rxjs" + +import { Injectable, Logger } from "@nestjs/common" +import { BulkRefundProgressEvent, BulkRefundResponse } from "@repo/domain/types" + +const PROGRESS_CLEANUP_MS = 5 * 60 * 1000 // 5 minutes + +@Injectable() +export class BulkRefundProgressTracker { + private readonly logger = new Logger(BulkRefundProgressTracker.name) + + private readonly activeOperations = new Map>() + + /** + * Start tracking a new bulk refund operation and return the progress subject + */ + startTracking(eventId: number): Subject { + if (this.activeOperations.has(eventId)) { + throw new Error(`Bulk refund operation already in progress for event ${eventId}`) + } + + const subject = new Subject() + this.activeOperations.set(eventId, subject) + + // Auto-cleanup after timeout + setTimeout(() => { + this.cleanupOperation(eventId) + }, PROGRESS_CLEANUP_MS) + + return subject + } + + /** + * Get the progress observable for an active operation + */ + getProgressObservable(eventId: number): Observable | null { + return this.activeOperations.get(eventId)?.asObservable() ?? null + } + + /** + * Emit progress update for a payment being processed + */ + emitProgress(eventId: number, current: number, total: number, playerName: string): void { + const subject = this.activeOperations.get(eventId) + if (subject) { + subject.next({ + status: "processing", + current, + total, + playerName, + }) + } + } + + /** + * Mark operation as complete with final results + */ + completeOperation(eventId: number, result: BulkRefundResponse): void { + const subject = this.activeOperations.get(eventId) + if (subject) { + subject.next({ + status: "complete", + current: result.refundedCount + result.failedCount, + total: result.refundedCount + result.failedCount + result.skippedCount, + result, + }) + } + + // Cleanup after short delay to allow final event to be sent + setTimeout(() => { + this.cleanupOperation(eventId) + }, 1000) + } + + /** + * Mark operation as failed with error + */ + errorOperation(eventId: number, error: string): void { + const subject = this.activeOperations.get(eventId) + if (subject) { + subject.next({ + status: "error", + current: 0, + total: 0, + error, + }) + } + + // Cleanup after short delay + setTimeout(() => { + this.cleanupOperation(eventId) + }, 1000) + } + + /** + * Check if an operation is currently active + */ + isOperationActive(eventId: number): boolean { + return this.activeOperations.has(eventId) + } + + /** + * Clean up resources for an operation + */ + private cleanupOperation(eventId: number): void { + const subject = this.activeOperations.get(eventId) + if (subject) { + subject.complete() + this.activeOperations.delete(eventId) + this.logger.debug(`Cleaned up bulk refund operation for event ${eventId}`) + } + } + + /** + * Force cleanup of all operations (for testing/shutdown) + */ + cleanupAll(): void { + for (const eventId of this.activeOperations.keys()) { + this.cleanupOperation(eventId) + } + } +} From f0e0a64ca59667e92c8bbc0b6fd7f848ef91c520 Mon Sep 17 00:00:00 2001 From: Stuart Finley Date: Sat, 31 Jan 2026 13:04:48 +0000 Subject: [PATCH 05/12] [bulk-refund-prd 5] Add processBulkRefundsStream service method Returns Observable immediately, spawns async background op to process refunds one-by-one with progress tracking. Continues on individual payment failure. Co-Authored-By: Claude Opus 4.5 --- .../registration/services/refund.service.ts | 67 ++++++ plans/bulk-refund-prd.json | 223 ++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 plans/bulk-refund-prd.json diff --git a/apps/api/src/registration/services/refund.service.ts b/apps/api/src/registration/services/refund.service.ts index 256c7995..bc38f602 100644 --- a/apps/api/src/registration/services/refund.service.ts +++ b/apps/api/src/registration/services/refund.service.ts @@ -1,9 +1,14 @@ +import { Observable } from "rxjs" + import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common" import { inArray } from "drizzle-orm" import { BulkRefundPaymentPreview, BulkRefundPreview, + BulkRefundProgressEvent, + BulkRefundResponse, + BulkRefundResult, Refund, RefundRequest, } from "@repo/domain/types" @@ -12,6 +17,7 @@ import { DrizzleService, refund, registrationFee, toDbString } from "../../datab import { StripeService } from "../../stripe/stripe.service" import { PaymentsRepository } from "../repositories/payments.repository" import { toRefund } from "../mappers" +import { BulkRefundProgressTracker } from "./bulk-refund-progress-tracker" @Injectable() export class RefundService { @@ -21,6 +27,7 @@ export class RefundService { @Inject(DrizzleService) private readonly drizzle: DrizzleService, @Inject(PaymentsRepository) private readonly paymentsRepository: PaymentsRepository, @Inject(StripeService) private readonly stripeService: StripeService, + @Inject(BulkRefundProgressTracker) private readonly progressTracker: BulkRefundProgressTracker, ) {} /** Process Stripe refunds for payments. */ @@ -170,4 +177,64 @@ export class RefundService { skippedCount, } } + + /** Process bulk refunds for an event with streaming progress. */ + processBulkRefundsStream(eventId: number, issuerId: number): Observable { + // Return existing observable if operation is already running + const existing = this.progressTracker.getProgressObservable(eventId) + if (existing) { + return existing + } + + const subject = this.progressTracker.startTracking(eventId) + + // Spawn async background operation + void (async () => { + try { + const preview = await this.getBulkRefundPreview(eventId) + const total = preview.payments.length + const results: BulkRefundResult[] = [] + let refundedCount = 0 + let failedCount = 0 + let totalRefundAmount = 0 + + for (let i = 0; i < preview.payments.length; i++) { + const payment = preview.payments[i] + this.progressTracker.emitProgress(eventId, i + 1, total, payment.playerName) + + try { + await this.processRefunds( + [{ paymentId: payment.paymentId, registrationFeeIds: payment.registrationFeeIds }], + issuerId, + ) + results.push({ paymentId: payment.paymentId, success: true }) + refundedCount++ + totalRefundAmount += payment.refundAmount + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error" + this.logger.error( + `Bulk refund failed for payment ${payment.paymentId}: ${errorMessage}`, + ) + results.push({ paymentId: payment.paymentId, success: false, error: errorMessage }) + failedCount++ + } + } + + const response: BulkRefundResponse = { + refundedCount, + failedCount, + skippedCount: preview.skippedCount, + totalRefundAmount, + results, + } + this.progressTracker.completeOperation(eventId, response) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error" + this.logger.error(`Bulk refund operation failed for event ${eventId}: ${errorMessage}`) + this.progressTracker.errorOperation(eventId, errorMessage) + } + })() + + return subject.asObservable() + } } diff --git a/plans/bulk-refund-prd.json b/plans/bulk-refund-prd.json new file mode 100644 index 00000000..ddbc06de --- /dev/null +++ b/plans/bulk-refund-prd.json @@ -0,0 +1,223 @@ +{ + "feature": "Bulk Refund for Cancelled Events", + "description": "Add bulk refund feature to web admin event menu for refunding all payments when weather cancels an event. Uses SSE streaming for real-time progress feedback.", + "issueUrl": "https://github.com/finleysg/bhmc-admin/issues/81", + "items": [ + { + "id": 1, + "category": "domain", + "description": "Add bulk refund types to domain package", + "details": "Add BulkRefundPaymentPreview, BulkRefundPreview, BulkRefundResult, BulkRefundResponse, and BulkRefundProgressEvent interfaces to packages/domain/src/types/register/refund.ts", + "stepsToVerify": [ + "BulkRefundPaymentPreview has: paymentId, playerName, feeCount, refundAmount, registrationFeeIds[]", + "BulkRefundPreview has: eventId, payments[], totalRefundAmount, skippedCount", + "BulkRefundResult has: paymentId, success, error?", + "BulkRefundResponse has: refundedCount, failedCount, skippedCount, totalRefundAmount, results[]", + "BulkRefundProgressEvent has: status ('processing'|'complete'|'error'), current, total, playerName?, error?, result? (BulkRefundResponse when complete)", + "Types are exported from domain package index" + ], + "passes": true + }, + { + "id": 2, + "category": "api", + "description": "Add repository method to find confirmed payments by event", + "details": "Add findConfirmedPaymentsByEventWithDetails(eventId) to apps/api/src/registration/repositories/payments.repository.ts. Query payment joined with registrationFee, registrationSlot, player where eventId matches, confirmed=1, and paymentCode starts with 'pi_'. Return array with player names.", + "stepsToVerify": [ + "Method exists in payments.repository.ts", + "Joins payment with registrationFee, registrationSlot, and player tables", + "Filters by eventId, confirmed=1, paymentCode LIKE 'pi_%'", + "Returns payment details including player name for each payment", + "Groups results by payment ID" + ], + "passes": true + }, + { + "id": 3, + "category": "api", + "description": "Add getBulkRefundPreview service method", + "details": "Add getBulkRefundPreview(eventId) to apps/api/src/registration/services/refund.service.ts. Call repository to get confirmed payments, 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).", + "stepsToVerify": [ + "Method exists in refund.service.ts", + "Calls findConfirmedPaymentsByEventWithDetails", + "Filters payments to those with at least one isPaid=1 fee", + "Returns BulkRefundPreview with correct totals", + "Skipped payments (no paid fees) increment skippedCount", + "Each payment preview includes playerName, feeCount, refundAmount" + ], + "passes": true + }, + { + "id": 4, + "category": "api", + "description": "Add bulk refund progress tracker", + "details": "Add BulkRefundProgressTracker class to apps/api/src/registration/services/bulk-refund-progress-tracker.ts following the pattern in apps/api/src/golfgenius/services/progress-tracker.ts. Manages RxJS Subject per eventId, emits BulkRefundProgressEvent, handles startTracking(), emitProgress(), completeOperation(), errorOperation().", + "stepsToVerify": [ + "File exists at apps/api/src/registration/services/bulk-refund-progress-tracker.ts", + "Class is injectable (@Injectable)", + "Maintains Map>", + "startTracking(eventId) creates Subject and returns Observable", + "getProgressObservable(eventId) returns existing Observable if running", + "emitProgress(eventId, current, total, playerName) sends processing event", + "completeOperation(eventId, result) sends complete event and cleans up", + "errorOperation(eventId, error) sends error event and cleans up" + ], + "passes": true + }, + { + "id": 5, + "category": "api", + "description": "Add processBulkRefundsStream service method", + "details": "Add processBulkRefundsStream(eventId, issuerId) to apps/api/src/registration/services/refund.service.ts. Returns Observable immediately. Spawns async operation that gets preview, loops through payments calling per-payment refund individually in try/catch, emits progress for each payment via tracker. On failure: log error, add to failed results, continue.", + "stepsToVerify": [ + "Method exists in refund.service.ts", + "Returns Observable immediately (non-blocking)", + "Checks if operation already running via tracker.getProgressObservable()", + "Calls tracker.startTracking() to initialize stream", + "Spawns async background operation (IIFE)", + "Calls getBulkRefundPreview to get payment list", + "Emits progress via tracker.emitProgress() for each payment processed", + "Continues processing on individual payment failure", + "Calls tracker.completeOperation() with BulkRefundResponse on success", + "Calls tracker.errorOperation() on fatal error" + ], + "passes": true + }, + { + "id": 6, + "category": "api", + "description": "Add bulk refund preview endpoint to controller", + "details": "Add GET :eventId/bulk-refund-preview endpoint to apps/api/src/registration/controllers/admin-registration.controller.ts that calls refundService.getBulkRefundPreview(eventId) and returns BulkRefundPreview.", + "stepsToVerify": [ + "Endpoint exists at GET /admin-registration/:eventId/bulk-refund-preview", + "Calls refundService.getBulkRefundPreview with eventId", + "Returns BulkRefundPreview JSON", + "Requires admin authentication" + ], + "passes": false + }, + { + "id": 7, + "category": "api", + "description": "Add bulk refund SSE endpoint to controller", + "details": "Add @Sse :eventId/bulk-refund endpoint to apps/api/src/registration/controllers/admin-registration.controller.ts. Checks if operation already running, calls refundService.processBulkRefundsStream(), pipes Observable to SSE format with map(data => ({data: JSON.stringify(data)})).", + "stepsToVerify": [ + "Endpoint exists at GET /admin-registration/:eventId/bulk-refund (SSE)", + "Uses @Sse() decorator", + "Checks if operation already running via tracker.getProgressObservable()", + "Returns existing Observable if already running", + "Calls refundService.processBulkRefundsStream() for new operations", + "Pipes events through map() to wrap as {data: JSON string}", + "Requires admin authentication" + ], + "passes": false + }, + { + "id": 8, + "category": "web-api", + "description": "Add bulk-refund-preview Next.js API route", + "details": "Create apps/web/app/api/registration/bulk-refund-preview/route.ts with GET handler that proxies to NestJS /admin-registration/{eventId}/bulk-refund-preview. Use query param eventId. Follow drop-players/route.ts pattern.", + "stepsToVerify": [ + "File exists at apps/web/app/api/registration/bulk-refund-preview/route.ts", + "GET handler reads eventId from query params", + "Proxies to NestJS /admin-registration/{eventId}/bulk-refund-preview", + "Uses fetchWithAuth pattern", + "Returns BulkRefundPreview JSON" + ], + "passes": false + }, + { + "id": 9, + "category": "web-api", + "description": "Add bulk-refund SSE proxy Next.js API route", + "details": "Create apps/web/app/api/registration/bulk-refund/route.ts with GET handler that proxies SSE to NestJS /admin-registration/{eventId}/bulk-refund. Use fetchSSEWithAuth() from lib/api-proxy.ts. Follow golfgenius import-scores/route.ts pattern.", + "stepsToVerify": [ + "File exists at apps/web/app/api/registration/bulk-refund/route.ts", + "GET handler reads eventId from query params", + "Uses fetchSSEWithAuth() to proxy SSE stream", + "Sets Content-Type: text/event-stream header", + "Streams response body back to client" + ], + "passes": false + }, + { + "id": 10, + "category": "web-ui", + "description": "Create bulk refund page with preview phase", + "details": "Create apps/web/app/events/[eventId]/refunds/page.tsx. On mount fetch preview from /api/registration/bulk-refund-preview?eventId={id}. Show table with columns: player name, fee count, refund amount. Show total refund amount and skipped count. Include 'Refund All' button.", + "stepsToVerify": [ + "Page exists at /events/{eventId}/refunds", + "Fetches preview on mount", + "Shows loading state while fetching", + "Displays table with player name, fee count, refund amount columns", + "Shows total refund amount", + "Shows skipped count if > 0", + "'Refund All' button is visible and enabled when payments exist", + "'Refund All' button is disabled when no refundable payments" + ], + "passes": false + }, + { + "id": 11, + "category": "web-ui", + "description": "Add SSE streaming result phase to bulk refund page", + "details": "Extend apps/web/app/events/[eventId]/refunds/page.tsx to handle result phase with SSE streaming. Use useReducer for state management. Clicking 'Refund All' creates EventSource to /api/registration/bulk-refund?eventId={id}. Display progress bar showing current/total, current player name. On 'complete' status show final results: refunded count, failed count, total amount, per-payment failures.", + "stepsToVerify": [ + "Page uses useReducer for state management", + "Clicking 'Refund All' creates new EventSource", + "EventSource subscribes to /api/registration/bulk-refund?eventId={id}", + "onmessage handler parses JSON and updates progress state", + "Shows progress bar with current/total during processing", + "Shows current player name being processed", + "Closes EventSource on 'complete' or 'error' status", + "Shows refunded count after completion", + "Shows failed count if > 0", + "Shows total refund amount", + "Lists per-payment failures with error messages" + ], + "passes": false + }, + { + "id": 12, + "category": "web-ui", + "description": "Add Bulk Refunds LinkCard to event hub", + "details": "Add LinkCard to apps/web/app/events/[eventId]/page.tsx with title 'Bulk Refunds', description 'Refund all payments for a cancelled event.', href to /events/{eventId}/refunds, and appropriate icon.", + "stepsToVerify": [ + "LinkCard exists on event hub page", + "Title is 'Bulk Refunds'", + "Description is 'Refund all payments for a cancelled event.'", + "Links to /events/{eventId}/refunds", + "Has an icon (suggested: money/refund related)" + ], + "passes": false + }, + { + "id": 13, + "category": "test", + "description": "Add tests for getBulkRefundPreview", + "details": "Add tests to apps/api/src/registration/services/__tests__/refund.service.test.ts for getBulkRefundPreview method.", + "stepsToVerify": [ + "Test file exists or is extended", + "Test: preview with mix of paid/unpaid fees returns correct counts", + "Test: preview with no payments returns empty result", + "Test: payments with no paid fees are skipped" + ], + "passes": false + }, + { + "id": 14, + "category": "test", + "description": "Add tests for processBulkRefundsStream", + "details": "Add tests to apps/api/src/registration/services/__tests__/refund.service.test.ts for processBulkRefundsStream method.", + "stepsToVerify": [ + "Test: returns Observable immediately", + "Test: emits progress events for each payment", + "Test: bulk refund continues processing on individual failure", + "Test: bulk refund with no refundable payments completes with zeros", + "Test: final event has status 'complete' with BulkRefundResponse", + "Test: failed refunds appear in results with error messages" + ], + "passes": false + } + ] +} From 8bb51136482668b3943c8ca6ac7112878ca5ec0e Mon Sep 17 00:00:00 2001 From: Stuart Finley Date: Sat, 31 Jan 2026 13:08:02 +0000 Subject: [PATCH 06/12] [bulk-refund-prd 6] Add bulk refund preview endpoint to controller Add GET :eventId/bulk-refund-preview endpoint that returns BulkRefundPreview. Co-Authored-By: Claude Opus 4.5 --- .../controllers/admin-registration.controller.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/api/src/registration/controllers/admin-registration.controller.ts b/apps/api/src/registration/controllers/admin-registration.controller.ts index ffd5470b..8184d4dd 100644 --- a/apps/api/src/registration/controllers/admin-registration.controller.ts +++ b/apps/api/src/registration/controllers/admin-registration.controller.ts @@ -12,6 +12,7 @@ import { import type { AdminRegistration, AvailableSlotGroup, + BulkRefundPreview, RefundRequest, PlayerQuery, RegisteredPlayer, @@ -189,4 +190,11 @@ export class AdminRegistrationController { await this.refundService.processRefunds(refundRequests, issuerId) return { success: true } } + + @Get(":eventId/bulk-refund-preview") + async getBulkRefundPreview( + @Param("eventId", ParseIntPipe) eventId: number, + ): Promise { + return this.refundService.getBulkRefundPreview(eventId) + } } From 46e5c3caa2a542e46cb6ec9011d6eee4e66527f0 Mon Sep 17 00:00:00 2001 From: Stuart Finley Date: Sat, 31 Jan 2026 13:11:27 +0000 Subject: [PATCH 07/12] [bulk-refund-prd 7] Add bulk refund SSE endpoint to controller Add @Sse :eventId/bulk-refund endpoint for streaming bulk refund progress. Checks for existing operation, returns Observable piped through map() to JSON format. Follows golfgenius.controller.ts pattern. Co-Authored-By: Claude Opus 4.5 --- .../admin-registration.controller.ts | 29 +++++++++++++++++++ plans/bulk-refund-prd.json | 4 +-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/api/src/registration/controllers/admin-registration.controller.ts b/apps/api/src/registration/controllers/admin-registration.controller.ts index 8184d4dd..594b2bdb 100644 --- a/apps/api/src/registration/controllers/admin-registration.controller.ts +++ b/apps/api/src/registration/controllers/admin-registration.controller.ts @@ -1,3 +1,6 @@ +import { Observable } from "rxjs" +import { map } from "rxjs/operators" + import { Body, Controller, @@ -8,6 +11,7 @@ import { ParseIntPipe, Post, Query, + Sse, } from "@nestjs/common" import type { AdminRegistration, @@ -28,6 +32,7 @@ import type { import { Admin } from "../../auth" import { AdminRegistrationService } from "../services/admin-registration.service" +import { BulkRefundProgressTracker } from "../services/bulk-refund-progress-tracker" import { PlayerService } from "../services/player.service" import { RefundService } from "../services/refund.service" import { RegistrationService } from "../services/registration.service" @@ -43,6 +48,7 @@ export class AdminRegistrationController { @Inject(PlayerService) private readonly adminRegisterService: PlayerService, @Inject(RefundService) private readonly refundService: RefundService, @Inject(RegistrationService) private readonly registrationService: RegistrationService, + @Inject(BulkRefundProgressTracker) private readonly progressTracker: BulkRefundProgressTracker, ) {} @Get("players") @@ -197,4 +203,27 @@ export class AdminRegistrationController { ): Promise { return this.refundService.getBulkRefundPreview(eventId) } + + @Sse(":eventId/bulk-refund") + bulkRefund(@Param("eventId", ParseIntPipe) eventId: number): Observable<{ data: string }> { + // Check if operation is already running + const existing = this.progressTracker.getProgressObservable(eventId) + if (existing) { + return existing.pipe( + map((progress) => ({ + data: JSON.stringify(progress), + })), + ) + } + + // Start new bulk refund and return progress stream + const issuerId = 1 // TODO: change issuer to a string + const observable = this.refundService.processBulkRefundsStream(eventId, issuerId) + + return observable.pipe( + map((progress) => ({ + data: JSON.stringify(progress), + })), + ) + } } diff --git a/plans/bulk-refund-prd.json b/plans/bulk-refund-prd.json index ddbc06de..6bffcce2 100644 --- a/plans/bulk-refund-prd.json +++ b/plans/bulk-refund-prd.json @@ -94,7 +94,7 @@ "Returns BulkRefundPreview JSON", "Requires admin authentication" ], - "passes": false + "passes": true }, { "id": 7, @@ -110,7 +110,7 @@ "Pipes events through map() to wrap as {data: JSON string}", "Requires admin authentication" ], - "passes": false + "passes": true }, { "id": 8, From cbfb13536e0433eaa7677ff71441bc969e6086c1 Mon Sep 17 00:00:00 2001 From: Stuart Finley Date: Sat, 31 Jan 2026 13:13:59 +0000 Subject: [PATCH 08/12] [bulk-refund-prd 8] Add bulk-refund-preview Next.js API route GET /api/registration/bulk-refund-preview?eventId={id} Proxies to NestJS /admin-registration/{eventId}/bulk-refund-preview Co-Authored-By: Claude Opus 4.5 --- .../api/registration/bulk-refund-preview/route.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 apps/web/app/api/registration/bulk-refund-preview/route.ts diff --git a/apps/web/app/api/registration/bulk-refund-preview/route.ts b/apps/web/app/api/registration/bulk-refund-preview/route.ts new file mode 100644 index 00000000..642d6028 --- /dev/null +++ b/apps/web/app/api/registration/bulk-refund-preview/route.ts @@ -0,0 +1,13 @@ +import { NextRequest, NextResponse } from "next/server" +import { fetchWithAuth } from "@/lib/api-proxy" + +export async function GET(request: NextRequest) { + const eventId = request.nextUrl.searchParams.get("eventId") + + if (!eventId) { + return NextResponse.json({ error: "eventId is required" }, { status: 400 }) + } + + const backendPath = `/admin-registration/${eventId}/bulk-refund-preview` + return fetchWithAuth({ request, backendPath }) +} From 1c1b94201a0fe01eaf74a72fa0aad88f355e633c Mon Sep 17 00:00:00 2001 From: Stuart Finley Date: Sat, 31 Jan 2026 18:08:54 +0000 Subject: [PATCH 09/12] [bulk-refund-prd 9] Add bulk-refund SSE proxy Next.js API route - GET /api/registration/bulk-refund?eventId=X - Uses fetchSSEWithAuth() to proxy SSE stream - Follows same pattern as bulk-refund-preview route Co-Authored-By: Claude Opus 4.5 --- .../app/api/registration/bulk-refund/route.ts | 16 ++++++++++++++++ plans/bulk-refund-prd.json | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/api/registration/bulk-refund/route.ts diff --git a/apps/web/app/api/registration/bulk-refund/route.ts b/apps/web/app/api/registration/bulk-refund/route.ts new file mode 100644 index 00000000..eb54672b --- /dev/null +++ b/apps/web/app/api/registration/bulk-refund/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server" + +import { fetchSSEWithAuth } from "@/lib/api-proxy" + +export async function GET(request: NextRequest) { + const eventId = request.nextUrl.searchParams.get("eventId") + + if (!eventId) { + return NextResponse.json({ error: "eventId is required" }, { status: 400 }) + } + + return fetchSSEWithAuth({ + request, + backendPath: `/admin-registration/${eventId}/bulk-refund`, + }) +} diff --git a/plans/bulk-refund-prd.json b/plans/bulk-refund-prd.json index 6bffcce2..2808c7ce 100644 --- a/plans/bulk-refund-prd.json +++ b/plans/bulk-refund-prd.json @@ -124,7 +124,7 @@ "Uses fetchWithAuth pattern", "Returns BulkRefundPreview JSON" ], - "passes": false + "passes": true }, { "id": 9, @@ -138,7 +138,7 @@ "Sets Content-Type: text/event-stream header", "Streams response body back to client" ], - "passes": false + "passes": true }, { "id": 10, From 593e0c21e543812cd4c64727fca18b4bc26dac77 Mon Sep 17 00:00:00 2001 From: Stuart Finley Date: Sat, 31 Jan 2026 22:02:38 +0000 Subject: [PATCH 10/12] [bulk-refund-prd 10] Create bulk refund page with preview phase - apps/web/app/events/[eventId]/refunds/page.tsx: page with preview table - Fetches preview on mount, shows player name/fee count/refund amount - Shows total refund amount, skipped count, Refund All button Co-Authored-By: Claude Opus 4.5 --- .../web/app/events/[eventId]/refunds/page.tsx | 145 ++++++++++++++++++ plans/bulk-refund-prd.json | 2 +- 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/events/[eventId]/refunds/page.tsx diff --git a/apps/web/app/events/[eventId]/refunds/page.tsx b/apps/web/app/events/[eventId]/refunds/page.tsx new file mode 100644 index 00000000..3a8ca55a --- /dev/null +++ b/apps/web/app/events/[eventId]/refunds/page.tsx @@ -0,0 +1,145 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import type { BulkRefundPreview } from "@repo/domain/types" +import { useAuth } from "@/lib/auth-context" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { PageLayout } from "@/components/ui/page-layout" +import { Card, CardBody, CardTitle } from "@/components/ui/card" +import { Alert } from "@/components/ui/alert" + +export default function BulkRefundPage() { + const { isAuthenticated: signedIn, isLoading: isPending } = useAuth() + const router = useRouter() + const { eventId } = useParams<{ eventId: string }>() + + const [preview, setPreview] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!signedIn || !eventId) return + + const fetchPreview = async () => { + try { + const response = await fetch(`/api/registration/bulk-refund-preview?eventId=${eventId}`) + if (!response.ok) { + const errorBody = await response.text() + throw new Error(`Failed to fetch preview: ${errorBody}`) + } + const data = (await response.json()) as BulkRefundPreview + setPreview(data) + } catch (err) { + console.error("Error fetching bulk refund preview:", err) + setError(err instanceof Error ? err.message : "Failed to load preview") + } finally { + setLoading(false) + } + } + + void fetchPreview() + }, [eventId, signedIn]) + + if (isPending || loading) { + return ( + + + + ) + } + + if (!signedIn) { + return null + } + + if (error) { + return ( + + + + Bulk Refunds + + {error} + + + + + + ) + } + + const hasPayments = preview && preview.payments.length > 0 + + return ( + + + + Bulk Refunds + + {!hasPayments && ( +
+

+ No refundable payments found for this event. +

+ +
+ )} + + {hasPayments && ( + <> +
+ + + + + + + + + + {preview.payments.map((payment) => ( + + + + + + ))} + + + + + + + + +
Player NameFee CountRefund Amount
{payment.playerName}{payment.feeCount}${payment.refundAmount.toFixed(2)}
Total + {preview.payments.reduce((sum, p) => sum + p.feeCount, 0)} + ${preview.totalRefundAmount.toFixed(2)}
+
+ + {preview.skippedCount > 0 && ( +

+ {preview.skippedCount} payment(s) skipped (no paid fees). +

+ )} + +
+ + +
+ + )} +
+
+
+ ) +} diff --git a/plans/bulk-refund-prd.json b/plans/bulk-refund-prd.json index 2808c7ce..65644335 100644 --- a/plans/bulk-refund-prd.json +++ b/plans/bulk-refund-prd.json @@ -155,7 +155,7 @@ "'Refund All' button is visible and enabled when payments exist", "'Refund All' button is disabled when no refundable payments" ], - "passes": false + "passes": true }, { "id": 11, From bc002a733857a9623b9cd9316aceead94771fe89 Mon Sep 17 00:00:00 2001 From: Stuart Finley Date: Sat, 31 Jan 2026 23:00:29 +0000 Subject: [PATCH 11/12] [bulk-refund-prd 11] Add SSE streaming result phase to bulk refund page - useReducer for state (phase, current, total, playerName, error, result) - EventSource to /api/registration/bulk-refund?eventId=X - progress bar w/ current/total, current player name - complete phase shows refundedCount, failedCount, totalRefundAmount - lists per-payment failures w/ error messages - closes EventSource on complete/error Co-Authored-By: Claude Opus 4.5 --- .../web/app/events/[eventId]/refunds/page.tsx | 213 +++++++++++++++++- 1 file changed, 210 insertions(+), 3 deletions(-) diff --git a/apps/web/app/events/[eventId]/refunds/page.tsx b/apps/web/app/events/[eventId]/refunds/page.tsx index 3a8ca55a..111511a5 100644 --- a/apps/web/app/events/[eventId]/refunds/page.tsx +++ b/apps/web/app/events/[eventId]/refunds/page.tsx @@ -1,14 +1,78 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useReducer, useState } from "react" import { useParams, useRouter } from "next/navigation" -import type { BulkRefundPreview } from "@repo/domain/types" +import type { + BulkRefundPreview, + BulkRefundProgressEvent, + BulkRefundResponse, +} from "@repo/domain/types" import { useAuth } from "@/lib/auth-context" import { LoadingSpinner } from "@/components/ui/loading-spinner" import { PageLayout } from "@/components/ui/page-layout" import { Card, CardBody, CardTitle } from "@/components/ui/card" import { Alert } from "@/components/ui/alert" +type RefundPhase = "preview" | "processing" | "complete" | "error" + +interface RefundState { + phase: RefundPhase + current: number + total: number + playerName: string + error: string | null + result: BulkRefundResponse | null +} + +type RefundAction = + | { type: "START_PROCESSING"; total: number } + | { type: "PROGRESS"; current: number; total: number; playerName: string } + | { type: "COMPLETE"; result: BulkRefundResponse } + | { type: "ERROR"; error: string } + +function refundReducer(state: RefundState, action: RefundAction): RefundState { + switch (action.type) { + case "START_PROCESSING": + return { + ...state, + phase: "processing", + current: 0, + total: action.total, + playerName: "", + error: null, + result: null, + } + case "PROGRESS": + return { + ...state, + current: action.current, + total: action.total, + playerName: action.playerName, + } + case "COMPLETE": + return { + ...state, + phase: "complete", + result: action.result, + } + case "ERROR": + return { + ...state, + phase: "error", + error: action.error, + } + } +} + +const initialRefundState: RefundState = { + phase: "preview", + current: 0, + total: 0, + playerName: "", + error: null, + result: null, +} + export default function BulkRefundPage() { const { isAuthenticated: signedIn, isLoading: isPending } = useAuth() const router = useRouter() @@ -17,6 +81,7 @@ export default function BulkRefundPage() { const [preview, setPreview] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [refundState, dispatch] = useReducer(refundReducer, initialRefundState) useEffect(() => { if (!signedIn || !eventId) return @@ -53,6 +118,45 @@ export default function BulkRefundPage() { return null } + const handleRefundAll = () => { + if (!eventId || !preview) return + + dispatch({ type: "START_PROCESSING", total: preview.payments.length }) + + const eventSource = new EventSource(`/api/registration/bulk-refund?eventId=${eventId}`) + + eventSource.onmessage = (event) => { + try { + const progressData = JSON.parse(event.data as string) as BulkRefundProgressEvent + + if (progressData.status === "processing") { + dispatch({ + type: "PROGRESS", + current: progressData.current, + total: progressData.total, + playerName: progressData.playerName ?? "", + }) + } else if (progressData.status === "complete" && progressData.result) { + dispatch({ type: "COMPLETE", result: progressData.result }) + eventSource.close() + } else if (progressData.status === "error") { + dispatch({ type: "ERROR", error: progressData.error ?? "Unknown error" }) + eventSource.close() + } + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : "Failed to parse progress data" + console.error(errorMessage) + dispatch({ type: "ERROR", error: errorMessage }) + eventSource.close() + } + } + + eventSource.onerror = () => { + dispatch({ type: "ERROR", error: "Connection lost" }) + eventSource.close() + } + } + if (error) { return ( @@ -73,6 +177,109 @@ export default function BulkRefundPage() { const hasPayments = preview && preview.payments.length > 0 + // Processing phase - show progress + if (refundState.phase === "processing") { + const progressPercent = + refundState.total > 0 ? Math.round((refundState.current / refundState.total) * 100) : 0 + + return ( + + + + Processing Refunds +
+
+ +
+

+ {refundState.current} of {refundState.total} ({progressPercent}%) +

+ {refundState.playerName && ( +

+ Processing: {refundState.playerName} +

+ )} +
+
+
+
+ ) + } + + // Complete phase - show results + if (refundState.phase === "complete" && refundState.result) { + const { refundedCount, failedCount, totalRefundAmount, results } = refundState.result + const failures = results.filter((r) => !r.success) + + return ( + + + + Refund Complete +
+ + Successfully processed {refundedCount} refund(s) totaling $ + {totalRefundAmount.toFixed(2)} + + + {failedCount > 0 && ( + + {failedCount} refund(s) failed + + )} + + {failures.length > 0 && ( +
+

Failed Refunds:

+
    + {failures.map((failure) => ( +
  • + Payment #{failure.paymentId}: {failure.error} +
  • + ))} +
+
+ )} + +
+ +
+
+
+
+
+ ) + } + + // Error phase from SSE + if (refundState.phase === "error") { + return ( + + + + Bulk Refunds + + {refundState.error} + + + + + + ) + } + + // Preview phase (default) return ( @@ -132,7 +339,7 @@ export default function BulkRefundPage() { - From 66b7f7c297f7b27454a99a3716d381b27ffa312f Mon Sep 17 00:00:00 2001 From: Stuart Finley Date: Sat, 31 Jan 2026 17:40:35 -0600 Subject: [PATCH 12/12] Include the PRD for bulk refunds. --- plans/bulk-refund-prd.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/bulk-refund-prd.json b/plans/bulk-refund-prd.json index 65644335..85dd7713 100644 --- a/plans/bulk-refund-prd.json +++ b/plans/bulk-refund-prd.json @@ -175,7 +175,7 @@ "Shows total refund amount", "Lists per-payment failures with error messages" ], - "passes": false + "passes": true }, { "id": 12,