Close refund system and gate raw-PAN card entry

Refund system (Round 3 fixes + follow-up + alignment):
- Serialize cancellation refunds against the manual handler via
  per-payment advisory locks taken before the prior-refunds read
  (pg_advisory_xact_lock, ascending, same crussell:refund: key space)
- Aggregate pending cancellation refunds into ONE Square refund per
  charge (stable charge-level -square-agg key); atomic group UPDATE
  keeps crash-retry amounts identical for Square key-dedup
- Persist paymentID-square-amount idempotency keys on cancellation
  refunds; scheduler reads the stored key (legacy fallback for old rows)
- Add sweep-pending-square-refunds cron (*/5, concurrency 1) with
  refund_attempts cap; sweep retries stale manual pending refunds with
  each row's own stored idempotency key
- Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every
  terminal failed transition: tri-state result leaves rows pending on
  reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED
  resolves to completed
- Move over-refund guard inside the lock, counting completed + pending
  (excluding failed); ErrRefundDeclined distinguishes definitive vs
  ambiguous outcomes
- forgiveFees now executes a real full refund (forceFullRefund override)
  with admin_forgiven_fees reason threaded to Square
- Surface failed card refunds in the admin notification centre
  (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup)
- Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key)
  DO NOTHING without consuming refundRemaining

Frontend:
- Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token
  in request bodies; gate new-card entry behind CardEntryUnavailable
  notice + newCardDisabled prop across all 8 flows
- Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI
  and CardEntryUnavailable fallback
- Update cancellation-policy page to in-person cash pickup wording

Tests:
- Rewrite the two amount-blind dedup tests to assert real money movement
  (single call, aggregated amount, shared refund ID)
- Add coverage: manual refund vs cancellation serialization (concurrent
  goroutines), reconcile error vs no-match branches, stale manual retry,
  forgive-fees real refund row + reason, double-cancel dedup, mock refund
  key dedup, ListPaymentRefunds filtering
