architecture/refund-as-row-refactor.md

Refactor: Refund as separate row

Status: SHIPPED 2026-05-11 (big-bang). Deployed as a single migration + handler rewrite + FE shape change. Outcome: PaymentIntent.REFUND rows with parentPaymentId self-FK now live alongside captured charges; the denormalised refundedAmount column is gone. Backend + FE + tests + docs landed in three commits across booking-api and booking-web (see changelog). Effort actual: ~half a day (vs the 2.5–3 day phased estimate below — refund volume was small enough to skip the dual-write phases).

Why

Current behaviour: RefundPaymentHandler mutates the original Payment row in-place — refundedAmount accumulates, status walks CAPTURED → PARTIALLY_REFUNDED → REFUNDED. One row holds the whole charge + refund history.

Industry standard (Stripe, Adyen, Square, PayPal, Vipps): the charge row stays CAPTURED, and each refund becomes a new record (refund1, refund2, …) pointing back to the parent payment.

Reasons to migrate:

Concern Current (in-place) Refund-as-row
Per-refund audit (who/when/reason for each event) ❌ only total
Accounting / VAT (each refund = negative revenue at its own time)
Multiple partial refunds across time ❌ collapsed ✅ each visible
1-to-1 reconciliation with PSP refund tx ids
Immutable GDPR-friendly history ❌ rows mutate
Single-row "Paid" query ✅ simple ❌ must aggregate

The salon team has shipped one refund flow already; the only readers of refundedAmount are FE summaries. Migrating now is cheaper than after high volume.

Decision

Option 1 — PaymentIntent.REFUND + parentPaymentId self-reference. Confirmed 2026-05-11.

