Files
Crussell/frontend/src/routes/pay-tip/[id]/+page.svelte
T
popertots 2459ddc919 Fix review findings: expiry bug (all 8 files), idempotency keys, card_expiry/card_cvc removal, URL encoding, BuyerEmail logging, ValidateCardInfo, saved-card test, future work doc
Backend:
- Fix refund idempotency key: clock.Now() → deterministic (pr.ID + amount)
- Fix ValidateCardInfo: enforce mutual exclusivity, handle empty strings symmetrically
- Fix paymentFromSquare brand fallback (remove dead SourceType fallback)
- Fix URL encoding: PathEscape → QueryEscape for customer_id query param
- Fix BuyerEmail: log warning on DB error instead of silent discard
- Fix idempotency key in createCardOnFileHTTP: time.Now() → deterministic hex hash
- Add BuyerEmail to CreateTipPayment Square request
- Move realBaseURL from shared file to square_dev.go (only used in dev)
- Add TestTipPayment_WithSavedCard test (card_id path coverage)
- Fix AMEX brand in mock (AMEX → AMERICAN_EXPRESS, fix test)

Frontend:
- Fix off-by-month expiry bug in ALL 8 files using year-month arithmetic
  (parseExpiryParts returns 1-indexed, SvelteDate expects 0-indexed)
  Files: tip/+page, pay-tip/[id], UserBookingModal, UserPaymentModal,
  BookingFlow, account/+page (add card + buy gift card sections)
- Remove card_expiry/card_cvc from tip request bodies (backend has no fields)

Docs:
- Mark P9 (placeholder tokens) as completed, add P11 (Square Web Payments SDK)
- Mark T13 (rune arithmetic) as completed
2026-08-22 00:34:49 +01:00

