fix: review-loop A — discount credit on admin payments, campaign over-credit cap, sweep replay window, dedup refund revalidation, duplication/modularisation, GBP pence naming

Round-A fresh review (6 agents) + fix + secondary cross-cutting + verification rounds:
- F1: campaign discounts reduce the charged amount (deposit credit + admin PaymentModal discounted total); capDiscountToRemainingObligation prevents over-credit at completion in all four campaign blocks
- F2: sweep replay rescue distinguishes legitimate same-key retries (21h window) from expired-key new charges; ccof blind-fails leave pending + CRITICAL instead of clawing back
- F3: post-start online overflow carved as a tip record (mirrors terminal split builder)
- A1: single-source Square decline-code classification (till delegates to square.IsDefinitivePaymentError)
- A2/A5: refund attempt-cap literals consolidated; refund-failure counter capped + reset on terminal resolutions + admin notifications
- A3/A9: idempotency helpers adopted across derivations; IsExplicitDevOrMockEnv relocated + all gates unified (incl. health-check)
- A7: 2FA user+IP limiter + TRUST_PROXY_HEADERS startup warning; SNAPSHOT_ENC_KEY startup validation; TWO_FACTOR_PEPPER docs corrected
- A8: snapshot encryption on all 6 write sites + marker-aware reuse paths; MPV->SPV effective voucher type (single VAT point)
- A10/A11/A12/A16: gift-card slot scan advances past failed; amount-aware refund reconciliation; completed-booking refund re-check; PaymentWasRefunded on SquareClient interface
- Dedup refund revalidation on tip/terminal/gift-card paths; sweep acknowledged_at IS NULL parity; refund-notification single source (exported payments.InsertRefundFailedNotifications)
- Duplication/modularisation round: shared frontend helpers (sanitizeDecimalInput, campaignDiscountCents, twoFactorBlocksSavedCards getter, generateUUID), single-source MaxIdempotencyKeyLength, notification-helper consolidation, snapshot-guard comments
- Cross-cutting GBP rename: Cents->Pence across backend + frontend + tests (26 identifiers, 16 files)
- Tests: 11 behavior-change tests updated to new invariants; coverage for fixed functions; frontend vitest 55 tests; docs corrected (test counts, 2FA delivery, pre-launch checklist, resolution status)
- gitleaks: allowlist backend/internal/square test fixtures (mock idempotency keys)

All 25 backend packages pass; frontend 55/55 + build clean; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 6d82535780
commit faceb9809c
49 changed files with 2006 additions and 1074 deletions
@@ -50,6 +50,9 @@
let refundReason = $state('');
let refundLoading = $state(false);
let refundIdempotencyKey = $state('');
// Pence of the selected payment already returned via completed refunds —
// the refund amount is pre-filled with the residual (amount this).
let refundAlreadyRefundedPence = $state(0);
$effect(() => {
if (open && bookingId) {
@@ -342,15 +345,42 @@
}
}
function openRefundModal(paymentId: string, amountPence: number) {
refundPaymentId = paymentId;
refundAmount = (amountPence / 100).toFixed(2);
async function openRefundModal(payment: Payment) {
refundPaymentId = payment.id;
refundAmount = (payment.amount / 100).toFixed(2);
refundAlreadyRefundedPence = 0;
refundReason = '';
// Unique per refund attempt so two equal partial refunds of the same
// payment don't collide on the backend's amount-derived key; reused on
// retry (the backend dedups on it) so a timeout can't double-refund.
refundIdempotencyKey = crypto.randomUUID();
showRefundModal = true;
// Pre-fill the refund with the RESIDUAL (payment.amount already
// refunded) and surface the already-refunded total, so a partially
// refunded payment doesn't look fully refundable. The admin booking
// detail doesn't include refunds, so fetch the payment summary.
try {
const res = await apiFetch(`/api/bookings/${bookingId}/payment-summary`);
if (res.ok) {
const data = await res.json();
const alreadyRefunded = (
(data.refunds ?? []) as Array<{
payment_id: string;
amount: number;
status: string;
}>
)
.filter((r) => r.payment_id === payment.id && r.status === 'completed')
.reduce((sum, r) => sum + r.amount, 0);
if (alreadyRefunded > 0) {
refundAlreadyRefundedPence = alreadyRefunded;
refundAmount = (Math.max(0, payment.amount - alreadyRefunded) / 100).toFixed(2);
}
}
} catch {
// Non-fatal — the modal stays open with the full amount pre-filled.
}
}
async function processRefund() {
@@ -611,7 +641,7 @@
size="sm"
variant="outline"
class="text-red-600 hover:bg-red-50 hover:text-red-700"
onclick={() => openRefundModal(payment.id, payment.amount)}
onclick={() => openRefundModal(payment)}
>
Refund
</Button>
@@ -837,6 +867,12 @@
placeholder="0.00"
/>
</div>
{#if refundAlreadyRefundedPence > 0}
<p class="mt-1 text-xs text-gray-500">
Already refunded: £{(refundAlreadyRefundedPence / 100).toFixed(2)} — the amount above is the
remaining balance.
</p>
{/if}
</div>
<div>