diff --git a/frontend/src/lib/components/admin/TillPurchases.svelte b/frontend/src/lib/components/admin/TillPurchases.svelte index 7e9d54d..567a58a 100644 --- a/frontend/src/lib/components/admin/TillPurchases.svelte +++ b/frontend/src/lib/components/admin/TillPurchases.svelte @@ -16,12 +16,13 @@ qty: number; }; - type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square'; + type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square' | 'saved_card'; const PAYMENT_METHODS: Array<{ key: TillPaymentMethod; label: string }> = [ { key: 'cash', label: 'Cash' }, { key: 'card_machine', label: 'Card Machine' }, - { key: 'online_square', label: 'Online Card' } + { key: 'online_square', label: 'Online Card' }, + { key: 'saved_card', label: 'Saved Card' } ]; let cart = $state([]); @@ -37,6 +38,144 @@ // async, so `processing` may not reach the button before a fast second click. let isProcessingPaymentSync = false; + // Idempotency keys are cached per cart line (item id, quantity index, price, + // payment method) so a lost-response retry of the SAME cart reuses the keys: + // the backend re-attempts the charge with the stored key, Square dedups, and + // the customer is not charged twice. A changed cart/amount/payment method + // yields a different composite key, so genuinely new sales get fresh keys. + // Mirrors the BookingFlow/PaymentModal/TipPayment per-charge caching pattern. + let idempotencyKeys = new Map(); + + function idempotencyKeyFor(item: CartItem, qtyIndex: number): string { + // saved_card charges also key on the selected card id so switching to a + // different card (or back to another method) yields fresh keys. + const composite = `${item.id}:${qtyIndex}:${item.price}:${paymentMethod}:${paymentMethod === 'saved_card' ? (selectedSavedCardId ?? '') : ''}`; + let key = idempotencyKeys.get(composite); + if (!key) { + key = generateUUID(); + idempotencyKeys.set(composite, key); + } + return key; + } + + // ---------- Customer picker (saved-card payments) ---------- + type TillCustomer = { + id: string; + name: string; + email?: string; + }; + + // Fields mirror the backend SavedCard shape (payment-methods endpoint). + type SavedCard = { + id: string; + brand: string; + last_4: string; + exp_month: number; + exp_year: number; + cardholder_name?: string; + }; + + let customerQuery = $state(''); + let customerResults = $state([]); + let loadingCustomers = $state(false); + let showCustomerResults = $state(false); + let selectedCustomer = $state(null); + let savedCards = $state([]); + let loadingSavedCards = $state(false); + let savedCardsError = $state(null); + let selectedSavedCardId = $state(null); + + // Square convention: a card is valid through the end of its exp_month/exp_year. + const validCards = $derived( + savedCards.filter((card) => { + const now = new Date(); + return ( + card.exp_year > now.getFullYear() || + (card.exp_year === now.getFullYear() && card.exp_month >= now.getMonth() + 1) + ); + }) + ); + + // The saved-card option is hidden outright unless a customer is selected + // AND has at least one currently-valid card on file. + const showSavedCardOption = $derived(selectedCustomer !== null && validCards.length > 0); + + const availablePaymentMethods = $derived( + PAYMENT_METHODS.filter((m) => m.key !== 'saved_card' || showSavedCardOption) + ); + + // If the saved-card option disappears (customer cleared, no valid cards, or + // a card expires mid-session) fall back to cash instead of leaving the till + // on an unrenderable method. + $effect(() => { + if (paymentMethod === 'saved_card' && !showSavedCardOption) { + paymentMethod = 'cash'; + selectedSavedCardId = null; + } + }); + + async function searchCustomers() { + if (!customerQuery.trim()) return; + loadingCustomers = true; + try { + const res = await apiFetch( + `/api/admin/users?page=1&per_page=5&q=${encodeURIComponent(customerQuery.trim())}` + ); + if (res.ok) { + const data = await res.json(); + const excludedRoles = ['admin', 'guest', 'affiliate']; + customerResults = (data.users || []) + .filter((u: { account_role: string }) => !excludedRoles.includes(u.account_role)) + .map((u: { id: string; fullName: string; email?: string }) => ({ + id: u.id, + name: u.fullName || 'Customer', + email: u.email + })); + showCustomerResults = true; + } + } catch { + toast.error('Failed to search customers'); + } finally { + loadingCustomers = false; + } + } + + function selectCustomer(customer: TillCustomer) { + selectedCustomer = customer; + customerQuery = ''; + customerResults = []; + showCustomerResults = false; + fetchSavedCards(customer.id); + } + + function clearSelectedCustomer() { + selectedCustomer = null; + savedCards = []; + savedCardsError = null; + selectedSavedCardId = null; + customerResults = []; + showCustomerResults = false; + } + + async function fetchSavedCards(userId: string) { + loadingSavedCards = true; + savedCards = []; + savedCardsError = null; + selectedSavedCardId = null; + try { + const res = await apiFetch(`/api/admin/users/${userId}/payment-methods`); + if (res.ok) { + savedCards = await res.json(); + } else { + savedCardsError = 'Failed to load saved cards'; + } + } catch { + savedCardsError = 'Failed to load saved cards'; + } finally { + loadingSavedCards = false; + } + } + const subtotal = $derived(cart.reduce((sum, item) => sum + item.price * item.qty, 0)); const itemCount = $derived(cart.reduce((sum, item) => sum + item.qty, 0)); @@ -105,7 +244,13 @@ return; } if (hasRetailItems) { - toast.error('Retail items cannot be charged yet — the till API currently supports gift card sales only'); + toast.error( + 'Retail items cannot be charged yet — the till API currently supports gift card sales only' + ); + return; + } + if (paymentMethod === 'saved_card' && (!selectedCustomer || !selectedSavedCardId)) { + toast.error('Select a customer and a saved card before charging'); return; } isProcessingPaymentSync = true; @@ -122,9 +267,12 @@ action: 'create', amount: item.price, payment_method: paymentMethod, - idempotency_key: generateUUID() + idempotency_key: idempotencyKeyFor(item, i) }; - if (paymentMethod === 'online_square') { + if (paymentMethod === 'saved_card') { + body.user_id = selectedCustomer?.id; + body.user_saved_card_id = selectedSavedCardId; + } else if (paymentMethod === 'online_square') { if (!onlineSquareCardInput) { throw new Error('Card form is not ready — please wait a moment and try again'); } @@ -159,6 +307,7 @@ toast.success('Sale complete'); cart = []; + idempotencyKeys.clear(); } catch (err) { const msg = err instanceof Error ? err.message : 'Sale failed'; paymentError = msg; @@ -239,8 +388,12 @@ }} /> - Add {:else} @@ -257,6 +410,114 @@ +
+ Customer (saved card payments) + {#if selectedCustomer} +
+
+

{selectedCustomer.name}

+ {#if selectedCustomer.email} +

{selectedCustomer.email}

+ {/if} + {#if savedCardsError} +

{savedCardsError}

+ {/if} +
+
+ {#if loadingSavedCards} + Loading cards... + {:else if !savedCardsError} + + {validCards.length} valid card{validCards.length === 1 ? '' : 's'} + + {/if} + +
+
+ {:else} +
+
+
+ { + if (e.key === 'Enter') { + e.preventDefault(); + searchCustomers(); + } else if (e.key === 'Escape') { + showCustomerResults = false; + } + }} + onfocus={() => (showCustomerResults = true)} + onblur={() => (showCustomerResults = false)} + /> +
+ +
+ {#if showCustomerResults} +
+ {#if loadingCustomers} +
+
+
+ {:else if customerResults.length === 0} +
+ {customerQuery.trim() + ? 'No customers found.' + : 'Type a name, email or phone to search.'} +
+ {:else} +
    + {#each customerResults as user (user.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ {/if} +
+ {/if} +
+
@@ -330,8 +591,12 @@ Payment Method -
- {#each PAYMENT_METHODS as m (m.key)} +
+ {#each availablePaymentMethods as m (m.key)} + {/each} +
+ +
+ + + + + +

+ This card may require bank app confirmation to complete. Ensure the customer has + their phone ready. +

+
+ {/if} +
+ {/if} + {#if hasRetailItems}

Retail items can't be charged yet — the till API currently supports gift card sales @@ -380,15 +714,17 @@ class="mt-3 w-full" onclick={chargeCart} loading={processing} - disabled={ - !canCharge || processing || (paymentMethod === 'online_square' && !onlineSquareCardReady) - } + disabled={!canCharge || + processing || + (paymentMethod === 'online_square' && !onlineSquareCardReady) || + (paymentMethod === 'saved_card' && !selectedSavedCardId)} > {processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}

Secure payment powered by Square

- Gift card sales are processed through the till; retail items require manual recording for now. + Gift card sales are processed through the till; retail items require manual recording for + now.

{/if}