fix: review-loop A — discount credit on admin payments, campaign over-credit cap, sweep replay window, dedup refund revalidation, duplication/modularisation, GBP pence naming
Round-A fresh review (6 agents) + fix + secondary cross-cutting + verification rounds: - F1: campaign discounts reduce the charged amount (deposit credit + admin PaymentModal discounted total); capDiscountToRemainingObligation prevents over-credit at completion in all four campaign blocks - F2: sweep replay rescue distinguishes legitimate same-key retries (21h window) from expired-key new charges; ccof blind-fails leave pending + CRITICAL instead of clawing back - F3: post-start online overflow carved as a tip record (mirrors terminal split builder) - A1: single-source Square decline-code classification (till delegates to square.IsDefinitivePaymentError) - A2/A5: refund attempt-cap literals consolidated; refund-failure counter capped + reset on terminal resolutions + admin notifications - A3/A9: idempotency helpers adopted across derivations; IsExplicitDevOrMockEnv relocated + all gates unified (incl. health-check) - A7: 2FA user+IP limiter + TRUST_PROXY_HEADERS startup warning; SNAPSHOT_ENC_KEY startup validation; TWO_FACTOR_PEPPER docs corrected - A8: snapshot encryption on all 6 write sites + marker-aware reuse paths; MPV->SPV effective voucher type (single VAT point) - A10/A11/A12/A16: gift-card slot scan advances past failed; amount-aware refund reconciliation; completed-booking refund re-check; PaymentWasRefunded on SquareClient interface - Dedup refund revalidation on tip/terminal/gift-card paths; sweep acknowledged_at IS NULL parity; refund-notification single source (exported payments.InsertRefundFailedNotifications) - Duplication/modularisation round: shared frontend helpers (sanitizeDecimalInput, campaignDiscountCents, twoFactorBlocksSavedCards getter, generateUUID), single-source MaxIdempotencyKeyLength, notification-helper consolidation, snapshot-guard comments - Cross-cutting GBP rename: Cents->Pence across backend + frontend + tests (26 identifiers, 16 files) - Tests: 11 behavior-change tests updated to new invariants; coverage for fixed functions; frontend vitest 55 tests; docs corrected (test counts, 2FA delivery, pre-launch checklist, resolution status) - gitleaks: allowlist backend/internal/square test fixtures (mock idempotency keys) All 25 backend packages pass; frontend 55/55 + build clean; env-docs 41/41.
This commit is contained in:
@@ -38,7 +38,12 @@
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import { canSaveCardsForRole, isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
|
||||
import {
|
||||
canSaveCardsForRole,
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
||||
import {
|
||||
@@ -142,9 +147,7 @@
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks saved-card use
|
||||
// and saving new cards for reuse. The new-card (nonce) path has its own
|
||||
// SCA via Square tokenizeWithVerification.
|
||||
const twoFactorBlocksSavedCards = $derived(
|
||||
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
|
||||
);
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
|
||||
const depositCardFormValid = $derived(paymentCardSelectionValid);
|
||||
|
||||
@@ -334,7 +337,6 @@
|
||||
toast.error('Booking was not created. Please try again.');
|
||||
return;
|
||||
}
|
||||
const bookingId = confirmedBooking.id;
|
||||
// Charge the SERVER-computed deposit: the booking response carries the
|
||||
// authoritative deposit_amount (20% of the server-side total, which
|
||||
// accounts for discounts / admin adjustments). The client-side
|
||||
@@ -343,7 +345,7 @@
|
||||
confirmedBooking.deposit_amount && confirmedBooking.deposit_amount > 0
|
||||
? confirmedBooking.deposit_amount
|
||||
: _amount;
|
||||
const amountCents = Math.round(depositAmount * 100);
|
||||
const amountPence = Math.round(depositAmount * 100);
|
||||
|
||||
let newCardToken: string | undefined;
|
||||
let verificationToken: string | undefined;
|
||||
@@ -359,11 +361,11 @@
|
||||
if (
|
||||
!depositNonce ||
|
||||
depositTokenizedForSaveCard !== depositSaveCard ||
|
||||
isNonceStale(depositTokenizedAt, depositTokenAmount, amountCents)
|
||||
isNonceStale(depositTokenizedAt, depositTokenAmount, amountPence)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await paymentCardSelection.tokenizeWithVerification(
|
||||
amountCents,
|
||||
amountPence,
|
||||
{
|
||||
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
|
||||
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
|
||||
@@ -373,7 +375,7 @@
|
||||
);
|
||||
depositNonce = tokenized.nonce;
|
||||
depositVerificationToken = tokenized.verificationToken ?? '';
|
||||
depositTokenAmount = amountCents;
|
||||
depositTokenAmount = amountPence;
|
||||
depositTokenizedAt = Date.now();
|
||||
depositTokenizedForSaveCard = depositSaveCard;
|
||||
} catch (err) {
|
||||
@@ -396,17 +398,17 @@
|
||||
const cardKey = selectedPaymentMethod || 'new-card';
|
||||
if (
|
||||
!depositIdempotencyKey ||
|
||||
depositKeyedAmount !== amountCents ||
|
||||
depositKeyedAmount !== amountPence ||
|
||||
depositKeyedCard !== cardKey
|
||||
) {
|
||||
depositIdempotencyKey = generateUUID();
|
||||
depositKeyedAmount = amountCents;
|
||||
depositKeyedAmount = amountPence;
|
||||
depositKeyedCard = cardKey;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
payment_type: 'deposit',
|
||||
amount: amountCents,
|
||||
amount: amountPence,
|
||||
idempotency_key: depositIdempotencyKey,
|
||||
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
|
||||
@@ -415,93 +417,12 @@
|
||||
|
||||
paymentAttempted = true;
|
||||
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${bookingId}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
depositPaid = true;
|
||||
depositIdempotencyKey = '';
|
||||
depositKeyedAmount = 0;
|
||||
depositKeyedCard = '';
|
||||
depositNonce = '';
|
||||
depositVerificationToken = '';
|
||||
depositTokenAmount = 0;
|
||||
depositTokenizedAt = 0;
|
||||
depositTokenizedForSaveCard = false;
|
||||
depositSaveCard = false;
|
||||
// Immutable update — avoid mutating the existing object so
|
||||
// concurrent renders (e.g. a stale fetch) can't observe partial
|
||||
// state. (See audit: HIGH issue #3 — confirmedBooking mutated
|
||||
// in place, potential overcharge on double-click race.)
|
||||
confirmedBooking = {
|
||||
...confirmedBooking,
|
||||
deposit_paid: true,
|
||||
amount_paid: (confirmedBooking.amount_paid || 0) + depositAmount,
|
||||
amount_due: Math.max(0, (confirmedBooking.amount_due || 0) - depositAmount)
|
||||
};
|
||||
toast.success('Payment successful!');
|
||||
} else {
|
||||
const text = await response.text();
|
||||
// A 409 "already paid" (double-tab, or a lost-response retry that
|
||||
// actually landed) must not leave the user wedged on the pay form
|
||||
// with a stale deposit_paid=false — money was taken. Reconcile
|
||||
// against the server's truth so the confirmation gate (depositPaid
|
||||
// / confirmedBooking.deposit_paid) opens and the user reaches the
|
||||
// confirmation screen. The body-text match is a belt-and-braces
|
||||
// fallback for 4xx responses that still report the charge as
|
||||
// already processed.
|
||||
if (response.status === 409 || /already|paid|processed/.test(text.toLowerCase())) {
|
||||
try {
|
||||
const bookingResp = await apiFetch(`/api/bookings/${bookingId}`);
|
||||
if (bookingResp.ok) {
|
||||
const serverBooking = await bookingResp.json();
|
||||
// Immutable update — spread, never mutate (see audit note above).
|
||||
confirmedBooking = {
|
||||
...confirmedBooking,
|
||||
status: serverBooking.status ?? confirmedBooking.status,
|
||||
deposit_paid: serverBooking.deposit_paid ?? confirmedBooking.deposit_paid,
|
||||
deposit_amount:
|
||||
serverBooking.deposit_amount ?? confirmedBooking.deposit_amount,
|
||||
amount_paid: serverBooking.amount_paid ?? confirmedBooking.amount_paid,
|
||||
amount_due: serverBooking.amount_due ?? confirmedBooking.amount_due,
|
||||
payments: serverBooking.payments ?? confirmedBooking.payments,
|
||||
total_amount: serverBooking.total_amount ?? confirmedBooking.total_amount
|
||||
};
|
||||
depositPaid = confirmedBooking.deposit_paid;
|
||||
toast.success('Payment successful!');
|
||||
} else {
|
||||
toast.warning(
|
||||
text || 'Payment failed — you can pay again from your booking details.'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.warning(
|
||||
text || 'Payment failed — you can pay again from your booking details.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.warning(text || 'Payment failed — you can pay again from your booking details.');
|
||||
}
|
||||
// A definitive charge failure (declined card, any 4xx) consumes the
|
||||
// nonce + SCA verification token (Square nonces are single-use) —
|
||||
// clear the cached pair so a retry re-tokenizes fresh instead of
|
||||
// resubmitting a spent nonce for up to 240s. The idempotency key
|
||||
// stays so a lost-response retry still dedups against the original
|
||||
// charge (matches the TipPayment pattern).
|
||||
depositNonce = '';
|
||||
depositVerificationToken = '';
|
||||
depositTokenAmount = 0;
|
||||
depositTokenizedAt = 0;
|
||||
depositTokenizedForSaveCard = false;
|
||||
}
|
||||
await submitDepositPayment({
|
||||
body,
|
||||
amountPence,
|
||||
depositAmount,
|
||||
confirmOverflowTip: false
|
||||
});
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred. Your booking may still be confirmed — check your appointments.'
|
||||
@@ -520,8 +441,181 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Submits the deposit payment request and processes the outcome. Shared by
|
||||
// the initial attempt and the overflow-tip confirm resend so both use the
|
||||
// exact same success/error handling. `confirmOverflowTip` adds the backend's
|
||||
// opt-in flag for a pre-start overpayment; the resend reuses the SAME body
|
||||
// (cached nonce / verification token / idempotency key) as the rejected
|
||||
// attempt — the guard fired before any Square call, so the tokens are
|
||||
// unconsumed and the key is still the correct dedup identity.
|
||||
async function submitDepositPayment(options: {
|
||||
body: Record<string, unknown>;
|
||||
amountPence: number;
|
||||
depositAmount: number;
|
||||
confirmOverflowTip: boolean;
|
||||
}): Promise<void> {
|
||||
const { body, amountPence, depositAmount, confirmOverflowTip } = options;
|
||||
if (!confirmedBooking) return;
|
||||
const bookingId = confirmedBooking.id;
|
||||
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${bookingId}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {})
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
depositPaid = true;
|
||||
depositIdempotencyKey = '';
|
||||
depositKeyedAmount = 0;
|
||||
depositKeyedCard = '';
|
||||
depositNonce = '';
|
||||
depositVerificationToken = '';
|
||||
depositTokenAmount = 0;
|
||||
depositTokenizedAt = 0;
|
||||
depositTokenizedForSaveCard = false;
|
||||
depositSaveCard = false;
|
||||
overflowConfirm = null;
|
||||
// Immutable update — avoid mutating the existing object so
|
||||
// concurrent renders (e.g. a stale fetch) can't observe partial
|
||||
// state. (See audit: HIGH issue #3 — confirmedBooking mutated
|
||||
// in place, potential overcharge on double-click race.)
|
||||
confirmedBooking = {
|
||||
...confirmedBooking,
|
||||
deposit_paid: true,
|
||||
amount_paid: (confirmedBooking.amount_paid || 0) + depositAmount,
|
||||
amount_due: Math.max(0, (confirmedBooking.amount_due || 0) - depositAmount)
|
||||
};
|
||||
toast.success('Payment successful!');
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
// Pre-start overpayment guard on stale booking data: park the rejected
|
||||
// request (body + amount) and surface the Confirm/Cancel prompt instead
|
||||
// of a dead-end 400. The cached nonce + SCA verification token +
|
||||
// idempotency key are NOT cleared — the confirm resend is the same
|
||||
// logical charge.
|
||||
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(text)) {
|
||||
overflowConfirm = {
|
||||
amountPence,
|
||||
overflowPence: Math.max(
|
||||
0,
|
||||
amountPence - Math.round((confirmedBooking?.amount_due ?? 0) * 100)
|
||||
),
|
||||
depositAmount,
|
||||
body
|
||||
};
|
||||
return;
|
||||
}
|
||||
// A 409 "already paid" (double-tab, or a lost-response retry that
|
||||
// actually landed) must not leave the user wedged on the pay form
|
||||
// with a stale deposit_paid=false — money was taken. Reconcile
|
||||
// against the server's truth so the confirmation gate (depositPaid
|
||||
// / confirmedBooking.deposit_paid) opens and the user reaches the
|
||||
// confirmation screen. The body-text match is a belt-and-braces
|
||||
// fallback for 4xx responses that still report the charge as
|
||||
// already processed.
|
||||
if (response.status === 409 || /already|paid|processed/.test(text.toLowerCase())) {
|
||||
try {
|
||||
const bookingResp = await apiFetch(`/api/bookings/${bookingId}`);
|
||||
if (bookingResp.ok) {
|
||||
const serverBooking = await bookingResp.json();
|
||||
// Immutable update — spread, never mutate (see audit note above).
|
||||
confirmedBooking = {
|
||||
...confirmedBooking,
|
||||
status: serverBooking.status ?? confirmedBooking.status,
|
||||
deposit_paid: serverBooking.deposit_paid ?? confirmedBooking.deposit_paid,
|
||||
deposit_amount: serverBooking.deposit_amount ?? confirmedBooking.deposit_amount,
|
||||
amount_paid: serverBooking.amount_paid ?? confirmedBooking.amount_paid,
|
||||
amount_due: serverBooking.amount_due ?? confirmedBooking.amount_due,
|
||||
payments: serverBooking.payments ?? confirmedBooking.payments,
|
||||
total_amount: serverBooking.total_amount ?? confirmedBooking.total_amount
|
||||
};
|
||||
depositPaid = confirmedBooking.deposit_paid;
|
||||
toast.success('Payment successful!');
|
||||
} else {
|
||||
toast.warning(
|
||||
extractErrorMessage(text) ||
|
||||
'Payment failed — you can pay again from your booking details.'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.warning(
|
||||
extractErrorMessage(text) ||
|
||||
'Payment failed — you can pay again from your booking details.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.warning(
|
||||
extractErrorMessage(text) || 'Payment failed — you can pay again from your booking details.'
|
||||
);
|
||||
}
|
||||
// A definitive charge failure (declined card, any 4xx) consumes the
|
||||
// nonce + SCA verification token (Square nonces are single-use) —
|
||||
// clear the cached pair so a retry re-tokenizes fresh instead of
|
||||
// resubmitting a spent nonce for up to 240s. The idempotency key
|
||||
// stays so a lost-response retry still dedups against the original
|
||||
// charge (matches the TipPayment pattern).
|
||||
depositNonce = '';
|
||||
depositVerificationToken = '';
|
||||
depositTokenAmount = 0;
|
||||
depositTokenizedAt = 0;
|
||||
depositTokenizedForSaveCard = false;
|
||||
}
|
||||
|
||||
let paymentAttempted = $state(false);
|
||||
|
||||
// Pre-start overpayment confirmation (mirrors UserPaymentModal). The backend
|
||||
// rejects a deposit payment that exceeds the booking's remaining balance
|
||||
// before the appointment has started unless the request carries
|
||||
// `confirm_overflow_tip: true` (a tip is gratuity for service already
|
||||
// rendered). This fires on STALE booking data where the user would otherwise
|
||||
// be stuck with an unresolvable 400. The rejected request body (including
|
||||
// the cached nonce / SCA token / idempotency key) is parked here and a
|
||||
// Confirm/Cancel prompt is shown; Confirm resends the SAME body with the
|
||||
// flag, Cancel returns to the amount-editing form.
|
||||
let overflowConfirm = $state<{
|
||||
amountPence: number;
|
||||
overflowPence: number;
|
||||
depositAmount: number;
|
||||
body: Record<string, unknown>;
|
||||
} | null>(null);
|
||||
|
||||
function cancelOverflowConfirmation() {
|
||||
overflowConfirm = null;
|
||||
isProcessingPayment = false;
|
||||
isProcessingPaymentSync = false;
|
||||
}
|
||||
|
||||
// Confirm the pre-start overpayment: resend the SAME rejected request with
|
||||
// confirm_overflow_tip: true so the excess is recorded as a tip.
|
||||
async function confirmOverflowPayment() {
|
||||
const pending = overflowConfirm;
|
||||
if (!pending || isProcessingPayment) return;
|
||||
isProcessingPayment = true;
|
||||
isProcessingPaymentSync = true;
|
||||
try {
|
||||
await submitDepositPayment({
|
||||
body: pending.body,
|
||||
amountPence: pending.amountPence,
|
||||
depositAmount: pending.depositAmount,
|
||||
confirmOverflowTip: true
|
||||
});
|
||||
} finally {
|
||||
isProcessingPayment = false;
|
||||
isProcessingPaymentSync = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cached idempotency key per deposit attempt (amount + card): reused on
|
||||
// retry so a lost-response retry dedups instead of double-charging,
|
||||
// regenerated when the amount or card changes. Matches the tip-flow pattern.
|
||||
@@ -2458,33 +2552,93 @@
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={authStore.isAuthenticated
|
||||
? {
|
||||
firstName: authStore.currentUser?.firstName ?? '',
|
||||
lastName: authStore.currentUser?.lastName ?? '',
|
||||
email: authStore.currentUser?.email ?? '',
|
||||
phone: authStore.currentUser?.phone ?? '',
|
||||
specialRequests: customerInfo.specialRequests
|
||||
}
|
||||
: customerInfo}
|
||||
showCustomer={true}
|
||||
/>
|
||||
{#if overflowConfirm}
|
||||
<!-- Pre-start overpayment confirmation: the backend rejected the
|
||||
payment because the booking's remaining balance has changed
|
||||
since it was loaded (stale data). The excess over the
|
||||
remaining balance will be recorded as a tip once confirmed. -->
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 shrink-0 text-amber-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
|
||||
<p class="mt-1 text-sm text-amber-800">
|
||||
The balance for this booking has changed since it was last loaded. The extra £{(
|
||||
overflowConfirm.overflowPence / 100
|
||||
).toFixed(2)} will be recorded as a tip. Confirm to continue?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button
|
||||
class="flex-1"
|
||||
loading={isProcessingPayment}
|
||||
disabled={isProcessingPayment}
|
||||
onclick={confirmOverflowPayment}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
disabled={isProcessingPayment}
|
||||
onclick={cancelOverflowConfirmation}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={authStore.isAuthenticated
|
||||
? {
|
||||
firstName: authStore.currentUser?.firstName ?? '',
|
||||
lastName: authStore.currentUser?.lastName ?? '',
|
||||
email: authStore.currentUser?.email ?? '',
|
||||
phone: authStore.currentUser?.phone ?? '',
|
||||
specialRequests: customerInfo.specialRequests
|
||||
}
|
||||
: customerInfo}
|
||||
showCustomer={true}
|
||||
/>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
{#if paymentMethodsLoading}
|
||||
<div class="mb-6 py-4 text-center text-gray-500">Loading payment methods...</div>
|
||||
{#if authStore.isAuthenticated}
|
||||
{#if paymentMethodsLoading}
|
||||
<div class="mb-6 py-4 text-center text-gray-500">
|
||||
Loading payment methods...
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<CardSelection
|
||||
bind:this={paymentCardSelection}
|
||||
cards={paymentMethods}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={selectedPaymentMethod}
|
||||
bind:saveCard={depositSaveCard}
|
||||
onValidityChange={(v) => (paymentCardSelectionValid = v)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<CardSelection
|
||||
bind:this={paymentCardSelection}
|
||||
cards={paymentMethods}
|
||||
cards={[]}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={selectedPaymentMethod}
|
||||
bind:saveCard={depositSaveCard}
|
||||
@@ -2492,40 +2646,27 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<CardSelection
|
||||
bind:this={paymentCardSelection}
|
||||
cards={[]}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={selectedPaymentMethod}
|
||||
bind:saveCard={depositSaveCard}
|
||||
onValidityChange={(v) => (paymentCardSelectionValid = v)}
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between border-t pt-4">
|
||||
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isProcessingPayment || !depositCardFormValid}
|
||||
onclick={() => processPayment(calculateDepositAmount())}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isProcessingPayment
|
||||
? 'Processing...'
|
||||
: `Pay Deposit £${calculateDepositAmount()}`}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between border-t pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={prevStep}
|
||||
disabled={isProcessingPayment}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isProcessingPayment || !depositCardFormValid}
|
||||
onclick={() => processPayment(calculateDepositAmount())}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isProcessingPayment
|
||||
? 'Processing...'
|
||||
: `Pay Deposit £${calculateDepositAmount()}`}
|
||||
</Button>
|
||||
<p class="mt-4 text-center text-xs text-gray-500">
|
||||
Secure payment powered by Square
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user