From bb51c1512fd3698294c42dd806d48385f140072b Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 20 Aug 2026 14:20:39 +0100 Subject: [PATCH] feat: add amount input fields for card and saved card payments PaymentModal now allows partial payments for card and saved card: - Added amount input fields with validation (max 2 decimal places) - Default to full amount when left blank - Validate amount is > 0 and <= totalDue - Button label updates to show charge amount - Validation errors shown inline UserPaymentModal already had partial payment functionality, so no changes needed there. This allows customers to pay part of the balance now and pay the rest later online. --- .../components/payments/PaymentModal.svelte | 167 +++++++++++++++++- 1 file changed, 161 insertions(+), 6 deletions(-) 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} +