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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user