- Fix time-dependent booking flakes with fixtures.NextWorkingDayAt
- 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 54f6bf3c1a
commit ae8735ba2f
33 changed files with 4129 additions and 1868 deletions
@@ -15,8 +15,8 @@
import { computeBalanceDue } from '$lib/utils/booking';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
import CardInput from '$lib/components/payments/CardInput.svelte';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
interface Props {
open: boolean;
@@ -149,106 +149,12 @@
let tipSavedCards = $state<SavedCard[]>([]);
let tipLoadingCards = $state(false);
let tipSelectedCardId = $state<string | null>(null);
let tipShowNewCard = $state(false);
// New card form state for tips
let tipNewCardNumber = $state('');
let tipNewCardExpiry = $state('');
let tipNewCardCVC = $state('');
let tipSaveCardFuture = $state(false);
let tipCardNumberTouched = $state(false);
let tipCardExpiryTouched = $state(false);
let tipCVCTouched = $state(false);
const canSaveCards = $derived(
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
);
// Card validation (matching UserPaymentModal pattern)
function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
function handleTipFieldBlur(field: string) {
if (field === 'cardNumber') tipCardNumberTouched = true;
else if (field === 'cardExpiry') tipCardExpiryTouched = true;
else if (field === 'cardCVC') tipCVCTouched = true;
}
function handleTipFieldInput(field: string) {
if (field === 'cardNumber') tipCardNumberTouched = false;
else if (field === 'cardExpiry') tipCardExpiryTouched = false;
else if (field === 'cardCVC') tipCVCTouched = false;
}
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
const tipNewCardExpiryParts = $derived(parseExpiryParts(tipNewCardExpiry));
const isTipNewCardExpiryPast = $derived(
tipNewCardExpiryParts !== null &&
(() => {
const expiryYearMonth = tipNewCardExpiryParts.year * 12 + tipNewCardExpiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);
const hasTipNewCardInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) && tipNewCardExpiryParts === null
);
const tipNewCardError = $derived(
tipShowNewCard || tipSavedCards.length === 0
? tipCardNumberTouched && !isValidLuhn(tipNewCardNumber) && tipNewCardNumber.length > 0
? 'Invalid card number'
: tipCardExpiryTouched && hasTipNewCardInvalidMonth
? 'Invalid expiry month'
: tipCardExpiryTouched && isTipNewCardExpiryPast
? 'This card has expired'
: tipCardExpiryTouched &&
tipNewCardExpiry.length > 0 &&
!/^\d{2}\/\d{2}$/.test(tipNewCardExpiry)
? 'Enter expiry as MM/YY'
: tipCVCTouched && tipNewCardCVC.length < 3 && tipNewCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(tipNewCardNumber) &&
/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) &&
tipNewCardCVC.length >= 3
? null
: tipNewCardNumber.length === 0 &&
tipNewCardExpiry.length === 0 &&
tipNewCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
);
const isTipCardValid = $derived(
tipSelectedCardId !== null ||
(isValidLuhn(tipNewCardNumber) &&
tipNewCardExpiryParts !== null &&
!isTipNewCardExpiryPast &&
tipNewCardCVC.length >= 3)
);
const isTipCardValid = $derived(tipSelectedCardId !== null);
const tipPresets = $derived(
selectedBooking
@@ -314,31 +220,17 @@
return;
}
if (tipSavedCards.length > 0 && !tipSelectedCardId && !tipShowNewCard) {
toast.error('Please select a payment method');
if (tipSavedCards.length === 0) {
toast.error('Please add a saved card or contact the salon to pay by another method');
return;
}
if ((tipShowNewCard || tipSavedCards.length === 0) && !tipNewCardNumber.replace(/\s/g, '')) {
toast.error('Please enter your card number');
if (!tipSelectedCardId) {
toast.error('Please select a payment method');
return;
}
tipProcessing = true;
// Validate card details for new card payments
if (tipShowNewCard || tipSavedCards.length === 0) {
if (
!isValidLuhn(tipNewCardNumber) ||
!/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) ||
isTipNewCardExpiryPast ||
tipNewCardCVC.length < 3
) {
tipProcessing = false;
toast.error(tipNewCardError || 'Please enter valid credit card details');
return;
}
}
try {
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
tipIdempotencyKey = crypto.randomUUID();
@@ -346,16 +238,10 @@
}
const body: Record<string, unknown> = {
amount: Math.round(tipAmount * 100),
idempotency_key: tipIdempotencyKey
idempotency_key: tipIdempotencyKey,
card_id: tipSelectedCardId
};
if (tipShowNewCard || tipSavedCards.length === 0) {
body.new_card_token = tipNewCardNumber.replace(/\s/g, '');
body.save_card = tipSaveCardFuture;
} else {
body.card_id = tipSelectedCardId;
}
const response = await apiFetch(`/api/bookings/${selectedBooking.id}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1264,13 +1150,10 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipSelectedCardId ===
card.id && !tipShowNewCard
card.id
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
tipSelectedCardId = card.id;
tipShowNewCard = false;
}}
onclick={() => (tipSelectedCardId = card.id)}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
@@ -1281,66 +1164,16 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
>
</div>
</div>
{#if tipSelectedCardId === card.id && !tipShowNewCard}
{#if tipSelectedCardId === card.id}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipShowNewCard
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
tipSelectedCardId = null;
tipShowNewCard = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
</div>
{#if tipShowNewCard}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{#if tipShowNewCard}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={tipNewCardNumber}
bind:cardExpiry={tipNewCardExpiry}
bind:cardCVC={tipNewCardCVC}
bind:saveCard={tipSaveCardFuture}
showSaveCard={canSaveCards}
onfieldblur={handleTipFieldBlur}
onfieldinput={handleTipFieldInput}
/>
{#if tipNewCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{tipNewCardError}</div>
{/if}
</div>
{/if}
{:else}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={tipNewCardNumber}
bind:cardExpiry={tipNewCardExpiry}
bind:cardCVC={tipNewCardCVC}
bind:saveCard={tipSaveCardFuture}
showSaveCard={canSaveCards}
onfieldblur={handleTipFieldBlur}
onfieldinput={handleTipFieldInput}
/>
{#if tipNewCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{tipNewCardError}</div>
{/if}
</div>
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
/>
{/if}
</div>
</div>
@@ -9,6 +9,7 @@
import { EmailInput } from '$lib/components/ui/email-input';
import * as Modal from '$lib/components/ui/dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import { range } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
@@ -107,7 +108,6 @@
| 'amount_email'
| 'payment'
| 'cash_entry'
| 'card_details'
| 'processing'
| 'success'
| 'error'
@@ -142,9 +142,6 @@
// Payment Processing States
let cashAmount = $state('');
let ephemeralCardNumber = $state('');
let ephemeralCardExpiry = $state('');
let ephemeralCardCVC = $state('');
let paymentError = $state('');
let paymentResult = $state<{
id: string;
@@ -180,14 +177,7 @@
}
let topUpStep = $state<
| 'choice'
| 'amount'
| 'payment'
| 'cash_entry'
| 'card_details'
| 'processing'
| 'success'
| 'error'
'choice' | 'amount' | 'payment' | 'cash_entry' | 'processing' | 'success' | 'error'
>('choice');
let topUpMode = $state<'giveaway' | 'purchase'>('giveaway');
@@ -469,9 +459,6 @@
// Reset payment
cashAmount = '';
ephemeralCardNumber = '';
ephemeralCardExpiry = '';
ephemeralCardCVC = '';
paymentError = '';
paymentResult = null;
cardMachineItemID = null;
@@ -480,38 +467,9 @@
// =============== Embedded Payment Handlers ===============
const isEphemeralCardValid = $derived(
ephemeralCardNumber.replace(/\s/g, '').length >= 13 &&
ephemeralCardExpiry.includes('/') &&
ephemeralCardExpiry.length === 5 &&
ephemeralCardCVC.length >= 3
);
function handleEphemeralCardNumberInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
const clean = target.value.replace(/\D/g, '');
const formatted = clean.match(/.{1,4}/g)?.join(' ') || clean;
ephemeralCardNumber = formatted.slice(0, 19);
}
function handleEphemeralExpiryInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
const clean = target.value.replace(/\D/g, '');
if (clean.length > 2) {
ephemeralCardExpiry = clean.slice(0, 2) + '/' + clean.slice(2, 4);
} else {
ephemeralCardExpiry = clean;
}
}
function handleEphemeralCvcInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
ephemeralCardCVC = target.value.replace(/\D/g, '').slice(0, 4);
}
function setModalStep(
actionType: 'create' | 'topup',
step: 'processing' | 'success' | 'error' | 'payment' | 'cash_entry' | 'card_details'
step: 'processing' | 'success' | 'error' | 'payment' | 'cash_entry'
) {
if (actionType === 'create') {
generateStep = step;
@@ -636,54 +594,6 @@
setModalStep(actionType, 'error');
}
async function handleEmbeddedEphemeralCardPayment(actionType: 'create' | 'topup', gcId?: string) {
const cardNum = ephemeralCardNumber.replace(/\s/g, '');
const [monthStr, yearStr] = ephemeralCardExpiry.split('/');
const expMonth = parseInt(monthStr, 10);
const expYear = 2000 + parseInt(yearStr, 10);
setModalStep(actionType, 'processing');
processingMessage = 'Processing card payment...';
try {
const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount);
const body: Record<string, unknown> = {
item_type: 'gift_card',
action: actionType,
amount: amt,
payment_method: 'online_square',
idempotency_key: getIdempotencyKey(),
card_number: cardNum,
card_exp_month: expMonth,
card_exp_year: expYear,
card_cvc: ephemeralCardCVC
};
if (gcId) body.gift_card_id = gcId;
if (selectedCustomer) body.user_id = selectedCustomer.id;
if (actionType === 'create' && generateType === 'account' && selectedCustomer)
body.redeem_to_user_id = selectedCustomer.id;
const res = await apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (res.ok) {
const data = await res.json();
paymentResult = { ...data };
setModalStep(actionType, 'success');
await fetchGiftCards();
} else {
paymentError = await res.text();
setModalStep(actionType, 'error');
}
} catch {
paymentError = 'Network error processing card payment';
setModalStep(actionType, 'error');
}
}
async function handleEmbeddedGiveawayTopUp(gcId: string) {
topUpStep = 'processing';
processingMessage = 'Processing on-the-house top-up...';
@@ -1495,8 +1405,6 @@
Select the payment method.
{:else if generateStep === 'cash_entry'}
Enter cash amount received.
{:else if generateStep === 'card_details'}
Enter card payment details.
{/if}
</Modal.Description>
</Modal.Header>
@@ -1902,25 +1810,11 @@
</svg>
Cash
</button>
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 sm:col-span-2"
onclick={() => (generateStep = 'card_details')}
>
<svg
class="mx-auto mb-2 h-8 w-8 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
<path d="M1 14h22" />
<circle cx="7" cy="18" r="1.5" />
</svg>
Card Details
</button>
<div class="sm:col-span-2">
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please take payment by card machine or cash."
/>
</div>
</div>
</div>
<Modal.Footer>
@@ -1964,68 +1858,6 @@
Confirm Cash
</Button>
</Modal.Footer>
{:else if generateStep === 'card_details'}
<div class="space-y-4 py-4">
<div class="space-y-3 rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="text-sm font-medium text-gray-700">Online Card Processing</h4>
<div class="space-y-3">
<div>
<label for="generate-card-number" class="text-sm font-medium text-gray-700"
>Card Number</label
>
<Input
id="generate-card-number"
type="text"
inputmode="numeric"
value={ephemeralCardNumber}
oninput={handleEphemeralCardNumberInput}
placeholder="1234 5678 9012 3456"
maxlength={19}
class="mt-1"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="generate-card-expiry" class="text-sm font-medium text-gray-700"
>Expiry (MM/YY)</label
>
<Input
id="generate-card-expiry"
type="text"
inputmode="numeric"
value={ephemeralCardExpiry}
oninput={handleEphemeralExpiryInput}
placeholder="MM/YY"
maxlength={5}
class="mt-1"
/>
</div>
<div>
<label for="generate-card-cvc" class="text-sm font-medium text-gray-700">CVC</label>
<Input
id="generate-card-cvc"
type="text"
inputmode="numeric"
value={ephemeralCardCVC}
oninput={handleEphemeralCvcInput}
placeholder="123"
maxlength={4}
class="mt-1"
/>
</div>
</div>
</div>
</div>
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (generateStep = 'payment')}>Back</Button>
<Button
disabled={!isEphemeralCardValid}
onclick={() => handleEmbeddedEphemeralCardPayment('create')}
>
Pay {formatCurrency(Number(generateAmount))}
</Button>
</Modal.Footer>
{:else if generateStep === 'processing'}
<div class="flex flex-col items-center justify-center py-8">
<div
@@ -2113,8 +1945,6 @@
Select the payment method.
{:else if topUpStep === 'cash_entry'}
Enter cash amount received.
{:else if topUpStep === 'card_details'}
Enter card payment details.
{/if}
</Modal.Description>
</Modal.Header>
@@ -2219,25 +2049,11 @@
</svg>
Cash
</button>
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 sm:col-span-2"
onclick={() => (topUpStep = 'card_details')}
>
<svg
class="mx-auto mb-2 h-8 w-8 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
<path d="M1 14h22" />
<circle cx="7" cy="18" r="1.5" />
</svg>
Card Details
</button>
<div class="sm:col-span-2">
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please take payment by card machine or cash."
/>
</div>
</div>
</div>
<Modal.Footer>
@@ -2279,68 +2095,6 @@
Confirm Cash
</Button>
</Modal.Footer>
{:else if topUpStep === 'card_details'}
<div class="space-y-4 py-4">
<div class="space-y-3 rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="text-sm font-medium text-gray-700">Online Card Processing</h4>
<div class="space-y-3">
<div>
<label for="topup-card-number" class="text-sm font-medium text-gray-700"
>Card Number</label
>
<Input
id="topup-card-number"
type="text"
inputmode="numeric"
value={ephemeralCardNumber}
oninput={handleEphemeralCardNumberInput}
placeholder="1234 5678 9012 3456"
maxlength={19}
class="mt-1"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="topup-card-expiry" class="text-sm font-medium text-gray-700"
>Expiry (MM/YY)</label
>
<Input
id="topup-card-expiry"
type="text"
inputmode="numeric"
value={ephemeralCardExpiry}
oninput={handleEphemeralExpiryInput}
placeholder="MM/YY"
maxlength={5}
class="mt-1"
/>
</div>
<div>
<label for="topup-card-cvc" class="text-sm font-medium text-gray-700">CVC</label>
<Input
id="topup-card-cvc"
type="text"
inputmode="numeric"
value={ephemeralCardCVC}
oninput={handleEphemeralCvcInput}
placeholder="123"
maxlength={4}
class="mt-1"
/>
</div>
</div>
</div>
</div>
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (topUpStep = 'payment')}>Back</Button>
<Button
disabled={!isEphemeralCardValid}
onclick={() => handleEmbeddedEphemeralCardPayment('topup', selectedCardId ?? undefined)}
>
Pay {formatCurrency(Number(topUpAmount))}
</Button>
</Modal.Footer>
{:else if topUpStep === 'processing'}
<div class="flex flex-col items-center justify-center py-8">
<div
@@ -36,7 +36,7 @@
import DatePicker from '$lib/components/booking/DatePicker.svelte';
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
import CardInput from '$lib/components/payments/CardInput.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
@@ -87,7 +87,6 @@
>([]);
let paymentMethodsLoading = $state(false);
let selectedPaymentMethod = $state<string | null>(null);
let showNewCardForm = $state(false);
let isProcessingPayment = $state(false);
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
// on the next microtask), so `isProcessingPayment` may not propagate to the
@@ -95,76 +94,10 @@
// reactive flag is checked synchronously at the start of processPayment.
let isProcessingPaymentSync = false;
// New card form fields
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let cardNumberTouched = $state(false);
let cardExpiryTouched = $state(false);
let cardCVCTouched = $state(false);
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
const expiryParts = $derived(parseExpiryParts(newCardExpiry));
// Payment flow state
let depositPaid = $state(false);
const cardError = $derived(
cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched &&
expiryParts !== null &&
(() => {
const em = expiryParts.year * 12 + expiryParts.month;
const now2 = new SvelteDate();
const cm = now2.getFullYear() * 12 + now2.getMonth() + 1;
return em < cm;
})()
? 'This card has expired'
: cardExpiryTouched && !/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiry.length > 0
? 'Enter expiry as MM/YY'
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(newCardNumber) &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3
? null
: newCardNumber.length === 0 && newCardExpiry.length === 0 && newCardCVC.length === 0
? null
: 'Please complete all card fields'
);
const depositCardFormValid = $derived(
selectedPaymentMethod !== null ||
(showNewCardForm &&
newCardNumber.replace(/\s/g, '').length >= 13 &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3)
);
const depositCardFormValid = $derived(selectedPaymentMethod !== null);
// VAT registration status from public business info (via shared store)
const vatRegistered = $derived(getBusinessInfo()?.is_vat_registered ?? false);
@@ -325,18 +258,6 @@
}
}
function handleFieldBlur(field: string) {
if (field === 'cardNumber') cardNumberTouched = true;
else if (field === 'cardExpiry') cardExpiryTouched = true;
else if (field === 'cardCVC') cardCVCTouched = true;
}
function handleFieldInput(field: string) {
if (field === 'cardNumber') cardNumberTouched = false;
else if (field === 'cardExpiry') cardExpiryTouched = false;
else if (field === 'cardCVC') cardCVCTouched = false;
}
async function processPayment(amount: number) {
// Synchronous double-click guard — set BEFORE any await so a rapid second
// click is rejected immediately, even before the reactive `disabled` has
@@ -346,6 +267,10 @@
isProcessingPayment = true;
paymentAttempted = false;
try {
if (!selectedPaymentMethod) {
toast.error('Please select a saved card');
return;
}
await submitAndProceed();
if (!confirmedBooking) {
toast.error('Booking was not created. Please try again.');
@@ -356,7 +281,7 @@
// Cache the idempotency key per amount+card so a lost-response retry
// reuses it (backend dedups) instead of double-charging.
const cardKey = selectedPaymentMethod ?? newCardNumber.replace(/\s/g, '') ?? '';
const cardKey = selectedPaymentMethod;
if (
!depositIdempotencyKey ||
depositKeyedAmount !== amountCents ||
@@ -370,18 +295,10 @@
const body: Record<string, unknown> = {
payment_type: 'deposit',
amount: amountCents,
idempotency_key: depositIdempotencyKey
idempotency_key: depositIdempotencyKey,
card_id: selectedPaymentMethod
};
if (selectedPaymentMethod) {
body.card_id = selectedPaymentMethod;
} else {
const rawNumber = newCardNumber.replace(/\s/g, '');
if (rawNumber.length >= 13) {
body.new_card_token = rawNumber;
}
}
paymentAttempted = true;
const response = await apiFetch(`/api/bookings/${bookingId}/payment`, {
@@ -2345,7 +2262,7 @@
{#if authStore.isAuthenticated}
{#if paymentMethodsLoading}
<div class="py-4 text-center text-gray-500">Loading payment methods...</div>
<div class="mb-6 py-4 text-center text-gray-500">Loading payment methods...</div>
{:else if paymentMethods.length > 0}
<div class="mb-6">
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
@@ -2373,10 +2290,7 @@
<Button
size="sm"
variant={selectedPaymentMethod === method.id ? 'default' : 'outline'}
onclick={() => {
selectedPaymentMethod = method.id;
showNewCardForm = false;
}}
onclick={() => (selectedPaymentMethod = method.id)}
>
{selectedPaymentMethod === method.id ? 'Selected' : 'Use this card'}
</Button>
@@ -2384,34 +2298,19 @@
{/each}
</div>
</div>
{:else}
<div class="mb-6">
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
/>
</div>
{/if}
{#if !showNewCardForm}
<Button
variant="outline"
class="mb-6"
onclick={() => {
showNewCardForm = true;
selectedPaymentMethod = null;
}}
>
+ Add new card
</Button>
{/if}
{/if}
{#if showNewCardForm || !authStore.isAuthenticated}
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
disabled={isProcessingPayment}
onfieldblur={handleFieldBlur}
onfieldinput={handleFieldInput}
/>
{#if cardError}
<p class="mt-2 text-sm text-red-600">{cardError}</p>
{/if}
{:else}
<div class="mb-6">
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please contact the salon to pay by another method."
/>
</div>
{/if}
<div class="flex items-center justify-between border-t pt-4">
@@ -0,0 +1,21 @@
<script lang="ts">
import { NEW_CARD_ENTRY_UNAVAILABLE_MESSAGE } from '$lib/constants/payments';
let { message = NEW_CARD_ENTRY_UNAVAILABLE_MESSAGE }: { message?: string } = $props();
</script>
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<div class="flex items-start gap-3">
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<p class="text-sm text-gray-600">{message}</p>
</div>
</div>
@@ -1,101 +0,0 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
let {
cardNumber = $bindable(''),
cardExpiry = $bindable(''),
cardCVC = $bindable(''),
saveCard = $bindable(false),
showSaveCard = false,
disabled = false,
onfieldblur = (_field: string) => {},
onfieldinput = (_field: string) => {}
}: {
cardNumber?: string;
cardExpiry?: string;
cardCVC?: string;
saveCard?: boolean;
showSaveCard?: boolean;
disabled?: boolean;
onfieldblur?: (field: string) => void;
onfieldinput?: (field: string) => void;
} = $props();
function formatNumber(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 16);
const groups = digits.match(/.{1,4}/g);
return groups ? groups.join(' ') : digits;
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 4);
if (digits.length >= 3) {
return digits.substring(0, 2) + '/' + digits.substring(2);
}
return digits;
}
</script>
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="mb-4 text-sm font-medium text-gray-700">Card Details</h4>
<div class="space-y-4">
<div class="space-y-2">
<Label for="cardNumber">Card Number</Label>
<Input
id="cardNumber"
type="text"
inputmode="numeric"
value={cardNumber}
oninput={(e) => {
cardNumber = formatNumber((e.target as HTMLInputElement).value);
onfieldinput('cardNumber');
}}
onblur={() => onfieldblur('cardNumber')}
placeholder="1234 5678 9012 3456"
maxlength={19}
{disabled}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="cardExpiry">Expiry (MM/YY)</Label>
<Input
id="cardExpiry"
type="text"
inputmode="numeric"
value={cardExpiry}
oninput={(e) => {
cardExpiry = formatExpiry((e.target as HTMLInputElement).value);
onfieldinput('cardExpiry');
}}
onblur={() => onfieldblur('cardExpiry')}
placeholder="MM/YY"
maxlength={5}
{disabled}
/>
</div>
<div class="space-y-2">
<Label for="cardCVC">CVC</Label>
<Input
id="cardCVC"
type="text"
inputmode="numeric"
bind:value={cardCVC}
oninput={() => onfieldinput('cardCVC')}
onblur={() => onfieldblur('cardCVC')}
placeholder="123"
maxlength={4}
{disabled}
/>
</div>
</div>
{#if showSaveCard}
<div class="flex items-center gap-2">
<Checkbox id="saveCard" bind:checked={saveCard} {disabled} />
<Label for="saveCard" class="text-sm font-normal">Save card for next time</Label>
</div>
{/if}
</div>
</div>
@@ -1,7 +1,6 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import CardInput from './CardInput.svelte';
import CardBrandIcon from './CardBrandIcon.svelte';
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
export interface SelectableCard {
id: string;
@@ -14,21 +13,15 @@
let {
cards = [],
canSaveCards = false,
canSaveCards: _canSaveCards = false,
newCardDisabled = false,
selectedCardId = $bindable(''),
newCardNumber = $bindable(''),
newCardExpiry = $bindable(''),
newCardCVC = $bindable(''),
saveCard = $bindable(false),
onValidityChange = (_valid: boolean) => {}
}: {
cards?: SelectableCard[];
canSaveCards?: boolean;
newCardDisabled?: boolean;
selectedCardId?: string;
newCardNumber?: string;
newCardExpiry?: string;
newCardCVC?: string;
saveCard?: boolean;
onValidityChange?: (valid: boolean) => void;
} = $props();
@@ -47,94 +40,8 @@
}
});
// Blur-based validation state (matches the pattern used across all card flows)
let cardNumberTouched = $state(false);
let cardExpiryTouched = $state(false);
let cardCVCTouched = $state(false);
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
const expiryParts = $derived(parseExpiryParts(newCardExpiry));
const isExpiryInPast = $derived(
expiryParts !== null &&
(() => {
const expiryYearMonth = expiryParts.year * 12 + expiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);
const hasInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null);
function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
function handleFieldBlur(field: string) {
if (field === 'cardNumber') cardNumberTouched = true;
else if (field === 'cardExpiry') cardExpiryTouched = true;
else if (field === 'cardCVC') cardCVCTouched = true;
}
function handleFieldInput(field: string) {
if (field === 'cardNumber') cardNumberTouched = false;
else if (field === 'cardExpiry') cardExpiryTouched = false;
else if (field === 'cardCVC') cardCVCTouched = false;
}
// Exposed derived state — same semantics as the other card flows.
const cardError = $derived(
showNewCardForm || cards.length === 0
? cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched && hasInvalidMonth
? 'Invalid expiry month'
: cardExpiryTouched && isExpiryInPast
? 'This card has expired'
: cardExpiryTouched && newCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(newCardExpiry)
? 'Enter expiry as MM/YY'
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(newCardNumber) &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3
? null
: newCardNumber.length === 0 &&
newCardExpiry.length === 0 &&
newCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
);
const isCardValid = $derived(
(selectedCardId !== '' && cards.length > 0) ||
(isValidLuhn(newCardNumber) &&
expiryParts !== null &&
!isExpiryInPast &&
newCardCVC.length >= 3)
);
const isCardValid = $derived(selectedCardId !== '' && cards.length > 0);
$effect(() => {
onValidityChange(isCardValid);
@@ -170,44 +77,35 @@
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = '';
showNewCardForm = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
{#if !newCardDisabled}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = '';
showNewCardForm = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
</div>
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
</div>
{#if showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{/if}
{#if showNewCardForm || cards.length === 0}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard
showSaveCard={canSaveCards}
onfieldblur={handleFieldBlur}
onfieldinput={handleFieldInput}
/>
{#if cardError}
<p class="mt-1 text-xs font-semibold text-red-500">{cardError}</p>
{#if showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/if}
</div>
{/if}
{#if newCardDisabled}
{#if cards.length === 0}
<CardEntryUnavailable />
{/if}
{/if}
@@ -54,12 +54,6 @@
let stamps = $state(0);
let useLoyalty = $state(false);
// New card form fields (bound into CardSelection)
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let saveCardForFuture = $state(false);
// Partial payment amount (in pounds, user enters)
let partialAmount = $state<string>('');
let lastValidPartialAmount = $state<string>('');
@@ -347,24 +341,19 @@
}
let cardId: string | undefined;
let newCardToken: string | undefined;
let saveCard = false;
if (selectedCardId) {
cardId = selectedCardId;
} else if (newCardNumber) {
newCardToken = newCardNumber;
saveCard = saveCardForFuture;
} else {
status = 'error';
error = 'Please select or enter card details';
toast.error('Please select or enter card details');
error = 'Please select a saved card';
toast.error('Please select a saved card');
return;
}
// Cache the idempotency key per amount+type+card so a lost-response
// retry reuses it (backend dedups) instead of double-charging.
const cardKey = cardId ?? newCardToken ?? '';
const cardKey = cardId ?? '';
if (
!payIdempotencyKey ||
payKeyedAmount !== amountCents ||
@@ -385,8 +374,6 @@
amount: amountCents,
payment_type: paymentType,
card_id: cardId,
new_card_token: newCardToken,
save_card: saveCard,
idempotency_key: payIdempotencyKey
})
});
@@ -686,11 +673,8 @@
<CardSelection
cards={paymentMethods}
{canSaveCards}
newCardDisabled
bind:selectedCardId
bind:newCardNumber
bind:newCardExpiry
bind:newCardCVC
bind:saveCard={saveCardForFuture}
onValidityChange={(v) => (cardSelectionValid = v)}
/>
{/if}
+3
View File
@@ -0,0 +1,3 @@
// Backend rejects raw PANs; Square token minting is deferred (P11).
export const NEW_CARD_ENTRY_UNAVAILABLE_MESSAGE =
'Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method.';
+26 -424
View File
@@ -11,6 +11,7 @@
import { isValidUKPhone, formatPhoneDisplay, toE164UK } from '$lib/utils/phone';
import { range } from '$lib/utils/format';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
// zxcvbn-ts imports
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
@@ -29,7 +30,6 @@
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { EmailInput } from '$lib/components/ui/email-input/index.js';
@@ -153,12 +153,6 @@
}
}
let showAddCard = $state(false);
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let addingCard = $state(false);
// =============== Gift Card State ===============
let giftCardBalance = $state(0);
let loadingBalance = $state(false);
@@ -172,11 +166,6 @@
let buyRecipientType = $state<'self' | 'friend'>('self');
let buyRecipientEmail = $state('');
let buySelectedCard = $state('');
let buyShowNewCard = $state(false);
let buyNewCardNumber = $state('');
let buyNewCardExpiry = $state('');
let buyNewCardCVC = $state('');
let buySaveCard = $state(false);
let buyingGiftCard = $state(false);
let purchaseResultCode = $state<string | null>(null);
@@ -188,90 +177,18 @@
let buyKeyedCard = $state('');
$effect(() => {
// Auto-select the default saved card only once, when cards first load.
// Do NOT re-select when the user explicitly chooses "Use a new card"
// (buySelectedCard === ''), otherwise the click is immediately overridden.
if (savedCardsStore.cards.length > 0 && !buySelectedCard && !buyShowNewCard) {
// Auto-select the default saved card when cards first load. A new card
// cannot be entered online right now (see CardEntryUnavailable), so this
// only ever needs to pick between saved cards.
if (savedCardsStore.cards.length > 0 && !buySelectedCard) {
const defaultCard =
savedCardsStore.cards.find((c) => c.is_default) || savedCardsStore.cards[0];
buySelectedCard = defaultCard.id;
}
});
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
// Derived validations for Add Saved Card form
const newCardExpiryParts = $derived(parseExpiryParts(newCardExpiry));
const isNewCardExpiryInPast = $derived(
newCardExpiryParts !== null &&
(() => {
const expiryYearMonth = newCardExpiryParts.year * 12 + newCardExpiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);
const isNewCardExpiryInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null
);
const addCardError = $derived(
newCardNumber.length > 0 && !isValidLuhn(newCardNumber)
? 'Invalid card number'
: isNewCardExpiryInvalidMonth
? 'Invalid expiry month'
: isNewCardExpiryInPast
? 'This card has already expired'
: newCardCVC.length > 0 && newCardCVC.length < 3
? 'CVC must be at least 3 digits'
: null
);
const isAddCardValid = $derived(
isValidLuhn(newCardNumber) && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3
);
// Derived validations for Buy Gift Card form
const buyNewCardExpiryParts = $derived(parseExpiryParts(buyNewCardExpiry));
const isBuyNewCardExpiryInPast = $derived(
buyNewCardExpiryParts !== null &&
(() => {
const expiryYearMonth = buyNewCardExpiryParts.year * 12 + buyNewCardExpiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);
const isBuyNewCardExpiryInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(buyNewCardExpiry) && buyNewCardExpiryParts === null
);
const buyCardError = $derived(
buyNewCardNumber.length > 0 && !isValidLuhn(buyNewCardNumber)
? 'Invalid card number'
: isBuyNewCardExpiryInvalidMonth
? 'Invalid expiry month'
: isBuyNewCardExpiryInPast
? 'This card has already expired'
: buyNewCardCVC.length > 0 && buyNewCardCVC.length < 3
? 'CVC must be at least 3 digits'
: null
);
const isBuyCardValid = $derived(
buySelectedCard !== '' ||
(isValidLuhn(buyNewCardNumber) &&
buyNewCardExpiryParts !== null &&
!isBuyNewCardExpiryInPast &&
buyNewCardCVC.length >= 3)
);
// Derived validation for Buy Gift Card form
const isBuyCardValid = $derived(buySelectedCard !== '');
async function fetchGiftCardBalance() {
loadingBalance = true;
@@ -318,36 +235,20 @@
}
async function buyGiftCard() {
if (!buySelectedCard) {
toast.error('Please select a saved card');
buyingGiftCard = false;
return;
}
buyingGiftCard = true;
try {
let cardId: string | undefined;
let newCardToken: string | undefined;
let saveCard = false;
if (buySelectedCard) {
cardId = buySelectedCard;
} else if (buyNewCardNumber) {
if (
!isValidLuhn(buyNewCardNumber) ||
!/^\d{2}\/\d{2}$/.test(buyNewCardExpiry) ||
buyNewCardCVC.length < 3
) {
toast.error('Please enter valid credit card details');
buyingGiftCard = false;
return;
}
newCardToken = buyNewCardNumber;
saveCard = buySaveCard;
} else {
toast.error('Please select or enter card details');
buyingGiftCard = false;
return;
}
const cardId = buySelectedCard;
// Cache the idempotency key per amount+card so a lost-response retry
// reuses the same key (backend dedups) instead of double-charging.
// Regenerate when the amount or card changes.
const cardKey = cardId ?? newCardToken ?? '';
const cardKey = cardId;
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
buyIdempotencyKey = generateIdempotencyKey();
buyKeyedAmount = buyAmount;
@@ -362,8 +263,6 @@
recipient_type: buyRecipientType,
recipient_email: buyRecipientEmail,
card_id: cardId,
new_card_token: newCardToken,
save_card: saveCard,
idempotency_key: buyIdempotencyKey
})
});
@@ -372,19 +271,10 @@
const data = await res.json();
toast.success('Gift card purchased successfully!');
purchaseResultCode = data.code;
buyNewCardNumber = '';
buyNewCardExpiry = '';
buyNewCardCVC = '';
buyIdempotencyKey = '';
buyKeyedAmount = 0;
buyKeyedCard = '';
await fetchGiftCardBalance();
if (buySelectedCard === '') {
await savedCardsStore.fetch();
// If the new card was saved, return to saved-card selection so the
// auto-select effect picks a default for the next purchase.
buyShowNewCard = false;
}
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to purchase gift card');
@@ -475,77 +365,6 @@
);
}
function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
function formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 16);
const groups = digits.match(/.{1,4}/g);
return groups ? groups.join(' ') : digits;
}
function formatExpiryDate(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 4);
if (digits.length >= 3) {
return digits.substring(0, 2) + '/' + digits.substring(2);
}
return digits;
}
function handleCardNumberInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatCardNumber);
newCardNumber = formatted;
}
function handleExpiryInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
newCardExpiry = formatted;
}
function handleCvcInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, (val) =>
val.replace(/\D/g, '').substring(0, 4)
);
newCardCVC = formatted;
}
function handleBuyCardNumberInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatCardNumber);
buyNewCardNumber = formatted;
}
// Uses custom formatter with MM/YY slash and preserves cursor position
function handleBuyExpiryInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
buyNewCardExpiry = formatted;
}
function handleBuyCvcInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, (val) =>
val.replace(/\D/g, '').substring(0, 4)
);
buyNewCardCVC = formatted;
}
function generateIdempotencyKey(): string {
const array = new Uint8Array(16);
if (typeof window !== 'undefined' && window.crypto) {
@@ -564,58 +383,6 @@
.join('');
}
async function addCard() {
if (
!isValidLuhn(newCardNumber) ||
!/^\d{2}\/\d{2}$/.test(newCardExpiry) ||
newCardCVC.length < 3
) {
toast.error('Please fill in all card details correctly');
return;
}
const cardNum = newCardNumber.replace(/\s/g, '');
const [monthStr, yearStr] = newCardExpiry.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) {
toast.error('Invalid expiry month');
return;
}
const expiryDate = new Date(year, month);
if (expiryDate < new Date()) {
toast.error('This card has already expired');
return;
}
addingCard = true;
try {
const res = await apiFetch('/api/user/payment-methods', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
card_number: cardNum,
expiry: newCardExpiry,
cvc: newCardCVC
})
});
if (res.ok) {
toast.success('Card added');
showAddCard = false;
newCardNumber = '';
newCardExpiry = '';
newCardCVC = '';
savedCardsStore.invalidate();
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to add card');
}
} catch (err) {
console.error('addCard error:', err);
toast.error('Network error');
} finally {
addingCard = false;
}
}
async function fetchNotifPrefs() {
try {
const res = await apiFetch('/api/user/notification-preferences');
@@ -2039,88 +1806,12 @@
<Skeleton class="h-16 w-full" />
<Skeleton class="h-16 w-full" />
</div>
{:else if showAddCard}
<div class="space-y-4">
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="mb-3 text-sm font-medium text-gray-700">Add New Card</h4>
<div class="space-y-3">
<div>
<label for="account-cardNumber" class="text-sm font-medium text-gray-700"
>Card Number</label
>
<Input
id="account-cardNumber"
type="text"
inputmode="numeric"
value={newCardNumber}
oninput={handleCardNumberInput}
placeholder="1234 5678 9012 3456"
maxlength={19}
class="mt-1"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="account-cardExpiry" class="text-sm font-medium text-gray-700"
>Expiry (MM/YY)</label
>
<Input
id="account-cardExpiry"
type="text"
inputmode="numeric"
value={newCardExpiry}
oninput={handleExpiryInput}
placeholder="MM/YY"
maxlength={5}
class="mt-1"
/>
</div>
<div>
<label for="account-cardCVC" class="text-sm font-medium text-gray-700"
>CVC</label
>
<Input
id="account-cardCVC"
type="text"
inputmode="numeric"
value={newCardCVC}
oninput={handleCvcInput}
placeholder="123"
maxlength={4}
class="mt-1"
/>
</div>
</div>
{#if addCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{addCardError}</div>
{/if}
</div>
</div>
<div class="flex gap-3">
<Button
variant="ghost"
onclick={() => {
showAddCard = false;
newCardNumber = '';
newCardExpiry = '';
newCardCVC = '';
}}
>
Cancel
</Button>
<Button
onclick={addCard}
loading={addingCard}
disabled={addingCard || !isAddCardValid}
>
Add Card
</Button>
</div>
</div>
{:else if savedCardsStore.cards.length === 0}
<div class="py-8 text-center">
<p class="text-gray-500">No saved cards yet</p>
<Button class="mt-4" onclick={() => (showAddCard = true)}>Add a Card</Button>
<div class="space-y-4 py-2">
<p class="text-center text-gray-500">No saved cards yet</p>
<CardEntryUnavailable
message="Online card entry is temporarily unavailable, so new cards cannot be added right now. Please contact the salon to pay by another method."
/>
</div>
{:else}
<div class="space-y-3">
@@ -2150,9 +1841,9 @@
</Button>
</div>
{/each}
<Button variant="outline" class="w-full" onclick={() => (showAddCard = true)}>
+ Add a Card
</Button>
<CardEntryUnavailable
message="Online card entry is temporarily unavailable, so new cards cannot be added right now. Please contact the salon to pay by another method."
/>
</div>
{/if}
</Card.Content>
@@ -2419,10 +2110,7 @@
card.id
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
buySelectedCard = card.id;
buyShowNewCard = false;
}}
onclick={() => (buySelectedCard = card.id)}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
@@ -2438,95 +2126,9 @@
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard ===
''
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
buySelectedCard = '';
buyShowNewCard = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700"
>Use a new card</span
>
</div>
{#if buySelectedCard === ''}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{/if}
{#if buySelectedCard === ''}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<div>
<label for="buy-card-num" class="text-xs font-medium text-gray-600"
>Card Number</label
>
<Input
id="buy-card-num"
type="text"
inputmode="numeric"
placeholder="1234 5678 9012 3456"
value={buyNewCardNumber}
oninput={handleBuyCardNumberInput}
maxlength={19}
class="mt-1 h-8 bg-white text-xs"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="buy-card-exp" class="text-xs font-medium text-gray-600"
>Expiry (MM/YY)</label
>
<Input
id="buy-card-exp"
type="text"
inputmode="numeric"
placeholder="MM/YY"
value={buyNewCardExpiry}
oninput={handleBuyExpiryInput}
maxlength={5}
class="mt-1 h-8 bg-white text-xs"
/>
</div>
<div>
<label for="buy-card-cvc" class="text-xs font-medium text-gray-600"
>CVC</label
>
<Input
id="buy-card-cvc"
type="text"
inputmode="numeric"
placeholder="123"
value={buyNewCardCVC}
oninput={handleBuyCvcInput}
maxlength={4}
class="mt-1 h-8 bg-white text-xs"
/>
</div>
</div>
{#if buyCardError}
<div class="mt-1 text-[10px] font-semibold text-red-500">
{buyCardError}
</div>
{/if}
<div class="flex items-center gap-2 pt-1">
<Checkbox id="buy-save-card" bind:checked={buySaveCard} />
<label for="buy-save-card" class="text-[10px] text-gray-500"
>Save card for future purchases</label
>
</div>
</div>
{:else}
<CardEntryUnavailable />
{/if}
</div>
@@ -109,7 +109,8 @@
deposit_not_paid_by_deadline: 'Deposit Deadline Passed',
affiliate_claim: 'Affiliate Referral Claimed',
'1_month_no_pay': 'No Payments in 1 Month',
'1_week_no_pay': 'No Payments in 1 Week'
'1_week_no_pay': 'No Payments in 1 Week',
refund_failed: 'Card refund failed — arrange in-person pickup'
};
function hasAction(reason: string): string | null {
@@ -125,6 +126,7 @@
case 'deposit_not_paid_by_deadline':
case '1_week_no_pay':
case '1_month_no_pay':
case 'refund_failed':
return 'see_user';
default:
return null;
@@ -156,8 +156,9 @@
</ul>
<p class="mb-4 text-xs leading-relaxed text-gray-500 italic">
If a card refund cannot be processed (e.g. the card is expired or the Square payment
reference is unavailable), the refund amount will be credited to your account balance as a
fallback, so you are never left out of pocket.
reference is unavailable), we will notify you via your account and arrange collection of the
refund in person at the salon — please allow at least a day's notice so we can have cash on
hand. You will never be left out of pocket.
</p>
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Guest / Walk-In Bookings</h3>
+19 -185
View File
@@ -11,8 +11,8 @@
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import CardInput from '$lib/components/payments/CardInput.svelte';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
// Types
@@ -53,16 +53,8 @@
// Card selection state
let savedCards = $state<SavedCard[]>([]);
let selectedCardId = $state<string | null>(null);
let showNewCardForm = $state(false);
// New card form state
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let saveCardForFuture = $state(false);
let cardNumberTouched = $state(false);
let cardExpiryTouched = $state(false);
let cardCVCTouched = $state(false);
const isCardValid = $derived(selectedCardId !== null);
// Tip selection state
let selectedTip = $state<number | null>(null);
@@ -84,96 +76,6 @@
];
});
const canSaveCards = $derived(
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
);
// Card validation (matching UserPaymentModal pattern)
function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
const newCardExpiryParts = $derived(parseExpiryParts(newCardExpiry));
const isNewCardExpiryPast = $derived(
newCardExpiryParts !== null &&
(() => {
const expiryYearMonth = newCardExpiryParts.year * 12 + newCardExpiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);
const hasNewCardInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null
);
const newCardError = $derived(
showNewCardForm || savedCards.length === 0
? cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched && hasNewCardInvalidMonth
? 'Invalid expiry month'
: cardExpiryTouched && isNewCardExpiryPast
? 'This card has expired'
: cardExpiryTouched && newCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(newCardExpiry)
? 'Enter expiry as MM/YY'
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(newCardNumber) &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3
? null
: newCardNumber.length === 0 &&
newCardExpiry.length === 0 &&
newCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
);
const isCardValid = $derived(
selectedCardId !== null ||
(isValidLuhn(newCardNumber) &&
newCardExpiryParts !== null &&
!isNewCardExpiryPast &&
newCardCVC.length >= 3)
);
function handleFieldBlur(field: string) {
if (field === 'cardNumber') cardNumberTouched = true;
else if (field === 'cardExpiry') cardExpiryTouched = true;
else if (field === 'cardCVC') cardCVCTouched = true;
}
function handleFieldInput(field: string) {
if (field === 'cardNumber') cardNumberTouched = false;
else if (field === 'cardExpiry') cardExpiryTouched = false;
else if (field === 'cardCVC') cardCVCTouched = false;
}
const onfieldblur = handleFieldBlur;
const onfieldinput = handleFieldInput;
// Format functions
function formatDate(dateStr: string): string {
const date = new SvelteDate(dateStr);
@@ -291,31 +193,17 @@
return;
}
if (savedCards.length > 0 && !selectedCardId && !showNewCardForm) {
toast.error('Please select a payment method');
if (savedCards.length === 0) {
toast.error('Please add a saved card or contact the salon to pay by another method');
return;
}
if ((showNewCardForm || savedCards.length === 0) && !newCardNumber.replace(/\s/g, '')) {
toast.error('Please enter your card number');
if (!selectedCardId) {
toast.error('Please select a payment method');
return;
}
paymentState = 'processing';
// Validate card details for new card payments
if (showNewCardForm || savedCards.length === 0) {
if (
!isValidLuhn(newCardNumber) ||
!/^\d{2}\/\d{2}$/.test(newCardExpiry) ||
isNewCardExpiryPast ||
newCardCVC.length < 3
) {
paymentState = 'idle';
toast.error(newCardError || 'Please enter valid credit card details');
return;
}
}
try {
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
tipIdempotencyKey = crypto.randomUUID();
@@ -324,16 +212,10 @@
const amountInPence = Math.round(tipAmount * 100);
const body: Record<string, unknown> = {
amount: amountInPence,
idempotency_key: tipIdempotencyKey
idempotency_key: tipIdempotencyKey,
card_id: selectedCardId
};
if (showNewCardForm || savedCards.length === 0) {
body.new_card_token = newCardNumber.replace(/\s/g, '');
body.save_card = saveCardForFuture;
} else {
body.card_id = selectedCardId;
}
const response = await apiFetch(`/api/bookings/${bookingId}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -573,13 +455,10 @@
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
card.id && !showNewCardForm
card.id
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = card.id;
showNewCardForm = false;
}}
onclick={() => (selectedCardId = card.id)}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
@@ -590,67 +469,22 @@
>
</div>
</div>
{#if selectedCardId === card.id && !showNewCardForm}
{#if selectedCardId === card.id}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = null;
showNewCardForm = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700"
>Use a new card</span
>
</div>
{#if showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{#if showNewCardForm}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
{onfieldblur}
{onfieldinput}
/>
{#if newCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
{/if}
</div>
{/if}
</div>
{:else}
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
{onfieldblur}
{onfieldinput}
/>
{#if newCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
{/if}
<div class="space-y-3">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">
Payment Method
</span>
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
/>
</div>
{/if}
</Card.Content>
</Card.Root>
+14 -189
View File
@@ -6,8 +6,8 @@
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import CardInput from '$lib/components/payments/CardInput.svelte';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -59,16 +59,8 @@
// Card selection state (same pattern as UserPaymentModal)
let savedCards = $state<SavedCard[]>([]);
let selectedCardId = $state<string | null>(null);
let showNewCardForm = $state(false);
// New card form state
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let saveCardForFuture = $state(false);
let cardNumberTouched = $state(false);
let cardExpiryTouched = $state(false);
let cardCVCTouched = $state(false);
const isCardValid = $derived(selectedCardId !== null);
let selectedTip = $state<number | null>(null);
let customTip = $state('');
@@ -93,99 +85,6 @@
];
});
const canSaveCards = $derived(
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
);
// Card validation (matching UserPaymentModal pattern)
function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
function handleFieldBlur(field: string) {
if (field === 'cardNumber') cardNumberTouched = true;
else if (field === 'cardExpiry') cardExpiryTouched = true;
else if (field === 'cardCVC') cardCVCTouched = true;
}
function handleFieldInput(field: string) {
if (field === 'cardNumber') cardNumberTouched = false;
else if (field === 'cardExpiry') cardExpiryTouched = false;
else if (field === 'cardCVC') cardCVCTouched = false;
}
const onfieldblur = handleFieldBlur;
const onfieldinput = handleFieldInput;
const newCardExpiryParts = $derived(parseExpiryParts(newCardExpiry));
const isNewCardExpiryPast = $derived(
newCardExpiryParts !== null &&
(() => {
// parseExpiryParts returns months 1-indexed (1=Jan, 12=Dec).
// Use year-month arithmetic to avoid Date constructor 0-index confusion.
const expiryYearMonth = newCardExpiryParts.year * 12 + newCardExpiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);
const hasNewCardInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null
);
const newCardError = $derived(
showNewCardForm || savedCards.length === 0
? cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched && hasNewCardInvalidMonth
? 'Invalid expiry month'
: cardExpiryTouched && isNewCardExpiryPast
? 'This card has expired'
: cardExpiryTouched && newCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(newCardExpiry)
? 'Enter expiry as MM/YY'
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(newCardNumber) &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3
? null
: newCardNumber.length === 0 &&
newCardExpiry.length === 0 &&
newCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
);
const isCardValid = $derived(
selectedCardId !== null ||
(isValidLuhn(newCardNumber) &&
newCardExpiryParts !== null &&
!isNewCardExpiryPast &&
newCardCVC.length >= 3)
);
function formatDate(dateStr: string): string {
const date = new SvelteDate(dateStr);
return date.toLocaleDateString('en-GB', {
@@ -243,31 +142,17 @@
return;
}
if (savedCards.length > 0 && !selectedCardId && !showNewCardForm) {
toast.error('Please select a payment method');
if (savedCards.length === 0) {
toast.error('Please add a saved card or contact the salon to pay by another method');
return;
}
if ((showNewCardForm || savedCards.length === 0) && !newCardNumber.replace(/\s/g, '')) {
toast.error('Please enter your card number');
if (!selectedCardId) {
toast.error('Please select a payment method');
return;
}
paymentState = 'processing';
// Validate card details for new card payments
if (showNewCardForm || savedCards.length === 0) {
if (
!isValidLuhn(newCardNumber) ||
!/^\d{2}\/\d{2}$/.test(newCardExpiry) ||
isNewCardExpiryPast ||
newCardCVC.length < 3
) {
paymentState = 'idle';
toast.error(newCardError || 'Please enter valid credit card details');
return;
}
}
try {
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
tipIdempotencyKey = crypto.randomUUID();
@@ -276,16 +161,10 @@
const amountInPence = Math.round(tipAmount * 100);
const body: Record<string, unknown> = {
amount: amountInPence,
idempotency_key: tipIdempotencyKey
idempotency_key: tipIdempotencyKey,
card_id: selectedCardId
};
if (showNewCardForm || savedCards.length === 0) {
body.new_card_token = newCardNumber.replace(/\s/g, '');
body.save_card = saveCardForFuture;
} else {
body.card_id = selectedCardId;
}
const response = await apiFetch(`/api/bookings/${booking.id}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -594,13 +473,10 @@
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
card.id && !showNewCardForm
card.id
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = card.id;
showNewCardForm = false;
}}
onclick={() => (selectedCardId = card.id)}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
@@ -611,67 +487,16 @@
>
</div>
</div>
{#if selectedCardId === card.id && !showNewCardForm}
{#if selectedCardId === card.id}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = null;
showNewCardForm = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700"
>Use a new card</span
>
</div>
{#if showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{/if}
{#if showNewCardForm && savedCards.length > 0}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
{onfieldblur}
{onfieldinput}
/>
{#if newCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
{/if}
</div>
{:else if savedCards.length === 0}
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
{onfieldblur}
{onfieldinput}
{:else}
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
/>
{#if newCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
{/if}
{/if}
</div>
</Card.Content>