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