fix: full-scope review — tip-inclusive amount_due, sweep deposit-strand, A6 clamp cap, B13 clawback, 2FA single-use, mint audit, account-deletion re-auth, refresh dedup
Full-scope Loop A restart review (18 findings across money/security/dup-mod): MONEY: - HIGH: amount_paid/amount_due CTEs now exclude payment_type='tip' (bookings.go x6, today.go) — a tip before the final balance no longer undercharges the booking - MEDIUM-HIGH: pending payment row stores the actual chargeAmount (not req.Amount) so the sweep replay amount-match rescues deposit-with-discount rows instead of auto-refunding them; refundSweepDuplicateCharge refunds the replayed payment's actual amount - MEDIUM: A6 deposit clamp-up now caps at the discounted obligation (remainingPence - eligibleDiscountPence) — no more silent overcharge when a campaign discount >= deposit - MEDIUM: B13 campaign-loss balance credits are clawed back on cancellation (clawbackB13CampaignCredit in ProcessCancellationRefundTx) - LOW: replayLegitimateRetryWindow extended 22h->24h so a legitimate same-key retry in the retry-eligible window is rescued, not auto-refunded SECURITY: - 2FA single-use strengthened (consume-at-gate for fresh charges, re-issue on failure) - Admin 2FA mint now writes admin_audit_log + logs code reuse - Account deletion requires current password (and 2FA when enforced) — stolen token can no longer destroy the account - Multi-tab refresh-token replay deduped via cross-tab lock (no false family-kill alerts) - family-alive cache invalidated on password change / GDPR erasure - Login lockout keyed per user+IP with a capped ceiling FRONTEND/DUP-MOD: - OverflowTipConfirm shared component (UserPaymentModal + BookingFlow); overflow computation aligned (deposit-discount-aware) - PaymentModal admin 2FA gate now method-conditioned (no over-reveal on cash/giftcard) - requestTwoFactorCode shared helper (requestNewTwoFactorCode + adminRequestNewTwoFactorCode) - BookingFlow deposit display aligned to the discounted amount; formatCurrency used consistently 26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
This commit is contained in:
@@ -41,12 +41,15 @@
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import {
|
||||
canSaveCardsForRole,
|
||||
campaignDiscountPence,
|
||||
depositChargePence,
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
|
||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import {
|
||||
@@ -249,6 +252,13 @@
|
||||
discounted_total: number;
|
||||
} | null>(null);
|
||||
|
||||
function formatCurrency(pence: number): string {
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
currency: 'GBP'
|
||||
}).format(pence / 100);
|
||||
}
|
||||
|
||||
// =============== Payment Functions ===============
|
||||
async function fetchUserDepositsRequired() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
@@ -522,12 +532,23 @@
|
||||
// idempotency key are NOT cleared — the confirm resend is the same
|
||||
// logical charge.
|
||||
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(text)) {
|
||||
// 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 — mirroring UserPaymentModal so the
|
||||
// two surfaces can't show different amounts for the same booking.
|
||||
const depositDiscountPence = campaignDiscountPence(discountPreview);
|
||||
overflowConfirm = {
|
||||
amountPence,
|
||||
overflowPence: Math.max(
|
||||
0,
|
||||
amountPence - Math.round((confirmedBooking?.amount_due ?? 0) * 100)
|
||||
amountPence -
|
||||
Math.round((confirmedBooking?.amount_due ?? 0) * 100) -
|
||||
depositDiscountPence
|
||||
),
|
||||
chargePence: Math.max(0, amountPence - depositDiscountPence),
|
||||
depositAmount,
|
||||
body
|
||||
};
|
||||
@@ -603,6 +624,10 @@
|
||||
let overflowConfirm = $state<{
|
||||
amountPence: number;
|
||||
overflowPence: number;
|
||||
// Actual amount the backend will charge. Deposits are sent RAW and the
|
||||
// backend charges amountPence minus the eligible campaign credit, so
|
||||
// this can differ from amountPence (mirrors UserPaymentModal).
|
||||
chargePence?: number;
|
||||
depositAmount: number;
|
||||
body: Record<string, unknown>;
|
||||
} | null>(null);
|
||||
@@ -2573,47 +2598,21 @@
|
||||
<!-- 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-lg 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 £{(
|
||||
overflowConfirm.overflowPence / 100
|
||||
).toFixed(2)} will be recorded as a tip. Confirm to continue?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button
|
||||
class="flex-1"
|
||||
loading={isProcessingPayment}
|
||||
disabled={isProcessingPayment}
|
||||
onclick={confirmOverflowPayment}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
disabled={isProcessingPayment}
|
||||
onclick={cancelOverflowConfirmation}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
remaining balance will be recorded as a tip once confirmed.
|
||||
Shared markup with the customer payment modal
|
||||
(OverflowTipConfirm) so the two surfaces can't drift. -->
|
||||
<OverflowTipConfirm
|
||||
overflowPence={overflowConfirm.overflowPence}
|
||||
discountNote={overflowConfirm.chargePence !== undefined &&
|
||||
overflowConfirm.chargePence < overflowConfirm.amountPence
|
||||
? `An eligible campaign discount of ${formatCurrency(
|
||||
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
|
||||
)} applies — you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
|
||||
: undefined}
|
||||
loading={isProcessingPayment}
|
||||
onConfirm={confirmOverflowPayment}
|
||||
onCancel={cancelOverflowConfirmation}
|
||||
/>
|
||||
{:else}
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
@@ -2698,7 +2697,12 @@
|
||||
>
|
||||
{isProcessingPayment
|
||||
? 'Processing...'
|
||||
: `Pay Deposit £${calculateDepositAmount()}`}
|
||||
: `Pay Deposit ${formatCurrency(
|
||||
depositChargePence(
|
||||
Math.round(calculateDepositAmount() * 100),
|
||||
campaignDiscountPence(discountPreview)
|
||||
)
|
||||
)}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user