Refactor: Refund as separate row
Status: SHIPPED 2026-05-11 (big-bang). Deployed as a single migration + handler rewrite + FE shape change. Outcome:
PaymentIntent.REFUNDrows withparentPaymentIdself-FK now live alongside captured charges; the denormalisedrefundedAmountcolumn 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
statusis 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: defaultintent != REFUND; optionincludeRefunds: truefor timeline view.
PaymentIntegrationService.onBookingCancelled (FULL_REFUND branch): no change — still dispatches RefundPaymentCommand, handler creates the row.
Read / queries
ListPaymentsHandler— default filter excludesREFUND(admin list keeps "one row per payment" UX); explicitincludeRefundsfor the timeline.GetPaymentHandler— returns{ payment, refunds: Payment[] }sorted bycapturedAt.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.ts—onBookingCancelledFULL_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 > 0rows in one transaction and dropped the column in the same migration file. No observation window was needed. - Cache field retention. The
refundedAmountcolumn 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 = NULLbecause 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 = trueflags them in the drawer's "Refund history" section. - Manual providers (
MANUAL_CASH,MANUAL_TERMINAL) reuse the parentproviderRefbecause 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.