fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore
Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul: MONEY: - HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges - HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining - MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row - MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking) - MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal - MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited - MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency SECURITY: - HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts - MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records - MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded - MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep) - MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert - LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed DUP/MOD: - Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
This commit is contained in:
@@ -856,9 +856,13 @@
|
||||
{apptHours > 72
|
||||
? "Over 72 hours' notice means no deposit protection applies — a full refund is given regardless."
|
||||
: apptHours >= 24
|
||||
? "Between 24–72 hours' notice, up to 50% of the total (" +
|
||||
? "Between 24–72 hours' notice, up to " +
|
||||
POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100 +
|
||||
'% of the total (' +
|
||||
'£' +
|
||||
(selectedBooking.total_amount * 0.5).toFixed(2) +
|
||||
(selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT).toFixed(
|
||||
2
|
||||
) +
|
||||
') is treated as a protected deposit to cover the lost slot. The remaining balance above that is refunded.'
|
||||
: "Under 24 hours' notice, the full amount paid (" +
|
||||
'£' +
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { formatDuration } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -353,7 +354,7 @@
|
||||
// Unique per refund attempt so two equal partial refunds of the same
|
||||
// payment don't collide on the backend's amount-derived key; reused on
|
||||
// retry (the backend dedups on it) so a timeout can't double-refund.
|
||||
refundIdempotencyKey = crypto.randomUUID();
|
||||
refundIdempotencyKey = generateUUID();
|
||||
showRefundModal = true;
|
||||
|
||||
// Pre-fill the refund with the RESIDUAL (payment.amount − already
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -446,8 +447,9 @@
|
||||
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
|
||||
<p class="mt-1">
|
||||
"Rescheduling within {Math.round(hoursUntilAppointment)}h of the original time with
|
||||
payments present means deposit protection applies — up to 50% of the total (up to £{(
|
||||
booking.total_amount * 0.5
|
||||
payments present means deposit protection applies — up to {POLICY.PROTECTED_DEPOSIT_MAX_PCT *
|
||||
100}% of the total (up to £{(
|
||||
booking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT
|
||||
).toFixed(2)}) could be retained depending on notice period."
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -327,7 +327,7 @@
|
||||
}
|
||||
|
||||
function calculateDepositAmount(): number {
|
||||
return Math.round(getTotalPrice() * 0.2 * 100) / 100;
|
||||
return Math.round(getTotalPrice() * POLICY.REQUIRED_DEPOSIT_PCT * 100) / 100;
|
||||
}
|
||||
|
||||
async function fetchDiscountPreview() {
|
||||
@@ -2607,8 +2607,8 @@
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-emerald-800">Deposit Paid</h3>
|
||||
<p class="mt-1 text-emerald-700">
|
||||
Your deposit of <strong>{formatCurrency(calculateDepositAmount())}</strong> has been
|
||||
paid successfully. See you at your appointment!
|
||||
Your deposit of <strong>{formatCurrency(calculateDepositAmount())}</strong> has
|
||||
been paid successfully. See you at your appointment!
|
||||
</p>
|
||||
</div>
|
||||
{:else if depositRequired}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import {
|
||||
CARD_VERIFICATION_RETRY_MESSAGE,
|
||||
campaignDiscountPence,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
PAYMENT_METHOD_SAVED_CARD,
|
||||
@@ -26,10 +27,10 @@
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
|
||||
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
|
||||
interface Props {
|
||||
booking: Booking;
|
||||
@@ -62,7 +63,7 @@
|
||||
amount: number;
|
||||
};
|
||||
|
||||
type PaymentMethod = 'card' | 'cash' | 'giftcard' | (typeof PAYMENT_METHOD_SAVED_CARD) | null;
|
||||
type PaymentMethod = 'card' | 'cash' | 'giftcard' | typeof PAYMENT_METHOD_SAVED_CARD | null;
|
||||
|
||||
let status = $state<PaymentStatus>('idle');
|
||||
let selectedMethod = $state<PaymentMethod>(null);
|
||||
@@ -75,6 +76,65 @@
|
||||
// reactive flag is checked synchronously at the start of every handler.
|
||||
let isProcessingPaymentSync = false;
|
||||
|
||||
// Overpayment confirmation (mirrors UserPaymentModal/BookingFlow). The
|
||||
// backend rejects a payment that exceeds the booking's remaining balance
|
||||
// unless the request carries `confirm_overflow_tip: true` — a tip is
|
||||
// gratuity for service already rendered. The guard fires on STALE booking
|
||||
// data (multi-tab, admin-changed totals) where the operator would otherwise
|
||||
// be stuck with an unresolvable 400; the rejected request body is parked
|
||||
// here and a Confirm/Cancel prompt is shown, with Confirm resending the SAME
|
||||
// body plus the flag.
|
||||
let overflowConfirm = $state<{
|
||||
amountPence: number;
|
||||
overflowPence: number;
|
||||
body: Record<string, unknown>;
|
||||
} | null>(null);
|
||||
|
||||
function confirmOverflowPayment() {
|
||||
const pending = overflowConfirm;
|
||||
if (!pending || status === 'saved-card-processing') return;
|
||||
status = 'saved-card-processing';
|
||||
error = null;
|
||||
isProcessingPaymentSync = true;
|
||||
apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...pending.body, confirm_overflow_tip: true })
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const errData = await response.text();
|
||||
throw new Error(extractErrorMessage(errData) || 'Failed to process payment');
|
||||
}
|
||||
const data = await response.json();
|
||||
status = 'success';
|
||||
paymentResult = {
|
||||
checkout_id: data.payment_id || data.checkout_id || data.id || '',
|
||||
status: 'COMPLETED',
|
||||
card_brand: data.card_brand,
|
||||
last4: data.card_last4,
|
||||
amount: data.amount
|
||||
};
|
||||
overflowConfirm = null;
|
||||
toast.success('Payment successful');
|
||||
onComplete(paymentResult);
|
||||
})
|
||||
.catch((_err) => {
|
||||
status = 'error';
|
||||
error = _err instanceof Error ? _err.message : 'Failed to process payment';
|
||||
toast.error(error ?? 'Unknown error');
|
||||
})
|
||||
.finally(() => {
|
||||
isProcessingPaymentSync = false;
|
||||
});
|
||||
}
|
||||
|
||||
function cancelOverflowConfirmation() {
|
||||
overflowConfirm = null;
|
||||
status = 'idle';
|
||||
selectedMethod = null;
|
||||
}
|
||||
|
||||
// B6/B10: charging a customer's saved card requires the customer's current
|
||||
// 2FA verification code when the backend enforces the gate. The backend keys
|
||||
// on the CARD OWNER (the booking's user), so the input is surfaced whenever
|
||||
@@ -168,7 +228,7 @@
|
||||
);
|
||||
|
||||
const loyaltyDiscount = $derived(
|
||||
useLoyalty ? Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) : 0
|
||||
useLoyalty ? Math.round(booking.total_amount * 100 * POLICY.LOYALTY_DISCOUNT_RATE) : 0
|
||||
);
|
||||
|
||||
// Campaign discount preview — fetched on mount, mirroring the customer flow
|
||||
@@ -324,11 +384,10 @@
|
||||
|
||||
const tipPercentages = $derived.by(() => {
|
||||
if (netTotal <= 0) return [];
|
||||
return [
|
||||
{ pct: 10, amount: Math.round(netTotal * 0.1 * 100) / 100 },
|
||||
{ pct: 15, amount: Math.round(netTotal * 0.15 * 100) / 100 },
|
||||
{ pct: 20, amount: Math.round(netTotal * 0.2 * 100) / 100 }
|
||||
];
|
||||
return POLICY.TIP_PRESET_PCTS.map((pct) => ({
|
||||
pct,
|
||||
amount: Math.round(netTotal * (pct / 100) * 100) / 100
|
||||
}));
|
||||
});
|
||||
|
||||
const tipMultiplier = $derived(
|
||||
@@ -342,7 +401,7 @@
|
||||
const totalWithTip = $derived(tipEnabled ? netTotal * tipMultiplier : netTotal);
|
||||
const tipDisplay = $derived(
|
||||
selectedTipPercent !== null
|
||||
? `${selectedTipPercent}%`
|
||||
? `${selectedTipPercent}%`
|
||||
: customTipAmount && parseFloat(customTipAmount) > 0
|
||||
? `${formatCurrency(parseFloat(customTipAmount))}`
|
||||
: ''
|
||||
@@ -404,6 +463,24 @@
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.text();
|
||||
// Overflow guard (defensive — the admin terminal path currently
|
||||
// clamps instead, but a 400 carrying the code must surface the
|
||||
// Confirm/Cancel prompt like the customer modal, not a dead-end).
|
||||
if (isOverflowTipConfirmationRequired(errData)) {
|
||||
overflowConfirm = {
|
||||
amountPence: Math.round(finalAmount * 100) - loyaltyDiscount,
|
||||
overflowPence: Math.max(
|
||||
0,
|
||||
Math.round(finalAmount * 100) - loyaltyDiscount - Math.round(netTotal * 100)
|
||||
),
|
||||
body: {
|
||||
amount: Math.round(finalAmount * 100) - loyaltyDiscount,
|
||||
payment_type: 'full',
|
||||
tip_enabled: tipEnabled
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
throw new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
|
||||
}
|
||||
|
||||
@@ -841,9 +918,7 @@
|
||||
let verificationToken = '';
|
||||
status = 'saved-card-waiting-sca';
|
||||
try {
|
||||
const squareCardId = savedCards.find(
|
||||
(c) => c.id === selectedSavedCardId
|
||||
)?.square_card_id;
|
||||
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
|
||||
const proactive = await runSavedCardSCAProactively({
|
||||
amountPence: chargeAmount,
|
||||
squareCardId: squareCardId ?? '',
|
||||
@@ -898,6 +973,27 @@
|
||||
if (!response.ok) {
|
||||
responseStatus = response.status;
|
||||
const errData = await response.text();
|
||||
// Overflow guard: a 400 carrying the backend's
|
||||
// `overflow_tip_confirmation_required` code means the charge
|
||||
// exceeds the booking's remaining balance (stale data). Park the
|
||||
// rejected request and surface the Confirm/Cancel prompt instead
|
||||
// of a dead-end 400; Confirm resends the SAME body with the flag.
|
||||
if (isOverflowTipConfirmationRequired(errData)) {
|
||||
overflowConfirm = {
|
||||
amountPence: chargeAmount,
|
||||
overflowPence: Math.max(0, chargeAmount - Math.round(netTotal * 100)),
|
||||
body: {
|
||||
amount: chargeAmount,
|
||||
payment_type: 'full',
|
||||
payment_method: 'saved_card',
|
||||
saved_card_id: selectedSavedCardId,
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
|
||||
idempotency_key: savedCardIdempotencyKey
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
// A 402 verification-required here means the fresh proactive
|
||||
// token was stale/expired at Square — the charge did NOT land.
|
||||
// Surface the SCA-first guidance; the operator taps Pay again and
|
||||
@@ -981,7 +1077,15 @@
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
// ESC/overlay while a charge is in flight must not close the modal — the
|
||||
// charge may still land. ESC while the overflow-confirm prompt is showing
|
||||
// dismisses the prompt (back to the amount-editing form), mirroring the
|
||||
// customer modal, instead of closing the whole flow.
|
||||
if (isChargeInFlight(status)) return;
|
||||
if (overflowConfirm) {
|
||||
cancelOverflowConfirmation();
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
@@ -990,7 +1094,23 @@
|
||||
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if status === 'idle'}
|
||||
{#if overflowConfirm}
|
||||
<div class="space-y-4">
|
||||
<!-- 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. Shared component with the
|
||||
customer payment modal and the booking-flow deposit step so the
|
||||
admin surface can't drift. -->
|
||||
<OverflowTipConfirm
|
||||
overflowPence={overflowConfirm.overflowPence}
|
||||
loading={status === 'saved-card-processing'}
|
||||
onConfirm={confirmOverflowPayment}
|
||||
onCancel={cancelOverflowConfirmation}
|
||||
/>
|
||||
<Button variant="ghost" onclick={handleClose} class="w-full">Close</Button>
|
||||
</div>
|
||||
{:else if status === 'idle'}
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
|
||||
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
|
||||
@@ -1039,8 +1159,8 @@
|
||||
<div class="text-sm font-medium text-fuchsia-900">Use Loyalty Stamp Card</div>
|
||||
<div class="mt-0.5 text-xs text-fuchsia-700">
|
||||
{Math.floor(stamps / 10)} full card{Math.floor(stamps / 10) === 1 ? '' : 's'} available
|
||||
· {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(
|
||||
Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) / 100
|
||||
· {Math.round(POLICY.LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(
|
||||
Math.round(booking.total_amount * 100 * POLICY.LOYALTY_DISCOUNT_RATE) / 100
|
||||
)})
|
||||
</div>
|
||||
</label>
|
||||
@@ -1132,7 +1252,8 @@
|
||||
class="flex items-center justify-between rounded-md border border-green-200 bg-green-50 p-3"
|
||||
>
|
||||
<span class="text-sm font-medium text-green-800">Already paid</span>
|
||||
<span class="text-base font-bold text-green-800">{formatCurrency(amountPaidPence / 100)}</span
|
||||
<span class="text-base font-bold text-green-800"
|
||||
>{formatCurrency(amountPaidPence / 100)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1224,14 +1345,14 @@
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
|
||||
PAYMENT_METHOD_SAVED_CARD
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-input hover:bg-fuchsia-50'}"
|
||||
onclick={() => {
|
||||
selectedMethod = PAYMENT_METHOD_SAVED_CARD;
|
||||
status = 'saved-card-selecting';
|
||||
}}
|
||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
|
||||
PAYMENT_METHOD_SAVED_CARD
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-input hover:bg-fuchsia-50'}"
|
||||
onclick={() => {
|
||||
selectedMethod = PAYMENT_METHOD_SAVED_CARD;
|
||||
status = 'saved-card-selecting';
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-2 h-8 w-8"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import { formatCurrency } from '$lib/utils/format';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import {
|
||||
CARD_VERIFICATION_RETRY_MESSAGE,
|
||||
canSaveCardsForRole,
|
||||
@@ -155,11 +156,10 @@
|
||||
|
||||
const tipPercentages = $derived.by(() => {
|
||||
if (subtotal <= 0) return [];
|
||||
return [
|
||||
{ pct: 10, amount: Math.round(subtotal * 0.1 * 100) / 100 },
|
||||
{ pct: 15, amount: Math.round(subtotal * 0.15 * 100) / 100 },
|
||||
{ pct: 20, amount: Math.round(subtotal * 0.2 * 100) / 100 }
|
||||
];
|
||||
return POLICY.TIP_PRESET_PCTS.map((pct) => ({
|
||||
pct,
|
||||
amount: Math.round(subtotal * (pct / 100) * 100) / 100
|
||||
}));
|
||||
});
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
@@ -32,8 +33,6 @@
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
|
||||
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||
|
||||
interface Props {
|
||||
booking: Booking;
|
||||
onClose: () => void;
|
||||
@@ -223,25 +222,27 @@
|
||||
);
|
||||
|
||||
const loyaltyDiscount = $derived(
|
||||
useLoyalty ? Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) : 0
|
||||
useLoyalty ? Math.round(booking.total_amount * 100 * POLICY.LOYALTY_DISCOUNT_RATE) : 0
|
||||
);
|
||||
|
||||
// Deposit policy warning text — dynamic based on booking state
|
||||
const expectedDepositPercent = $derived(booking.deposit_required ? 20 : 0);
|
||||
const expectedDepositPercent = $derived(
|
||||
booking.deposit_required ? POLICY.REQUIRED_DEPOSIT_PCT * 100 : 0
|
||||
);
|
||||
const depositPolicyWarning = $derived<string | null>(
|
||||
{
|
||||
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 ${formatCurrency(
|
||||
booking.total_amount * 0.2
|
||||
)}) is required. Any payments up to 50% of total (${formatCurrency(
|
||||
booking.total_amount * 0.5
|
||||
booking.total_amount * POLICY.REQUIRED_DEPOSIT_PCT
|
||||
)}) is required. Any payments up to ${POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100}% of total (${formatCurrency(
|
||||
booking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT
|
||||
)}) are treated as deposit for cancellations.`;
|
||||
}
|
||||
if (totalPaid > 0 || booking.amount_due > 0) {
|
||||
return `Any payment up to 50% of total (${formatCurrency(
|
||||
booking.total_amount * 0.5
|
||||
return `Any payment up to ${POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100}% of total (${formatCurrency(
|
||||
booking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT
|
||||
)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
|
||||
}
|
||||
return null;
|
||||
@@ -729,7 +730,7 @@
|
||||
// pre-discounted deposit here would double-discount (HIGH-2).
|
||||
const depositPence = booking.deposit_amount
|
||||
? Math.round(booking.deposit_amount * 100)
|
||||
: Math.round(booking.total_amount * 0.2 * 100);
|
||||
: Math.round(booking.total_amount * POLICY.REQUIRED_DEPOSIT_PCT * 100);
|
||||
makePayment('deposit', depositPence);
|
||||
}
|
||||
|
||||
@@ -947,8 +948,11 @@
|
||||
<label for="use-loyalty" class="cursor-pointer select-none">
|
||||
<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) / 100)})
|
||||
{stamps} stamps available · {Math.round(
|
||||
POLICY.LOYALTY_DISCOUNT_RATE * 100
|
||||
)}% off ({formatCurrency(
|
||||
Math.round(booking.total_amount * 100 * POLICY.LOYALTY_DISCOUNT_RATE) / 100
|
||||
)})
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1009,7 +1013,8 @@
|
||||
<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) / 100)}</span
|
||||
<span class="font-medium"
|
||||
>{formatCurrency(Math.round(booking.total_amount * 100) / 100)}</span
|
||||
>
|
||||
</div>
|
||||
{#if discountPreview?.eligible}
|
||||
@@ -1029,7 +1034,9 @@
|
||||
{#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 / 100)}</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">
|
||||
@@ -1141,7 +1148,7 @@
|
||||
depositChargePence(
|
||||
booking.deposit_amount
|
||||
? Math.round(booking.deposit_amount * 100)
|
||||
: Math.round(booking.total_amount * 0.2 * 100),
|
||||
: Math.round(booking.total_amount * POLICY.REQUIRED_DEPOSIT_PCT * 100),
|
||||
campaignDiscountPence(discountPreview)
|
||||
) / 100
|
||||
)})
|
||||
|
||||
@@ -7,5 +7,14 @@ export const POLICY = {
|
||||
RESCHEDULE_BLOCK_HOURS_WITH_PAYMENTS: 72,
|
||||
RESCHEDULE_BLOCK_HOURS_NO_PAYMENTS: 24,
|
||||
PROTECTED_DEPOSIT_MAX_PCT: 0.5,
|
||||
REQUIRED_DEPOSIT_PCT: 0.2
|
||||
REQUIRED_DEPOSIT_PCT: 0.2,
|
||||
// Loyalty redemption: 10 stamps → 10% off the booking total. Shared by the
|
||||
// admin and customer payment modals so the discount rate can't drift.
|
||||
LOYALTY_DISCOUNT_RATE: 0.1,
|
||||
// Gratuity suggestion presets offered by the tip surfaces (admin payment
|
||||
// modal + customer tip page). Deliberately SEPARATE from the deposit policy
|
||||
// constants above — a tip suggestion is gratuity, not a deposit percentage,
|
||||
// and coupling them would silently change the tip buttons if the deposit
|
||||
// rate ever changed.
|
||||
TIP_PRESET_PCTS: [10, 15, 20]
|
||||
} as const;
|
||||
|
||||
@@ -283,8 +283,13 @@
|
||||
// value instead of a hardcoded copy and survives a page reload. The local
|
||||
// counter still only reflects confirmed purchases made in this session; any
|
||||
// rejection the counter can't foresee surfaces through the backend's error
|
||||
// toast.
|
||||
let dailyGiftCardBuyLimit = $state(500);
|
||||
// toast. The fallback below exists ONLY for old servers that don't expose
|
||||
// daily_buy_limit yet — it is always overwritten by the server value when
|
||||
// the balance fetch succeeds (see fetchGiftCardBalance).
|
||||
// Cross-reference: backend/handlers/payments/giftcard_limits.go
|
||||
// maxUserGiftCardDailyPence = 500_00 pence (£500).
|
||||
const DAILY_GIFT_CARD_BUY_LIMIT_FALLBACK_GBP = 500;
|
||||
let dailyGiftCardBuyLimit = $state(DAILY_GIFT_CARD_BUY_LIMIT_FALLBACK_GBP);
|
||||
let buyDailyTotal = $state(0);
|
||||
const buyLimitReached = $derived(buyDailyTotal >= dailyGiftCardBuyLimit);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user