diff --git a/frontend/src/lib/components/payments/PaymentModal.svelte b/frontend/src/lib/components/payments/PaymentModal.svelte index 78ae079..1790184 100644 --- a/frontend/src/lib/components/payments/PaymentModal.svelte +++ b/frontend/src/lib/components/payments/PaymentModal.svelte @@ -271,6 +271,12 @@ let selectedTipPercent = $state(null); let customTipAmount = $state(''); + // Partial payment amounts for card and saved card (in pounds, user enters) + let cardPaymentAmount = $state(''); + let lastValidCardAmount = $state(''); + let savedCardPaymentAmount = $state(''); + let lastValidSavedCardAmount = $state(''); + let pollingInterval: ReturnType | null = null; function selectTipPercent(percent: number) { @@ -289,6 +295,30 @@ tipEnabled = customTipAmount !== '' && parseFloat(customTipAmount) > 0; } + function handleCardAmountInput(e: Event) { + const input = e.target as HTMLInputElement; + const sanitized = sanitizeDecimalInput(input.value); + if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) { + cardPaymentAmount = sanitized; + lastValidCardAmount = sanitized; + } else { + cardPaymentAmount = lastValidCardAmount; + input.value = lastValidCardAmount; + } + } + + function handleSavedCardAmountInput(e: Event) { + const input = e.target as HTMLInputElement; + const sanitized = sanitizeDecimalInput(input.value); + if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) { + savedCardPaymentAmount = sanitized; + lastValidSavedCardAmount = sanitized; + } else { + savedCardPaymentAmount = lastValidSavedCardAmount; + input.value = lastValidSavedCardAmount; + } + } + function getServicePrice(service: BookingService): number { const override = serviceOverrides[service.service_id]; if (override && override.price !== '') { @@ -367,6 +397,56 @@ // disabled in that state; the handlers also guard defensively. const nothingToCharge = $derived(totalDue <= 0); + // Card payment amount validation + const cardAmountNum = $derived(cardPaymentAmount === '' ? totalDue : parseFloat(cardPaymentAmount)); + const cardAmountValid = $derived( + cardPaymentAmount === '' || + (cardPaymentAmount !== '' && + !isNaN(cardAmountNum) && + cardAmountNum > 0 && + cardAmountNum <= totalDue && + /^\d+(\.\d{0,2})?$/.test(cardPaymentAmount)) + ); + const cardValidationError = $derived( + cardPaymentAmount !== '' && !cardAmountValid + ? cardPaymentAmount === '' + ? '' + : !/^\d+(\.\d{0,2})?$/.test(cardPaymentAmount) + ? 'Enter a valid amount (max 2 decimal places)' + : cardAmountNum <= 0 + ? 'Amount must be greater than 0' + : cardAmountNum > totalDue + ? `Amount cannot exceed ${formatCurrency(totalDue)}` + : '' + : '' + ); + + // Saved card payment amount validation + const savedCardAmountNum = $derived( + savedCardPaymentAmount === '' ? totalDue : parseFloat(savedCardPaymentAmount) + ); + const savedCardAmountValid = $derived( + savedCardPaymentAmount === '' || + (savedCardPaymentAmount !== '' && + !isNaN(savedCardAmountNum) && + savedCardAmountNum > 0 && + savedCardAmountNum <= totalDue && + /^\d+(\.\d{0,2})?$/.test(savedCardPaymentAmount)) + ); + const savedCardValidationError = $derived( + savedCardPaymentAmount !== '' && !savedCardAmountValid + ? savedCardPaymentAmount === '' + ? '' + : !/^\d+(\.\d{0,2})?$/.test(savedCardPaymentAmount) + ? 'Enter a valid amount (max 2 decimal places)' + : savedCardAmountNum <= 0 + ? 'Amount must be greater than 0' + : savedCardAmountNum > totalDue + ? `Amount cannot exceed ${formatCurrency(totalDue)}` + : '' + : '' + ); + async function applyLoyaltyRedemption(): Promise { if (!useLoyalty) return; const res = await apiFetch(`/api/admin/bookings/${booking.id}/apply-redemption`, { @@ -381,12 +461,16 @@ async function handleCardPayment() { if (isProcessingPaymentSync) return; - const finalAmount = totalDue; + const finalAmount = cardAmountNum; if (isNaN(finalAmount) || finalAmount <= 0) { toast.error('Please enter a valid amount'); return; } + if (finalAmount > totalDue) { + toast.error(`Amount cannot exceed ${formatCurrency(totalDue)}`); + return; + } // The loyalty redemption is applied on top of totalDue; guard against // the effective charge being zero or negative. if (Math.round(finalAmount * 100) - loyaltyDiscount <= 0) { @@ -837,7 +921,16 @@ return; } - const chargeAmount = Math.round(totalDue * 100) - loyaltyDiscount; + const chargeAmount = Math.round(savedCardAmountNum * 100) - loyaltyDiscount; + + if (isNaN(savedCardAmountNum) || savedCardAmountNum <= 0) { + toast.error('Please enter a valid amount'); + return; + } + if (savedCardAmountNum > totalDue) { + toast.error(`Amount cannot exceed ${formatCurrency(totalDue)}`); + return; + } // totalDue can be £0 (fully discounted) and the loyalty discount is // applied on top — the effective charge could otherwise be 0 or negative. @@ -1446,10 +1539,40 @@ {/if} +
+ +
+ £ + +
+ {#if cardValidationError} +

{cardValidationError}

+ {/if} +

+ Leave blank to charge full amount ({formatCurrency(totalDue)}) +

+
+
-
@@ -1710,14 +1833,46 @@ }} /> + {#if selectedSavedCardId} +
+ +
+ £ + +
+ {#if savedCardValidationError} +

{savedCardValidationError}

+ {/if} +

+ Leave blank to charge full amount ({formatCurrency(totalDue)}) +

+
+ {/if} +