Rejected: separate Refund table (cleaner but more boilerplate; we don't need refund-of-refund).

Schema

enum PaymentIntent {
  DEPOSIT
  FULL_PAYMENT
  REMAINING_PAYMENT
  REFUND                    // NEW
}

model Payment {
  // ... existing fields ...
  parentPaymentId  String?   @map("parent_payment_id")
  parent           Payment?  @relation("PaymentRefunds", fields: [parentPaymentId], references: [id])
  refunds          Payment[] @relation("PaymentRefunds")

  // refundedAmount: KEEP as denormalised cache through Phase 1–4. DROP in Phase 5
  // after all readers have moved to the aggregate query.

  @@index([parentPaymentId, tenantId])
}

Domain invariants:

  • intent = REFUND ⇔ parentPaymentId IS NOT NULL (enforce at domain + DB CHECK)
  • parent.intent != REFUND (no refund-of-refund — enforce at handler)
  • Refund row is born CAPTURED (no INITIATED/AUTHORIZED on refund side)

Domain model

  • Drop payment.refund(amount, reason, at) instance method on the parent.
  • Add Payment.createRefund({parent, amount, reason, at, idempotencyKey, providerRef}) factory → produces a child row.
  • Parent's status is no longer mutated by the refund flow directly; a projection listener computes:
    • sum(refund.capturedAmount where parentPaymentId = parent.id)
    • < captured ⇒ PARTIALLY_REFUNDED, == captured ⇒ REFUNDED
  • New event: PaymentRefundCreated { refundPaymentId, parentPaymentId, amount, reason }.
  • Legacy events (PaymentRefunded, PaymentPartiallyRefunded) still emitted in Phase 2–4 for backward-compat with current listeners; drop in Phase 5.

Application layer

RefundPaymentHandler becomes:

async execute(cmd) {
  const parent = await payments.findById(cmd.paymentId, cmd.tenantId);
  assert(parent.intent !== REFUND);
  assert(parent.status ∈ {CAPTURED, PARTIALLY_REFUNDED});

  const existing = await payments.findRefundsByParent(parent.id, parent.tenantId);
  const cumulative = sum(existing.map(r => r.capturedAmount));
  assert(cumulative + cmd.amount ≤ parent.capturedAmount);

  if (!isManualProvider(parent.providerRef.providerKey)) {
    await adapter.refund({...});            // PSP call — unchanged
  }

  const refund = Payment.createRefund({
    parent,
    amount: cmd.amount,
    reason: cmd.reason,
    at: clock.now(),
    idempotencyKey: cmd.idempotencyKey,
    providerRef: refundProviderRef,         // Bambora reuses parent tx id; Stripe gets refund id
  });
  await payments.save(refund);

  parent.applyRefundProjection(cumulative + cmd.amount);  // updates parent.status, parent.refundedAmount (legacy cache during dual-write)
  await payments.save(parent);

  return { refundPaymentId: refund.id, parentPaymentId: parent.id, amount: cmd.amount, cumulativeRefunded: cumulative + cmd.amount, fullyRefunded };
}

Repository additions:

  • findRefundsByParent(parentId, tenantId): Promise<Payment[]>
  • listByTenant: default intent != REFUND; option includeRefunds: true for timeline view.

PaymentIntegrationService.onBookingCancelled (FULL_REFUND branch): no change — still dispatches RefundPaymentCommand, handler creates the row.

Read / queries

  • ListPaymentsHandler — default filter excludes REFUND (admin list keeps "one row per payment" UX); explicit includeRefunds for the timeline.
  • GetPaymentHandler — returns { payment, refunds: Payment[] } sorted by capturedAt.
  • GetPaymentsByBookingHandler — returns all rows (charges + refunds) sorted chronologically.

DTOs: add refunds: RefundLineDto[] and parentPaymentId field.

Frontend

File Change
usePayments.ts / usePayment.ts Response type adds refunds[] and parentPaymentId. Recompute refundable = captured - sum(refunds.captured).
PaymentList.tsx Default hide rows where intent = REFUND; toggle "Show refund history" for timeline mode. Status badge logic uses parent's projected status.
PaymentDetailDrawer.tsx Section "Refund history" — list payment.refunds[] with date / amount / reason / by whom. RefundDialog itself unchanged.
BookingPaymentHistory.tsx Flat chronological timeline mixing captures + refunds (each refund a red row "-X kr").
BookingPaymentSummary.tsx paid = sum(charge.captured) - sum(refund.captured) instead of charge.captured - charge.refunded.
lib/payment/next-payment.ts Same recompute.
RefundDialog.tsx No change — still POSTs /admin/payments/:id/refund.
BookingTicket.tsx, BookingDrawer.tsx, BookingList.tsx Replace direct refundedAmount reads with aggregate from refunds[].
useRefundPayment hook Response shape: { refundPaymentId, parentPaymentId, amount, cumulativeRefunded, fullyRefunded, currency }.

Migration — phased dual-write

Phase Deploy Scope
1. Schema-compat Backend only Add REFUND enum + parentPaymentId column (nullable). Run backfill script: for each Payment with refundedAmount > 0, create one synthetic refund Payment (intent=REFUND, parentPaymentId=parent.id, amount=parent.refundedAmount, status=CAPTURED, capturedAt=parent.updatedAt, providerRef=parent.providerRef, idempotencyKey="backfill-${parent.id}", metadata.backfilled=true). Parent row untouched.
2. Dual-write Backend RefundPaymentHandler creates the refund row AND keeps mutating parent.refundedAmount. Both legacy and new events fire.
3. FE migrate Frontend UI reads from refunds[] aggregate. Old refundedAmount becomes effectively unused at display time.
4. Stop dual-write Backend Handler stops mutating parent.refundedAmount. SQL: UPDATE Payment SET refundedAmount = 0. Drop legacy PaymentRefunded / PaymentPartiallyRefunded emissions.
5. Drop column Backend (optional, long-term) Remove refundedAmount column. Drop legacy event types from listener subscription.

Observation period between phases: ~3–5 days each in production, monitor refund metrics.

Big-bang alternative (skip to single deploy): possible if staging covers all paths and a 30-minute maintenance window is acceptable. Not recommended unless prod refund count is tiny.

Tests

  • capture-void-refund.handlers.spec.ts — rewrite refund cases: assert new row created, parent projection updated, manual provider still skips adapter call.
  • payment-integration.service.spec.tsonBookingCancelled FULL_REFUND path creates refund row.
  • New: migration backfill test (seed legacy → run script → verify refund row + sum).
  • E2E booking-snapshot.e2e-spec.ts, booking-outbox.e2e-spec.ts — update assertions.
  • FE usePayments.test.tsx, next-payment.test.ts, public-payment-api.test.ts, refund-schema.test.ts — new shapes / computation.
  • E2E (Playwright) — refund flow, refund row appears in drawer.

Coverage target: keep at 80%+ (project rule).

Risks

Risk Mitigation
Backfilled refund rows have no separate PSP tx id (legacy Bambora classic API) Use providerRef = parent.providerRef + metadata.backfilled=true. Acceptable; reconcile manually if questioned.
Concurrent refunds racing the cumulative check Existing (tenantId, idempotencyKey) UNIQUE keeps idempotency; second attempt with a different key fails the assert.
FE / BE shape mismatch during Phase 2 → 3 window Dual-write keeps parent.refundedAmount in sync — old FE keeps working.
Listeners (notification, email, projection, settled-negative) subscribe legacy event names Phase 2 emits both new and legacy events; Phase 5 drops legacy.
GitNexus impact of handler rewrite Run mcp__gitnexus__impact before Phase 2 deploy. Current upstream count: 2 (module + bootstrap), risk LOW per last scan.
Old tests asserting refundedAmount field directly Rewrite to use aggregate.

Effort

Phase Scope Hours
1 Schema + migration script 2
2 Domain rewrite (entity, factory, events) 3
3 Handler + repo (findRefundsByParent, dual-write) 3
4 Query handlers + DTOs (refunds aggregate) 2
5 FE migration (list, drawer, history, summary, next-payment) 4
6 Listeners (new event emission, legacy compat) 2
7 Tests rewrite (unit + integration + E2E) 4
8 Docs update (payment-architecture.md, payment-flow.md) 1
Total ~21 (2.5–3 days)

Shipping notes (post-deploy, 2026-05-11)

Skipped from the original plan:

  • Phased dual-write. Refund volume was tiny (5 pre-existing rows on the shared dev DB), so the schema migration backfilled all historical refundedAmount > 0 rows in one transaction and dropped the column in the same migration file. No observation window was needed.
  • Cache field retention. The refundedAmount column was dropped outright. The aggregate is exposed back to the FE in the DTO layer as a derived field (sum(refunds.capturedAmount)), so legacy readers (PaymentList, BookingPaymentSummary, next-payment) compile and behave identically with no shape change.

Caveats baked into the migration:

  • Backfilled REFUND rows carry provider_transaction_id = NULL because the legacy column did not record per-refund PSP ids; the unique (provider, provider_transaction_id) constraint forbids copying the parent's tx id, so NULL is the only legal value. metadata.backfilled = true flags them in the drawer's "Refund history" section.
  • Manual providers (MANUAL_CASH, MANUAL_TERMINAL) reuse the parent providerRef because there is no PSP boundary to call. Same applies to Bambora Classic, whose API has no separate refund tx id — the adapter falls back to the parent transaction id.

Trigger to revisit (not yet relevant): if accounting/VAT requirements demand the per-refund breakdown stored on a dedicated Refund table (rather than reusing Payment), the migration path is PaymentIntent.REFUND rows → Refund table rows; the application-side factory + projection stays unchanged.