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:
@@ -7,15 +7,18 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import type { Booking } from '$lib/types/booking';
|
||||
import type { UserSavedCard } from '$lib/types';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import {
|
||||
campaignDiscountPence,
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
@@ -45,9 +48,7 @@
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks saved-card use
|
||||
// and saving new cards for reuse. The new-card (nonce) path has its own
|
||||
// SCA via Square tokenizeWithVerification.
|
||||
const twoFactorBlocksSavedCards = $derived(
|
||||
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
|
||||
);
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
|
||||
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
|
||||
|
||||
@@ -86,9 +87,8 @@
|
||||
payment_type: string;
|
||||
} | null>(null);
|
||||
|
||||
// Card selection state
|
||||
let paymentMethods = $state<UserSavedCard[]>([]);
|
||||
let paymentMethodsLoading = $state(false);
|
||||
// Card selection state — cards and loading live in the shared savedCards
|
||||
// store so all payment surfaces fetch /api/user/payment-methods identically.
|
||||
let selectedCardId = $state('');
|
||||
let cardSelectionValid = $state(false);
|
||||
let cardSelection = $state<CardSelection | null>(null);
|
||||
@@ -140,15 +140,17 @@
|
||||
.reduce((sum, p) => sum + p.amount, 0) || 0
|
||||
);
|
||||
|
||||
const amountRemaining = $derived(booking.total_amount - totalPaid);
|
||||
|
||||
// Mirror of the backend's GetBookingRemainingBalanceCents (see
|
||||
// backend/handlers/payments/service.go): total − completed non-tip payments
|
||||
// + completed refunds, clamped to the booking total and floored at 0. The
|
||||
// backend rejects an unconfirmed pre-start overpayment when req.Amount >
|
||||
// this value, and records the excess (req.Amount − remainingCents) as a tip
|
||||
// once confirmed — so the overflow-confirmation prompt shows exactly that.
|
||||
const remainingBalanceCents = $derived.by(() => {
|
||||
// Tip-excluding remaining balance in pounds, mirroring the backend's
|
||||
// GetBookingRemainingBalancePence (see backend/handlers/payments/service.go):
|
||||
// total − completed non-tip payments + completed refunds, clamped to the
|
||||
// booking total and floored at 0. The backend rejects an unconfirmed
|
||||
// pre-start overpayment when req.Amount > this value, and records the excess
|
||||
// (req.Amount − remainingPence) as a tip once confirmed — so the
|
||||
// overflow-confirmation prompt shows exactly that. A completed TIP must not
|
||||
// reduce what the customer can still pay for the booking itself (gratuity,
|
||||
// not booking credit), so this deliberately differs from `totalPaid` (which
|
||||
// includes tips and drives the scenario labels / "Amount Paid" row).
|
||||
const remainingBalance = $derived.by(() => {
|
||||
const total = booking.total_amount ?? 0;
|
||||
const paid = (booking.payments ?? [])
|
||||
.filter((p) => p.status === 'completed' && p.payment_type !== 'tip')
|
||||
@@ -156,23 +158,29 @@
|
||||
const refunded = (booking.refunds ?? [])
|
||||
.filter((r) => r.status === 'completed')
|
||||
.reduce((sum, r) => sum + r.amount, 0);
|
||||
return Math.round(Math.max(0, Math.min(total - paid + refunded, total)) * 100);
|
||||
return Math.max(0, Math.min(total - paid + refunded, total));
|
||||
});
|
||||
|
||||
const remainingBalancePence = $derived(Math.round(remainingBalance * 100));
|
||||
|
||||
// Pre-start overpayment confirmation. The backend rejects a payment that
|
||||
// exceeds the booking's remaining balance before the appointment has
|
||||
// started unless the request carries `confirm_overflow_tip: true` — a tip
|
||||
// is gratuity for service already rendered. The frontend caps amounts at
|
||||
// amountRemaining in normal flows, so this fires on STALE booking data
|
||||
// the remaining balance in normal flows, so this fires on STALE booking data
|
||||
// (multi-tab, admin-changed totals, refunds that reopened capacity) where
|
||||
// the user would otherwise be stuck with an unresolvable 400. On the guard
|
||||
// firing, the rejected request (amount, type, cached card tokens) is parked
|
||||
// here and a Confirm/Cancel prompt is shown; Confirm resends the SAME
|
||||
// request with the flag, Cancel returns to the amount-editing form.
|
||||
let overflowConfirm = $state<{
|
||||
amountCents: number;
|
||||
amountPence: number;
|
||||
paymentType: string;
|
||||
overflowCents: number;
|
||||
overflowPence: number;
|
||||
// Actual amount the backend will charge. For deposits the backend
|
||||
// charges req.Amount minus the eligible campaign credit (the frontend
|
||||
// sends deposits RAW), so this can differ from amountPence.
|
||||
chargePence?: number;
|
||||
cardId?: string;
|
||||
newCardToken?: string;
|
||||
verificationToken?: string;
|
||||
@@ -216,7 +224,7 @@
|
||||
partialAmount !== '' &&
|
||||
!isNaN(partialAmountNum) &&
|
||||
partialAmountNum > 0 &&
|
||||
partialAmountNum <= amountRemaining &&
|
||||
partialAmountNum <= remainingBalance &&
|
||||
/^\d+(\.\d{0,2})?$/.test(partialAmount)
|
||||
);
|
||||
|
||||
@@ -228,7 +236,7 @@
|
||||
? 'Invalid amount format'
|
||||
: partialAmountNum <= 0
|
||||
? 'Amount must be greater than 0'
|
||||
: partialAmountNum > amountRemaining
|
||||
: partialAmountNum > remainingBalance
|
||||
? 'Amount exceeds balance'
|
||||
: 'Invalid amount'
|
||||
: null
|
||||
@@ -241,12 +249,6 @@
|
||||
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
|
||||
);
|
||||
|
||||
function campaignDiscountCents(): number {
|
||||
return discountPreview?.eligible
|
||||
? discountPreview.discounts.reduce((sum, d) => sum + Math.round(d.amount * 100), 0)
|
||||
: 0;
|
||||
}
|
||||
|
||||
function formatCurrency(pence: number): string {
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
@@ -319,37 +321,12 @@
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
function generateIdempotencyKey(): string {
|
||||
const array = new Uint8Array(16);
|
||||
if (typeof window !== 'undefined' && window.crypto) {
|
||||
window.crypto.getRandomValues(array);
|
||||
} else {
|
||||
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
array[6] = (array[6] & 0x0f) | 0x40;
|
||||
array[8] = (array[8] & 0x3f) | 0x80;
|
||||
return [...array]
|
||||
.map((b, i) => {
|
||||
const hex = b.toString(16).padStart(2, '0');
|
||||
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
|
||||
return hex;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function fetchPaymentMethods() {
|
||||
// Cached payment methods come from the shared savedCards store (single
|
||||
// fetch of /api/user/payment-methods), so the account, booking and tip
|
||||
// surfaces can't drift on the API shape or the loading semantics.
|
||||
async function loadSavedCards() {
|
||||
if (!authStore.isAuthenticated) return;
|
||||
paymentMethodsLoading = true;
|
||||
try {
|
||||
const response = await apiFetch('/api/user/payment-methods');
|
||||
if (response.ok) {
|
||||
paymentMethods = await response.json();
|
||||
}
|
||||
} catch (_err) {
|
||||
console.error('Failed to fetch payment methods:', _err);
|
||||
} finally {
|
||||
paymentMethodsLoading = false;
|
||||
}
|
||||
await savedCardsStore.fetch();
|
||||
}
|
||||
|
||||
async function fetchLoyaltyData() {
|
||||
@@ -365,22 +342,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAmountInput(value: string): string {
|
||||
// Remove all non-numeric chars except .
|
||||
const cleaned = value.replace(/[^0-9.]/g, '');
|
||||
// Keep only the first .
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
if (firstDot !== -1) {
|
||||
const integerPart = cleaned.substring(0, firstDot);
|
||||
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
||||
return integerPart + '.' + decimalPart;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function handlePartialAmountInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const sanitized = sanitizeAmountInput(input.value);
|
||||
const sanitized = sanitizeDecimalInput(input.value);
|
||||
// Only update if the sanitized value passes the regex (max 2 decimal places)
|
||||
if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
|
||||
partialAmount = sanitized;
|
||||
@@ -392,7 +356,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function makePayment(paymentType: string, amountCents: number) {
|
||||
async function makePayment(paymentType: string, amountPence: number) {
|
||||
status = 'processing';
|
||||
error = null;
|
||||
|
||||
@@ -433,11 +397,11 @@
|
||||
if (
|
||||
!newCardNonce ||
|
||||
newCardTokenizedForSaveCard !== saveCard ||
|
||||
isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountCents)
|
||||
isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountPence)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await cardSelection.tokenizeWithVerification(
|
||||
amountCents,
|
||||
amountPence,
|
||||
{
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
@@ -447,7 +411,7 @@
|
||||
);
|
||||
newCardNonce = tokenized.nonce;
|
||||
newCardVerificationToken = tokenized.verificationToken ?? '';
|
||||
newCardTokenAmount = amountCents;
|
||||
newCardTokenAmount = amountPence;
|
||||
newCardTokenizedAt = Date.now();
|
||||
newCardTokenizedForSaveCard = saveCard;
|
||||
} catch (_err) {
|
||||
@@ -477,17 +441,24 @@
|
||||
const cardKey = cardId ?? 'new-card';
|
||||
if (
|
||||
!payIdempotencyKey ||
|
||||
payKeyedAmount !== amountCents ||
|
||||
payKeyedAmount !== amountPence ||
|
||||
payKeyedType !== paymentType ||
|
||||
payKeyedCard !== cardKey
|
||||
) {
|
||||
payIdempotencyKey = generateIdempotencyKey();
|
||||
payKeyedAmount = amountCents;
|
||||
payIdempotencyKey = generateUUID();
|
||||
payKeyedAmount = amountPence;
|
||||
payKeyedType = paymentType;
|
||||
payKeyedCard = cardKey;
|
||||
}
|
||||
|
||||
await submitBookingPayment(paymentType, amountCents, cardId, newCardToken, verificationToken, false);
|
||||
await submitBookingPayment(
|
||||
paymentType,
|
||||
amountPence,
|
||||
cardId,
|
||||
newCardToken,
|
||||
verificationToken,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// Submits a booking-payment request and processes the outcome. Shared by
|
||||
@@ -499,7 +470,7 @@
|
||||
// the key is still the correct dedup identity for this amount+type+card).
|
||||
async function submitBookingPayment(
|
||||
paymentType: string,
|
||||
amountCents: number,
|
||||
amountPence: number,
|
||||
cardId: string | undefined,
|
||||
newCardToken: string | undefined,
|
||||
verificationToken: string | undefined,
|
||||
@@ -512,7 +483,7 @@
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: amountCents,
|
||||
amount: amountPence,
|
||||
payment_type: paymentType,
|
||||
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
@@ -532,10 +503,19 @@
|
||||
// nonce + SCA verification token + idempotency key are NOT
|
||||
// cleared here — the confirm resend is the same logical charge.
|
||||
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(errData)) {
|
||||
// The backend's overflow guard compares against the DISCOUNTED
|
||||
// remaining (remaining + eligible campaign credit), and for a
|
||||
// DEPOSIT it charges req.Amount − the campaign credit (the
|
||||
// frontend sends deposits raw). Both the displayed overflow
|
||||
// and the amount actually charged must therefore account for
|
||||
// the eligible campaign discount on the deposit path.
|
||||
const depositDiscountPence =
|
||||
paymentType === 'deposit' ? campaignDiscountPence(discountPreview) : 0;
|
||||
overflowConfirm = {
|
||||
amountCents,
|
||||
amountPence,
|
||||
paymentType,
|
||||
overflowCents: Math.max(0, amountCents - remainingBalanceCents),
|
||||
overflowPence: Math.max(0, amountPence - remainingBalancePence - depositDiscountPence),
|
||||
chargePence: Math.max(0, amountPence - depositDiscountPence),
|
||||
cardId,
|
||||
newCardToken,
|
||||
verificationToken
|
||||
@@ -567,7 +547,7 @@
|
||||
payment_type: data.payment_type
|
||||
};
|
||||
toast.success('Payment successful');
|
||||
fetchPaymentMethods();
|
||||
savedCardsStore.invalidate();
|
||||
onComplete();
|
||||
releaseLock();
|
||||
} catch (_err) {
|
||||
@@ -605,7 +585,7 @@
|
||||
error = null;
|
||||
await submitBookingPayment(
|
||||
pending.paymentType,
|
||||
pending.amountCents,
|
||||
pending.amountPence,
|
||||
pending.cardId,
|
||||
pending.newCardToken,
|
||||
pending.verificationToken,
|
||||
@@ -623,17 +603,20 @@
|
||||
}
|
||||
|
||||
function handlePayDeposit() {
|
||||
const depositCents = booking.deposit_amount
|
||||
const depositPence = booking.deposit_amount
|
||||
? Math.round(booking.deposit_amount * 100)
|
||||
: Math.round(booking.total_amount * 0.2 * 100);
|
||||
makePayment('deposit', depositCents);
|
||||
makePayment('deposit', depositPence);
|
||||
}
|
||||
|
||||
function handlePayFull() {
|
||||
const fullCents = Math.round(booking.amount_due * 100);
|
||||
const discountedCents = Math.max(0, fullCents - campaignDiscountCents() - loyaltyDiscount);
|
||||
const fullPence = Math.round(booking.amount_due * 100);
|
||||
const discountedPence = Math.max(
|
||||
0,
|
||||
fullPence - campaignDiscountPence(discountPreview) - loyaltyDiscount
|
||||
);
|
||||
const paymentType = booking.amount_paid > 0 ? 'balance' : 'full';
|
||||
makePayment(paymentType, discountedCents);
|
||||
makePayment(paymentType, discountedPence);
|
||||
}
|
||||
|
||||
function handlePayPartial() {
|
||||
@@ -649,10 +632,10 @@
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Fetch payment methods on mount if authenticated
|
||||
// Fetch payment methods + loyalty on mount if authenticated
|
||||
$effect(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
fetchPaymentMethods();
|
||||
loadSavedCards();
|
||||
fetchLoyaltyData();
|
||||
}
|
||||
});
|
||||
@@ -690,7 +673,21 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Root
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
// ESC while the overflow-confirm prompt is showing must dismiss the
|
||||
// prompt (back to the amount-editing form) instead of closing the whole
|
||||
// modal — the payment was rejected by the guard and the user needs to
|
||||
// confirm or adjust, not lose the flow entirely.
|
||||
if (overflowConfirm) {
|
||||
cancelOverflowConfirmation();
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-w-[calc(100%-2rem)] sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
|
||||
@@ -721,9 +718,18 @@
|
||||
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
|
||||
<p class="mt-1 text-sm text-amber-800">
|
||||
The balance for this booking has changed since it was last loaded. The extra
|
||||
{formatCurrency(overflowConfirm.overflowCents)} will be recorded as a tip. Confirm
|
||||
to continue?
|
||||
{formatCurrency(overflowConfirm.overflowPence)} will be recorded as a tip. Confirm to
|
||||
continue?
|
||||
</p>
|
||||
{#if overflowConfirm.paymentType === 'deposit' && overflowConfirm.chargePence !== undefined}
|
||||
<p class="mt-2 text-sm font-medium text-amber-800">
|
||||
An eligible campaign discount of
|
||||
{formatCurrency(
|
||||
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
|
||||
)}
|
||||
applies — you'll be charged {formatCurrency(overflowConfirm.chargePence)}.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
@@ -731,6 +737,7 @@
|
||||
class="flex-1"
|
||||
loading={status === 'processing'}
|
||||
disabled={status === 'processing'}
|
||||
autofocus
|
||||
onclick={confirmOverflowPayment}
|
||||
>
|
||||
Confirm
|
||||
@@ -919,7 +926,7 @@
|
||||
{formatCurrency(
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(amountRemaining * 100) - campaignDiscountCents() - loyaltyDiscount
|
||||
remainingBalancePence - campaignDiscountPence(discountPreview) - loyaltyDiscount
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
@@ -937,12 +944,12 @@
|
||||
"Card entry failed". Staying mounted keeps both alive for the
|
||||
full duration of makePayment. -->
|
||||
{#if authStore.isAuthenticated}
|
||||
{#if paymentMethodsLoading}
|
||||
{#if savedCardsStore.loading}
|
||||
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
|
||||
{:else}
|
||||
<CardSelection
|
||||
bind:this={cardSelection}
|
||||
cards={paymentMethods}
|
||||
cards={savedCardsStore.cards}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId
|
||||
bind:saveCard
|
||||
@@ -1008,7 +1015,7 @@
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(booking.amount_due * 100) -
|
||||
campaignDiscountCents() -
|
||||
campaignDiscountPence(discountPreview) -
|
||||
(useLoyalty ? loyaltyDiscount : 0)
|
||||
)
|
||||
)}
|
||||
@@ -1087,7 +1094,7 @@
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(booking.amount_due * 100) -
|
||||
campaignDiscountCents() -
|
||||
campaignDiscountPence(discountPreview) -
|
||||
(useLoyalty ? loyaltyDiscount : 0)
|
||||
)
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user