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:
@@ -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}
|
||||
|
||||
@@ -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.';
|
||||
Reference in New Issue
Block a user