fix: round-2 loop-A fresh review (503c326 baseline) — B1 replay cap, A6 discount record, 2FA reissue+cooldown, notification flood, lockout saturation, VAT/refund-status consolidation
Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed: MONEY: - CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) — a rejected auto-refund no longer re-replays the expired key every sweep run (which minted a stacking unauthorized charge each time); FAILED-webhook demotion respects the cap; never re-replay a key whose B1 refund failed - HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible campaign discount rows immediately (capped) instead of skipping with no discount recorded — no more promised-discount-not-recorded overcharge - MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed new-card+save_card charges (re-issue guard now covers req.SaveCard) - LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining now matches the authoritative tip-excluded balance) SECURITY: - MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap on critical_payment_log + refresh_token_reuse rows) - MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer clears LastMintAt on gate-verify; cleared on terminal charge success) - MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked state instead of a fresh 5-guess budget per request - MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs instead of sleeping unboundedly; login bcrypt concurrency semaphore added - LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check; email-verification per-user attempt counter DUP/MOD: - formatCurrency single source (frontend format.ts, 7 files consolidated); SquareRefundStatusToLocal single source (errors.go, all sites); admin audit-log helper dedup; SCA retry model unified (proactive on all 6 surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from backend; generateUUID at all card-form sites; magic numbers named (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card terminal charges now audited; DAV_SKIP_INIT documented in manuals Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet tags, frontend tests+build, env-docs 42/42.
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import { formatCurrency } from '$lib/utils/format';
|
||||
import {
|
||||
CARD_VERIFICATION_RETRY_MESSAGE,
|
||||
campaignDiscountPence,
|
||||
@@ -24,16 +25,12 @@
|
||||
isOverflowTipConfirmationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
runSavedCardSCAProactively,
|
||||
sanitizeDecimalInput,
|
||||
shouldFallbackTo2FA,
|
||||
submitPaymentWithRetry,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
import {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SavedCardVerificationOutcome,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||
|
||||
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||
|
||||
@@ -236,10 +233,16 @@
|
||||
get text(): string | null {
|
||||
if (!booking.deposit_required && totalPaid === 0 && booking.amount_due <= 0) return null;
|
||||
if (booking.deposit_required) {
|
||||
return `A ${expectedDepositPercent}% deposit (at least £${(booking.total_amount * 0.2).toFixed(2)}) is required. Any payments up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) are treated as deposit for cancellations.`;
|
||||
return `A ${expectedDepositPercent}% deposit (at least ${formatCurrency(
|
||||
booking.total_amount * 0.2
|
||||
)}) is required. Any payments up to 50% of total (${formatCurrency(
|
||||
booking.total_amount * 0.5
|
||||
)}) are treated as deposit for cancellations.`;
|
||||
}
|
||||
if (totalPaid > 0 || booking.amount_due > 0) {
|
||||
return `Any payment up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
|
||||
return `Any payment up to 50% of total (${formatCurrency(
|
||||
booking.total_amount * 0.5
|
||||
)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -281,13 +284,6 @@
|
||||
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
|
||||
);
|
||||
|
||||
function formatCurrency(pence: number): string {
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
currency: 'GBP'
|
||||
}).format(pence / 100);
|
||||
}
|
||||
|
||||
function formatTimer(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
@@ -311,8 +307,12 @@
|
||||
method: 'POST'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Honor the backend's lock TTL (ttl_min — PaymentLockDuration);
|
||||
// fall back to 5 min when the field is absent so the countdown
|
||||
// can never drift from the server's value.
|
||||
lockTimer = (Number(data?.ttl_min) || 5) * 60;
|
||||
lockAcquired = true;
|
||||
lockTimer = 300;
|
||||
}
|
||||
} catch (_err) {
|
||||
console.error('Failed to acquire payment lock:', _err);
|
||||
@@ -344,7 +344,8 @@
|
||||
method: 'POST'
|
||||
});
|
||||
if (response.ok) {
|
||||
lockTimer = 300;
|
||||
const data = await response.json();
|
||||
lockTimer = (Number(data?.ttl_min) || 5) * 60;
|
||||
lockAcquired = true;
|
||||
}
|
||||
} catch (_err) {
|
||||
@@ -499,7 +500,17 @@
|
||||
if (cardId && !verificationToken) {
|
||||
waitingForSCA = true;
|
||||
try {
|
||||
const proactive = await runSavedCardSCAProactively(amountPence, cardId);
|
||||
const squareCardId = savedCardsStore.cards.find((c) => c.id === cardId)?.square_card_id;
|
||||
const proactive = await runSavedCardSCAProactively({
|
||||
amountPence,
|
||||
squareCardId: squareCardId ?? '',
|
||||
buyer: {
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
},
|
||||
onOutcome: (o) => (lastSCAOutcome = o)
|
||||
});
|
||||
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
|
||||
status = 'error';
|
||||
error = CARD_VERIFICATION_RETRY_MESSAGE;
|
||||
@@ -682,43 +693,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first charge
|
||||
* attempt (never after a 402). Square's card.tokenize(verificationDetails,
|
||||
* squareCardId) determines UP FRONT whether buyer verification is required
|
||||
* and returns a fresh verification_token bound to the exact amount:
|
||||
* - 'verified' → the caller charges with the returned token (the first
|
||||
* attempt carries it — a naked ccof is never sent);
|
||||
* - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup
|
||||
* to the only available gate and the caller proceeds WITHOUT a token;
|
||||
* - 'challenge-cancelled' / 'sca-failed' → the caller must NOT charge:
|
||||
* the pending row stays retryable and the user taps Pay again to re-run
|
||||
* the challenge.
|
||||
*/
|
||||
async function runSavedCardSCAProactively(
|
||||
amountPence: number,
|
||||
cardId: string
|
||||
): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> {
|
||||
const squareCardId = savedCardsStore.cards.find((c) => c.id === cardId)?.square_card_id;
|
||||
if (!squareCardId) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
return { outcome: 'sca-unavailable' };
|
||||
}
|
||||
let result: SavedCardVerificationResult;
|
||||
try {
|
||||
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
});
|
||||
} catch (_err) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
return { outcome: 'sca-unavailable' };
|
||||
}
|
||||
lastSCAOutcome = result.outcome;
|
||||
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
|
||||
}
|
||||
|
||||
// Confirm the overpayment: resend the SAME rejected request with
|
||||
// confirm_overflow_tip: true so the excess is recorded as a tip. Works for
|
||||
// both pre-start and post-start overflows (B12).
|
||||
@@ -864,8 +838,8 @@
|
||||
discountNote={overflowConfirm.paymentType === 'deposit' &&
|
||||
overflowConfirm.chargePence !== undefined
|
||||
? `An eligible campaign discount of ${formatCurrency(
|
||||
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
|
||||
)} applies — you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
|
||||
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence) / 100
|
||||
)} applies — you'll be charged ${formatCurrency(overflowConfirm.chargePence / 100)}.`
|
||||
: undefined}
|
||||
loading={status === 'processing'}
|
||||
onConfirm={confirmOverflowPayment}
|
||||
@@ -950,9 +924,9 @@
|
||||
<span class="text-gray-600">{service.service_name || 'Unknown Service'}</span>
|
||||
<span class="font-medium">
|
||||
{service.override_price
|
||||
? formatCurrency(Math.round(service.override_price * 100))
|
||||
? formatCurrency(Math.round(service.override_price * 100) / 100)
|
||||
: service.price
|
||||
? formatCurrency(Math.round(service.price * 100))
|
||||
? formatCurrency(Math.round(service.price * 100) / 100)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -974,7 +948,7 @@
|
||||
<div class="text-sm font-medium text-fuchsia-900">Use my Loyalty Stamp Card</div>
|
||||
<div class="mt-0.5 text-xs text-fuchsia-700">
|
||||
{stamps} stamps available · {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off
|
||||
({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
|
||||
({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) / 100)})
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1035,7 +1009,7 @@
|
||||
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Total</span>
|
||||
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span
|
||||
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100) / 100)}</span
|
||||
>
|
||||
</div>
|
||||
{#if discountPreview?.eligible}
|
||||
@@ -1043,19 +1017,19 @@
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">{d.name}</span>
|
||||
<span class="font-medium text-green-700"
|
||||
>-{formatCurrency(Math.round(d.amount * 100))}</span
|
||||
>-{formatCurrency(Math.round(d.amount * 100) / 100)}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Amount Paid</span>
|
||||
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
|
||||
<span class="font-medium text-green-700">{formatCurrency(totalPaid / 100)}</span>
|
||||
</div>
|
||||
{#if useLoyalty && loyaltyDiscount > 0}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Loyalty Stamp Card (10% Off)</span>
|
||||
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount)}</span>
|
||||
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount / 100)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between border-t border-gray-200 pt-2">
|
||||
@@ -1065,7 +1039,7 @@
|
||||
Math.max(
|
||||
0,
|
||||
remainingBalancePence - campaignDiscountPence(discountPreview) - loyaltyDiscount
|
||||
)
|
||||
) / 100
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1169,7 +1143,7 @@
|
||||
? Math.round(booking.deposit_amount * 100)
|
||||
: Math.round(booking.total_amount * 0.2 * 100),
|
||||
campaignDiscountPence(discountPreview)
|
||||
)
|
||||
) / 100
|
||||
)})
|
||||
{:else}
|
||||
Pay {formatCurrency(
|
||||
@@ -1178,7 +1152,7 @@
|
||||
Math.round(booking.amount_due * 100) -
|
||||
campaignDiscountPence(discountPreview) -
|
||||
(useLoyalty ? loyaltyDiscount : 0)
|
||||
)
|
||||
) / 100
|
||||
)}
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -1248,7 +1222,7 @@
|
||||
>
|
||||
{#if paymentType === 'partial'}
|
||||
Pay {partialAmountValid
|
||||
? formatCurrency(Math.round(partialAmountNum * 100))
|
||||
? formatCurrency(Math.round(partialAmountNum * 100) / 100)
|
||||
: 'Part'}
|
||||
{:else}
|
||||
Pay {formatCurrency(
|
||||
@@ -1257,7 +1231,7 @@
|
||||
Math.round(booking.amount_due * 100) -
|
||||
campaignDiscountPence(discountPreview) -
|
||||
(useLoyalty ? loyaltyDiscount : 0)
|
||||
)
|
||||
) / 100
|
||||
)}
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user