fix: round-3 — tip gate asymmetry, webhook VAT align + 503 notifications, cash-tip campaign overcharge, lockout DoS, erasure durability, S3 retry cap, env parsing, per-user rate limiters, consume dead code, frontend 2FA remnants

- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate
- webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table
- cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password
- lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env
- erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added
- env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency
- auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO
- frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent fba00a10ad
commit 9a12a2d886
27 changed files with 1206 additions and 226 deletions
@@ -348,7 +348,9 @@
async function openRefundModal(payment: Payment) {
refundPaymentId = payment.id;
refundAmount = (payment.amount / 100).toFixed(2);
// payment.amount is in POUNDS (float64) — no /100 here. Pre-fill with
// the full amount; the payment-summary residual below overrides it.
refundAmount = payment.amount.toFixed(2);
refundAlreadyRefundedPence = 0;
refundReason = '';
// Unique per refund attempt so two equal partial refunds of the same
@@ -375,8 +377,11 @@
.filter((r) => r.payment_id === payment.id && r.status === 'completed')
.reduce((sum, r) => sum + r.amount, 0);
if (alreadyRefunded > 0) {
// `alreadyRefunded` is PENCE (payment-summary refund rows) while
// payment.amount is POUNDS — convert to pounds before subtracting
// so a partially refunded payment pre-fills the correct residual.
refundAlreadyRefundedPence = alreadyRefunded;
refundAmount = (Math.max(0, payment.amount - alreadyRefunded) / 100).toFixed(2);
refundAmount = Math.max(0, payment.amount - alreadyRefunded / 100).toFixed(2);
}
}
} catch {
@@ -41,13 +41,13 @@
class="mt-1 font-mono tracking-widest"
/>
<p class="mt-1 text-xs text-gray-500">
Your bank doesn't support in-app approval — enter the code sent to you / your phone.
Your card doesn't support in-app approval — please try a different card or payment method.
</p>
</div>
{:else}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<p class="text-sm text-amber-800">
Two-factor authentication is required to use online card payments.
Secure card verification is required for this payment method.
<a href={resolve('/account')} class="font-medium underline"
>Enable it in your account settings</a
>.
@@ -877,8 +877,16 @@
</Card.Root>
{#if showPaymentModal && activeAppointment}
{@const firstName = activeAppointment.user?.full_name?.split(' ')[0] || ''}
{@const lastName = activeAppointment.user?.full_name?.split(' ').slice(1).join(' ') || ''}
{@const bookingWithNames = {
...activeAppointment,
user: activeAppointment.user
? { ...activeAppointment.user, first_name: firstName, last_name: lastName }
: undefined
}}
<PaymentModal
booking={activeAppointment as BookingType}
booking={bookingWithNames as BookingType}
onClose={() => (showPaymentModal = false)}
onComplete={handlePaymentComplete}
/>
+29 -20
View File
@@ -240,35 +240,44 @@ describe('buildCashTillPaymentBody', () => {
});
describe('cashChargeBasePence', () => {
// Concrete arithmetic pinned to the FIX-1 scenario: booking £100, pending
// 10% campaign preview (£10), £10 loyalty redemption, £100 cash tender with
// the keep-change-as-tip checkbox on. netTotal = £90 (campaign subtracted),
// so the current charge base of £85 (totalDue loyalty) understates the
// backend's remaining basis and absorbs the tip.
it('restores the pending campaign credit into the charge base (tip carve basis)', () => {
// totalDuePence = £90 net (campaign subtracted, pre-loyalty)
expect(cashChargeBasePence(9000, 1000, 1000)).toBe(9000);
// FIX-1/FIX-2: The charge base is the FULL totalDuePence — neither the
// campaign credit nor the loyalty discount is subtracted. The backend
// applies both at completion via separate discount rows, and the tip carve
// (`amount remaining`) uses GetBookingRemainingBalancePence which does
// NOT account for pending discounts. Subtracting them would undercharge
// the booking and absorb the tip into booking credit.
it('charges the full total due — campaign and loyalty are applied server-side', () => {
// Booking £100 net (campaign already subtracted), £10 campaign credit,
// £10 loyalty redemption → charge base = £100 (full totalDuePence)
expect(cashChargeBasePence(10000, 1000, 1000)).toBe(10000);
});
it('keeps the plain net total when no campaign is eligible', () => {
// totalDue = £90 (no campaign), loyalty £10 → base = £80 = the net obligation
expect(cashChargeBasePence(9000, 0, 1000)).toBe(8000);
it('ignores campaignPence and loyaltyPence — always returns totalDuePence', () => {
// totalDue = £90 (no campaign), loyalty £10 → base = £90, not £80
expect(cashChargeBasePence(9000, 0, 1000)).toBe(9000);
});
it('never goes below zero (fully covered by discounts + loyalty)', () => {
expect(cashChargeBasePence(1000, 0, 5000)).toBe(0);
it('never goes below zero', () => {
expect(cashChargeBasePence(0, 0, 0)).toBe(0);
expect(cashChargeBasePence(-100, 0, 0)).toBe(0);
});
it('the folded tip body uses the base, so UI tip == backend-recorded tip', () => {
// Booking £100, campaign £10, loyalty £10: base = 9000 (100 20 + 10).
// Tender £100 → tip £10 → amount £100. The backend carves against the
// full £100 remaining, so it records £0 tip — matching the UI claim
// that only the amount above the charge base is a tip.
const basePence = cashChargeBasePence(9000, 1000, 1000);
const tipPence = 10000 - basePence;
expect(buildCashTillPaymentBody(basePence, tipPence).amount).toBe(10000);
// Booking £100 net, campaign £10, loyalty £10: base = 10000.
// Tender £110 → tip £10 → amount £110. The backend carves against
// the full £100 remaining (no discount deduction), so it records
// £10 tip — matching the UI claim.
const basePence = cashChargeBasePence(10000, 1000, 1000);
const tipPence = 11000 - basePence;
expect(buildCashTillPaymentBody(basePence, tipPence).amount).toBe(11000);
expect(tipPence).toBe(1000);
});
it('campaignPence and loyaltyPence are accepted but ignored (call-site compat)', () => {
// The parameters exist for call-site compatibility — the calling
// modals still compute them for display. The arithmetic ignores them.
expect(cashChargeBasePence(5000, 9999, 9999)).toBe(5000);
});
});
describe('payment failure classification', () => {
+19 -16
View File
@@ -62,27 +62,30 @@ export function buildCashTillPaymentBody(
}
/**
* The cash till-sale charge base in pence, aligned with the backend's
* CreateTerminalPayment tip carve (backend/handlers/payments/handlers.go
* ~577-630). The backend derives the recorded tip from `amount remaining`,
* The cash till-sale charge base in pence. The backend's
* CreateTerminalPayment derives the recorded tip from `amount remaining`,
* where `remaining` comes from GetBookingRemainingBalancePence (service.go) —
* which does NOT subtract the pending campaign discount: campaigns auto-apply
* AFTER the remaining is read, minting `payment_method='discount'` rows that
* never reduce the balance. If the frontend charges the campaign-reduced total
* (`totalDuePence`), the sent amount is smaller than the backend's remaining,
* so `amount remaining` is absorbed (the tip is under-recorded or the whole
* payment lands as booking credit). Restoring the campaign credit into the
* charge base keeps the sent amount on the same basis the backend carves
* against. `totalDuePence` is the net total (campaign already subtracted,
* pre-loyalty), `campaignPence` the eligible preview credit and `loyaltyPence`
* the redemption being applied on top.
* which does NOT subtract the pending campaign discount or loyalty discount
* (both mint `payment_method='discount'` rows that never reduce the balance).
* The charge base must therefore be the FULL totalDuePence — neither the
* campaign credit nor the loyalty discount is subtracted, because the backend
* applies both at completion via separate discount rows. The campaign and
* loyalty parameters are kept for call-site compatibility (the calling modals
* still compute them for display) but are deliberately unused in the
* arithmetic: subtracting them would undercharge the booking and cause the
* backend's tip carve (`amount remaining`) to absorb the tip or record a
* negative tip.
*
* FIX-1/FIX-2: `campaignPence` and `loyaltyPence` are accepted but IGNORED.
* The correct charge base is the full total due, with campaign and loyalty
* applied server-side at completion.
*/
export function cashChargeBasePence(
totalDuePence: number,
campaignPence: number,
loyaltyPence: number
_campaignPence: number,
_loyaltyPence: number
): number {
return Math.max(0, totalDuePence - loyaltyPence + campaignPence);
return Math.max(0, totalDuePence);
}
/**
+25 -82
View File
@@ -6,25 +6,20 @@
import { toast } from 'svelte-sonner';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
isNonceStale,
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
requestNewTwoFactorCode,
runSavedCardSCAProactively,
shouldShowSCARefusal,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { generateUUID } from '$lib/utils/uuid';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
isNonceStale,
isSquareConfigured,
isVerificationRequiredSignal,
requestNewTwoFactorCode,
runSavedCardSCAProactively,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import { generateUUID } from '$lib/utils/uuid';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
@@ -286,8 +281,6 @@
// verification code must be carried on the charge. Shared two-factor-code
// state (code, reveal, show/missing derivations, "Request a new code"
// handler) — see $lib/stores/twoFactorCode.svelte.ts.
const buyTwoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
const buySavedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// fallback); every other outcome keeps SCA primary for the next retry.
@@ -298,13 +291,6 @@
// Retryable purchase failure message shown above the Pay button (challenge
// cancelled/failed, decline) so the retry affordance matches the outcome.
let buyError = $state<string | null>(null);
const buyTwoFactor = useTwoFactorCodeForSavedCard({
enabled: () => buyTwoFactorEnabled,
gateActive: () => buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard),
// C6 SCA-only posture: SCA is ALWAYS the authorisation — the code input
// only ever surfaces via a backend gate rejection (defensive/opt-in).
scaAvailable: () => true
});
// Client-side mirror of the £500/day online purchase cap. The backend is
// authoritative — the balance endpoint the page already calls exposes the
@@ -560,8 +546,6 @@
// BEFORE any charge is submitted and surface the refusal
// notice — there is NO 2FA fallback; the gift card is
// bought online later.
buyTwoFactor.declineConsent();
buyTwoFactor.reveal = false;
buyingGiftCard = false;
buyError = null;
return;
@@ -606,15 +590,9 @@
save_card: buySaveCard && !verificationToken
}
: {}),
...(buyTwoFactor.showInput && !verificationToken
? { verification_code: buyTwoFactor.code }
: {}),
idempotency_key: buyIdempotencyKey
})
}),
// Finding 4: a 2FA-gated charge consumed its code at the backend
// gate — a 503 auto-retry would re-send a dead code and self-defeat.
{ verificationCodeGated: buyTwoFactor.showInput && !verificationToken }
})
);
if (res.ok) {
@@ -632,8 +610,6 @@
buyTokenAmount = 0;
buyTokenizedAt = 0;
buyTokenizedForSaveCard = false;
buyTwoFactor.setCode('');
buyTwoFactor.reveal = false;
await fetchGiftCardBalance();
} else {
// Capture the status BEFORE consuming the body — the
@@ -652,15 +628,10 @@
const buyErrMsg = verificationRequired
? VERIFICATION_REQUIRED_MESSAGE
: extractErrorMessage(errText) || 'Failed to purchase gift card';
if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) {
buyTwoFactor.reveal = true;
}
if (verificationRequired) {
// M13: a verification-required 402 means the backend did NOT
// accept the fallback code (SCA-only posture / invalid token)
// — withdraw consent so the code input never reappears and
// the SCA guidance is shown instead of looping on 2FA.
buyTwoFactor.declineConsent();
// — the SCA guidance is shown instead of looping on 2FA.
}
buyError = buyErrMsg;
toast.error(buyErrMsg);
@@ -1618,10 +1589,10 @@
toast.error('Please type DELETE to confirm');
return;
}
if (deleteCurrentPassword === '') {
toast.error('Enter your current password to confirm');
return;
}
// Passwordless accounts (social login) have no password to confirm —
// the password field is optional; the backend re-verifies credentials
// server-side (and its 2FA gate governs passwordless deletion).
const suppliedPassword = deleteCurrentPassword.trim();
const twoFactorActive = deleteTwoFactorRequired || deleteRevealTwoFactor;
if (twoFactorActive && deleteVerificationCode.trim() === '') {
toast.error('Enter your verification code to confirm');
@@ -1637,7 +1608,7 @@
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current_password: deleteCurrentPassword,
...(suppliedPassword ? { current_password: suppliedPassword } : {}),
...(twoFactorActive ? { verification_code: deleteVerificationCode.trim() } : {})
})
});
@@ -2815,36 +2786,7 @@
bind:selectedCardId={buySelectedCard}
bind:saveCard={buySaveCard}
onValidityChange={(v) => (buyCardSelectionValid = v)}
/>
<!-- C6: SCA-unavailable refusal — the ONLY behaviour on a genuine
sca-unavailable outcome: the charge cannot complete and the
user must pay online later (no 2FA code fallback). -->
<ScaFallbackConsentDialog
open={shouldShowSCARefusal(buyLastSCAOutcome)}
onOk={() => {
buyLastSCAOutcome = '';
buyError = null;
}}
/>
<!-- B6/B10: saved-card gift-card charges require the card owner's
current 2FA verification code when the backend enforces the gate. -->
<TwoFactorCodeInput
bind:code={buyTwoFactor.code}
showInput={buyTwoFactor.showInput}
enabled={buyTwoFactorEnabled}
/>
{#if buyTwoFactor.showInput && buyTwoFactorEnabled}
<Button
variant="outline"
class="min-h-11 w-full"
loading={buyTwoFactor.requesting}
disabled={buyTwoFactor.requesting}
onclick={buyTwoFactor.requestNewCode}
>
Request a new code
</Button>
{/if}
</div>
</div>
{#if buyWaitingForSCA}
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
@@ -2881,7 +2823,6 @@
onclick={buyGiftCard}
disabled={buyingGiftCard ||
!isBuyCardValid ||
buyTwoFactor.missing ||
(buyRecipientType === 'self' && !buySelfAck) ||
buyDailyTotal + buyAmount > dailyGiftCardBuyLimit}
class="mt-2 min-h-11 w-full"
@@ -3641,6 +3582,9 @@
autocomplete="current-password"
class="mt-2"
/>
<p class="mt-1 text-xs text-gray-500">
Only required if your account has a password.
</p>
</div>
{#if deleteTwoFactorRequired || deleteRevealTwoFactor}
<div class="space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3">
@@ -3690,7 +3634,6 @@
disabled={
deletingAccount ||
deleteConfirmText !== 'DELETE' ||
deleteCurrentPassword === '' ||
((deleteTwoFactorRequired || deleteRevealTwoFactor) &&
deleteVerificationCode.trim() === '')
}