657 lines
20 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Skeleton } from '$lib/components/ui/skeleton';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
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 { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
// Types
type Service = {
service_id: string;
booking_id: string;
service_name: string;
price: number;
duration_minutes: number;
override_price?: number;
override_duration_minutes?: number;
};
type Booking = {
id: string;
start_time: string;
status: string;
services: Service[];
total_amount: number;
amount_paid: number;
duration_minutes: number;
};
// State
let booking = $state<Booking | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
let pageState = $state<'loading' | 'authorized' | 'unauthorized' | 'admin'>('loading');
// Card selection state
let savedCards = $state<SavedCard[]>([]);
let loadingCards = $state(false);
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);
// Tip selection state
let selectedTip = $state<number | null>(null);
let customTip = $state('');
const tipAmount = $derived(
selectedTip !== null ? selectedTip : customTip ? parseFloat(customTip) || 0 : 0
);
// Get booking ID from URL
const bookingId = $derived($page.params.id);
const tipPercentages = $derived.by(() => {
const total = booking?.total_amount ?? 0;
if (total <= 0) return [];
return [
{ pct: 10, amount: Math.round(total * 0.1 * 100) / 100 },
{ pct: 15, amount: Math.round(total * 0.15 * 100) / 100 },
{ pct: 20, amount: Math.round(total * 0.2 * 100) / 100 }
];
});
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);
return date.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
function formatTimeRange(
startStr: string,
services: Service[],
fallbackDuration: number
): string {
const start = new SvelteDate(startStr);
const totalMinutes =
services?.reduce(
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
0
) ??
fallbackDuration ??
0;
const end = new SvelteDate(start.getTime() + totalMinutes * 60000);
const formatOpt: Intl.DateTimeFormatOptions = {
hour: 'numeric',
minute: '2-digit',
hour12: true
};
return `${start.toLocaleTimeString('en-GB', formatOpt)} ${end.toLocaleTimeString('en-GB', formatOpt)}`;
}
function formatPrice(pounds: number): string {
return ${pounds.toFixed(2)}`;
}
// Fetch booking data
async function fetchBookingData() {
loading = true;
error = null;
try {
const response = await apiFetch(`/api/bookings/${bookingId}`);
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication required');
}
if (response.status === 404) {
throw new Error('Booking not found');
}
throw new Error('Failed to load booking');
}
booking = await response.json();
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred';
} finally {
loading = false;
}
}
// Load saved cards
async function loadSavedCards() {
if (savedCardsStore.loaded) {
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
}
return;
}
loadingCards = true;
try {
await savedCardsStore.fetch();
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
}
} catch {
// ignore
} finally {
loadingCards = false;
}
}
// Handle tip selection
function selectTip(amount: number) {
selectedTip = amount;
customTip = '';
}
function handleCustomTipInput(e: Event) {
const input = e.target as HTMLInputElement;
const cleaned = input.value.replace(/[^0-9.]/g, '');
const firstDot = cleaned.indexOf('.');
let sanitized: string;
if (firstDot !== -1) {
const integerPart = cleaned.substring(0, firstDot);
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
sanitized = integerPart + '.' + decimalPart;
} else {
sanitized = cleaned;
}
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
customTip = sanitized;
}
selectedTip = null;
}
// Submit tip payment
async function submitTip() {
if (!booking) return;
if (tipAmount <= 0) {
toast.error('Please select a tip amount');
return;
}
if (savedCards.length > 0 && !selectedCardId && !showNewCardForm) {
toast.error('Please select a payment method');
return;
}
if ((showNewCardForm || savedCards.length === 0) && !newCardNumber.replace(/\s/g, '')) {
toast.error('Please enter your card number');
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 {
const amountInPence = Math.round(tipAmount * 100);
const body: Record<string, unknown> = { amount: amountInPence };
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' },
body: JSON.stringify(body)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(extractErrorMessage(errorText) || 'Payment failed');
}
paymentState = 'success';
toast.success('Thank you for your tip!');
} catch (err) {
paymentState = 'error';
const errorMessage = err instanceof Error ? err.message : 'Payment failed';
toast.error(errorMessage);
}
}
// Reset and retry
function retryPayment() {
paymentState = 'idle';
}
// Auth check + fetch
$effect(() => {
if (!browser) return;
if (authStore.isLoading) {
pageState = 'loading';
return;
}
if (!authStore.isAuthenticated) {
pageState = 'unauthorized';
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto('/login', { replaceState: true });
return;
}
if (authStore.currentUser?.role === 'admin') {
pageState = 'admin';
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto('/admin', { replaceState: true });
return;
}
pageState = 'authorized';
loadSavedCards();
if (bookingId) {
fetchBookingData();
}
});
</script>
<svelte:head>
<script>
(function () {
// Pre-hydration auth guard: reads localStorage directly because the Svelte
// authStore hasn't initialized yet at this point (async+$state). This runs
// synchronously before any rendering, preventing a flash of protected content.
// The authStore handles post-hydration auth.
try {
var token = localStorage.getItem('authToken');
if (!token) {
window.location.replace('/login');
return;
}
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 <= Date.now()) {
window.location.replace('/login');
}
} catch (e) {
window.location.replace('/login');
}
})();
</script>
<title>Leave a Tip - Crussell</title>
</svelte:head>
<div class="mx-auto min-h-screen px-4 py-8 sm:max-w-md md:py-12">
{#if loading || pageState === 'loading'}
<div class="space-y-6">
<div class="text-center">
<Skeleton class="mx-auto h-10 w-40" />
</div>
<Card.Root>
<Card.Content class="space-y-4 pt-6">
<Skeleton class="h-16 w-full" />
<Skeleton class="h-24 w-full" />
</Card.Content>
</Card.Root>
</div>
{:else if error}
<Card.Root>
<Card.Header>
<Card.Title class="text-red-600">Something went wrong</Card.Title>
</Card.Header>
<Card.Content>
<p class="text-gray-600">{error}</p>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<Button class="w-full sm:w-auto" onclick={() => goto('/')}>Go Home</Button>
<Button variant="outline" class="w-full sm:w-auto" onclick={fetchBookingData}>
Try Again
</Button>
</div>
</Card.Content>
</Card.Root>
{:else if booking}
<div class="mb-6 text-center">
<h1 class="text-2xl font-bold text-gray-900 sm:text-3xl">Leave a Tip</h1>
<p class="mt-1 text-gray-600">Show your appreciation for great service</p>
</div>
{#if paymentState === 'success'}
<Card.Root>
<Card.Content class="py-8 text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100"
>
<svg
class="h-8 w-8 text-green-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M20 6L9 17l-5-5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<h2 class="text-xl font-semibold text-gray-900">Thank you!</h2>
<p class="mt-2 text-gray-600">Your generosity is greatly appreciated.</p>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<Button class="mt-6" onclick={() => goto('/')}>Go Home</Button>
</Card.Content>
</Card.Root>
{:else}
<Card.Root class="mb-6">
<Card.Header>
<Card.Title>Your Appointment</Card.Title>
</Card.Header>
<Card.Content class="space-y-3">
<div class="flex justify-between">
<span class="text-sm text-gray-500">Date</span>
<span class="font-medium">{formatDate(booking.start_time)}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-500">Time</span>
<span class="font-medium"
>{formatTimeRange(
booking.start_time,
booking.services,
booking.duration_minutes ?? 0
)}</span
>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-500">Paid</span>
<span class="font-medium">{formatPrice(booking.amount_paid ?? 0)}</span>
</div>
<div class="border-t pt-3">
<div class="text-sm text-gray-500">Services</div>
<div class="mt-2 space-y-1">
{#each booking.services as service (service.service_id || service.booking_id)}
<div class="flex justify-between text-sm">
<span class="text-gray-700">{service.service_name}</span>
<span class="text-gray-500"
>{formatPrice(service.override_price ?? service.price)}</span
>
</div>
{/each}
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root class="mb-6">
<Card.Header>
<Card.Title>Choose Tip Amount</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="grid grid-cols-3 gap-3">
{#each tipPercentages as tip (tip.pct)}
<button
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTip ===
tip.amount
? 'bg-fuchsia-100'
: ''}"
onclick={() => selectTip(tip.amount)}
type="button"
>
<div>{formatPrice(tip.amount)}</div>
<div class="text-xs font-normal text-gray-500">{tip.pct}%</div>
</button>
{/each}
</div>
<div>
<label for="custom-tip" class="text-sm font-medium text-gray-700"
>Or enter custom amount</label
>
<div class="relative mt-1">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input
id="custom-tip"
type="text"
inputmode="decimal"
step="0.01"
min="0"
placeholder="0.00"
class="pl-7"
value={customTip}
oninput={handleCustomTipInput}
/>
</div>
</div>
</Card.Content>
</Card.Root>
<!-- Payment Method -->
<Card.Root class="mb-6">
<Card.Header>
<Card.Title>Payment Method</Card.Title>
</Card.Header>
<Card.Content>
{#if savedCards.length > 0}
<div class="space-y-3">
<span
class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>
Payment Method
</span>
<div class="space-y-2">
{#each savedCards as card (card.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId === card.id && !showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = card.id;
showNewCardForm = false;
}}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
<div class="text-sm">
<span class="font-mono">**** {card.last_4}</span>
<span class="ml-2 text-xs text-gray-400"
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
>
</div>
</div>
{#if selectedCardId === card.id && !showNewCardForm}
<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}
{/if}
</Card.Content>
</Card.Root>
{#if paymentState === 'error'}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-red-700">Payment failed. Please try again.</p>
<Button variant="outline" class="mt-3 w-full" onclick={retryPayment}>Try Again</Button>
</div>
{/if}
<Button
class="w-full"
size="lg"
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing'}
loading={paymentState === 'processing'}
onclick={submitTip}
>
{paymentState === 'processing' ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
{/if}
{/if}
</div>