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
+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>