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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() === '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user