fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs
Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes: - CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back - A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit) - A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds - A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows - A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs - A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point) - A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface - A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction - M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test - Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status) All 25 backend packages pass; frontend 41/41; build + env-docs green.
This commit is contained in:
@@ -12,7 +12,13 @@
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
|
||||
import {
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
|
||||
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||
|
||||
@@ -136,6 +142,42 @@
|
||||
|
||||
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(() => {
|
||||
const total = booking.total_amount ?? 0;
|
||||
const paid = (booking.payments ?? [])
|
||||
.filter((p) => p.status === 'completed' && p.payment_type !== 'tip')
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
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);
|
||||
});
|
||||
|
||||
// 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
|
||||
// (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;
|
||||
paymentType: string;
|
||||
overflowCents: number;
|
||||
cardId?: string;
|
||||
newCardToken?: string;
|
||||
verificationToken?: string;
|
||||
} | null>(null);
|
||||
|
||||
const loyaltyEligible = $derived(
|
||||
stamps >= 10 &&
|
||||
!(booking.discounts ?? []).some((d) => d.discount_source === 'loyalty') &&
|
||||
@@ -445,6 +487,25 @@
|
||||
payKeyedCard = cardKey;
|
||||
}
|
||||
|
||||
await submitBookingPayment(paymentType, amountCents, cardId, newCardToken, verificationToken, false);
|
||||
}
|
||||
|
||||
// Submits a booking-payment request and processes the outcome. Shared by
|
||||
// the initial attempt and the overflow-tip confirm resend so both use the
|
||||
// exact same success/error handling. `confirmOverflowTip` adds the backend's
|
||||
// opt-in flag for a pre-start overpayment; the resend reuses the SAME
|
||||
// cached nonce/verification token/idempotency key as the rejected attempt
|
||||
// (the guard fired before any Square call, so the tokens are unconsumed and
|
||||
// the key is still the correct dedup identity for this amount+type+card).
|
||||
async function submitBookingPayment(
|
||||
paymentType: string,
|
||||
amountCents: number,
|
||||
cardId: string | undefined,
|
||||
newCardToken: string | undefined,
|
||||
verificationToken: string | undefined,
|
||||
confirmOverflowTip: boolean
|
||||
): Promise<void> {
|
||||
let responseStatus = 0;
|
||||
try {
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${booking.id}/payment`, {
|
||||
@@ -453,6 +514,7 @@
|
||||
body: JSON.stringify({
|
||||
amount: amountCents,
|
||||
payment_type: paymentType,
|
||||
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
@@ -462,13 +524,32 @@
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
responseStatus = response.status;
|
||||
const errData = await response.text();
|
||||
// Pre-start overpayment on stale booking data: park the rejected
|
||||
// request (amount, type, cached tokens) and surface the
|
||||
// Confirm/Cancel prompt instead of a dead-end 400. The cached
|
||||
// nonce + SCA verification token + idempotency key are NOT
|
||||
// cleared here — the confirm resend is the same logical charge.
|
||||
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(errData)) {
|
||||
overflowConfirm = {
|
||||
amountCents,
|
||||
paymentType,
|
||||
overflowCents: Math.max(0, amountCents - remainingBalanceCents),
|
||||
cardId,
|
||||
newCardToken,
|
||||
verificationToken
|
||||
};
|
||||
status = 'idle';
|
||||
return;
|
||||
}
|
||||
throw new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Payment is synchronous (completed immediately)
|
||||
status = 'success';
|
||||
overflowConfirm = null;
|
||||
payIdempotencyKey = '';
|
||||
payKeyedAmount = 0;
|
||||
payKeyedType = '';
|
||||
@@ -491,9 +572,16 @@
|
||||
releaseLock();
|
||||
} catch (_err) {
|
||||
status = 'error';
|
||||
const msg = _err instanceof Error ? _err.message : 'Payment declined';
|
||||
overflowConfirm = null;
|
||||
let msg = _err instanceof Error ? _err.message : 'Payment declined';
|
||||
// Saved-card (ccof) charges skip the client-side SCA step, so a
|
||||
// definitive 402 on the saved-card path means the issuer still
|
||||
// requires verification — retrying the same saved card can never
|
||||
// succeed. Surface the fix instead of the generic backend text.
|
||||
const verificationFailure = isSavedCardVerificationRequired(responseStatus, !!cardId);
|
||||
if (verificationFailure) msg = SAVED_CARD_VERIFICATION_MESSAGE;
|
||||
error = msg;
|
||||
toast.error(`${msg}. Please try again or use another card.`);
|
||||
toast.error(verificationFailure ? msg : `${msg}. Please try again or use another card.`);
|
||||
// A definitive charge failure consumes the nonce + SCA verification
|
||||
// token — clear the cached pair so retries re-tokenize fresh. The
|
||||
// idempotency key stays for network-timeout dedup. CardSelection stays
|
||||
@@ -508,6 +596,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm the pre-start overpayment: resend the SAME rejected request with
|
||||
// confirm_overflow_tip: true so the excess is recorded as a tip.
|
||||
async function confirmOverflowPayment() {
|
||||
const pending = overflowConfirm;
|
||||
if (!pending || status === 'processing') return;
|
||||
status = 'processing';
|
||||
error = null;
|
||||
await submitBookingPayment(
|
||||
pending.paymentType,
|
||||
pending.amountCents,
|
||||
pending.cardId,
|
||||
pending.newCardToken,
|
||||
pending.verificationToken,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// Revert to the amount-editing form. The cached nonce/tokens/idempotency key
|
||||
// stay: a resubmit with the SAME amount+type+card reuses them (no charge was
|
||||
// made — the guard fired before Square), and a changed amount forces a fresh
|
||||
// tokenization + key.
|
||||
function cancelOverflowConfirmation() {
|
||||
overflowConfirm = null;
|
||||
status = 'idle';
|
||||
}
|
||||
|
||||
function handlePayDeposit() {
|
||||
const depositCents = booking.deposit_amount
|
||||
? Math.round(booking.deposit_amount * 100)
|
||||
@@ -585,7 +699,63 @@
|
||||
{/if}
|
||||
</Dialog.Header>
|
||||
|
||||
{#if status === 'idle' || status === 'processing' || status === 'error'}
|
||||
{#if overflowConfirm}
|
||||
<div class="space-y-4">
|
||||
<!-- Pre-start overpayment confirmation: the backend rejected the
|
||||
payment because the booking's remaining balance has changed
|
||||
since it was loaded (stale data). The excess over the
|
||||
remaining balance will be recorded as a tip once confirmed. -->
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 shrink-0 text-amber-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
<div>
|
||||
<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?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button
|
||||
class="flex-1"
|
||||
loading={status === 'processing'}
|
||||
disabled={status === 'processing'}
|
||||
onclick={confirmOverflowPayment}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
disabled={status === 'processing'}
|
||||
onclick={cancelOverflowConfirmation}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={handleClose}
|
||||
class="w-full"
|
||||
disabled={status === 'processing'}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
{:else if status === 'idle' || status === 'processing' || status === 'error'}
|
||||
<div class="space-y-4">
|
||||
<!-- Payment lock countdown banner — only for pending_release (vulnerable slot) -->
|
||||
{#if booking.status === 'pending_release' && lockAcquired && lockTimer > 0}
|
||||
|
||||
Reference in New Issue
Block a user