fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity
7 review agents (pipeline run, self-review, codebase-context, frontend-placement, backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work. ALL findings fixed, including every pre-existing red CI job: GDPR (HIGH): - anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII) no longer survive registered-user account deletion; gdpr test added BACKEND TEST GAPS (all 10): - delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker - twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper - insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all 6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors) - CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip, token-less fallback + SCA-required (new terminal_sca_test.go) - isVerificationRequiredError at all 5 charge sites (402 + code:verification_required) - customer_initiated handler-level assertions (MIT false admin / CIT true customer) - Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix, parseVerifyToken unit tests FRONTEND SCA + Square-API (CRITICAL): - tokenizeSavedCardWithVerification reads result.token (the verified token) not result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could never succeed in production before); parseTokenizeVerificationResult pure fn extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless - HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled - challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show; 'waiting for approval in your banking app' state on CIT surfaces - sca-unavailable demotion resets per attempt; card selection disabled mid-challenge; genuine saved-card declines no longer relabeled 'requires verification'; modal-close guard during processing; retry affordance standardized PIPELINE (every red job now green): - prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe) - govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean - race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic - DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes) - frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex), deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY, Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified against code everywhere Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/ gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
This commit is contained in:
@@ -6,24 +6,27 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import {
|
||||
isSquareConfigured,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
shouldFallbackTo2FA,
|
||||
submitPaymentWithRetry,
|
||||
adminRequestNewTwoFactorCode,
|
||||
requestNewTwoFactorCode,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
import {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import {
|
||||
CARD_VERIFICATION_RETRY_MESSAGE,
|
||||
isSquareConfigured,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
shouldFallbackTo2FA,
|
||||
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
|
||||
submitPaymentWithRetry,
|
||||
adminRequestNewTwoFactorCode,
|
||||
requestNewTwoFactorCode,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
import {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
|
||||
type CartItem = {
|
||||
id: string;
|
||||
@@ -65,7 +68,7 @@
|
||||
// the customer is not charged twice. A changed cart/amount/payment method
|
||||
// yields a different composite key, so genuinely new sales get fresh keys.
|
||||
// Mirrors the BookingFlow/PaymentModal/TipPayment per-charge caching pattern.
|
||||
let idempotencyKeys = new Map<string, string>();
|
||||
let idempotencyKeys = new SvelteMap<string, string>();
|
||||
|
||||
function idempotencyKeyFor(item: CartItem, qtyIndex: number): string {
|
||||
// saved_card charges also key on the selected card id so switching to a
|
||||
@@ -148,10 +151,13 @@
|
||||
let awaitingSCA = $state(false);
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
|
||||
gateActive: () =>
|
||||
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
|
||||
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
|
||||
mint: () =>
|
||||
selectedCustomer?.id ? adminRequestNewTwoFactorCode(selectedCustomer.id) : requestNewTwoFactorCode()
|
||||
selectedCustomer?.id
|
||||
? adminRequestNewTwoFactorCode(selectedCustomer.id)
|
||||
: requestNewTwoFactorCode()
|
||||
});
|
||||
|
||||
// The saved-card option is hidden outright unless a customer is selected
|
||||
@@ -408,7 +414,10 @@
|
||||
// challenge and retry the SAME sale line with the fresh token and
|
||||
// its SAME cached idempotency key. runTillSavedCardSCA throws to
|
||||
// stop the whole sale on any non-verified outcome.
|
||||
if (paymentMethod === 'saved_card' && isVerificationRequiredSignal(responseStatus, errText)) {
|
||||
if (
|
||||
paymentMethod === 'saved_card' &&
|
||||
isVerificationRequiredSignal(responseStatus, errText)
|
||||
) {
|
||||
await runTillSavedCardSCA(body);
|
||||
continue;
|
||||
}
|
||||
@@ -433,75 +442,71 @@
|
||||
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
|
||||
paymentError = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
isProcessingPaymentSync = false;
|
||||
processing = false;
|
||||
} finally {
|
||||
isProcessingPaymentSync = false;
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved-card (ccof) SCA challenge, run when a till sale line came back 402
|
||||
* with the verification-required signal. The CUSTOMER approves the 3DS
|
||||
* challenge in their banking app; the operator's screen shows the waiting
|
||||
* state. 'verified' retries the SAME sale line with the fresh verification_token
|
||||
* and its SAME cached idempotency key (never regenerated here); 'sca-unavailable'
|
||||
* demotes 2FA from backup to the available gate; 'challenge-cancelled' /
|
||||
* 'sca-failed' keep the pending row retryable (the idempotency key stays
|
||||
* cached). Throws to stop the whole sale on any non-verified outcome.
|
||||
*/
|
||||
async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void> {
|
||||
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
|
||||
// The till body carries the amount in POUNDS (the backend multiplies by
|
||||
// 100); the SCA challenge binds to pence, so convert for the challenge.
|
||||
const amountPence = Math.round((Number(body.amount) || 0) * 100);
|
||||
awaitingSCA = true;
|
||||
try {
|
||||
if (!squareCardId) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
throw new Error(VERIFICATION_REQUIRED_MESSAGE);
|
||||
}
|
||||
let result: SavedCardVerificationResult;
|
||||
/**
|
||||
* Saved-card (ccof) SCA challenge, run when a till sale line came back 402
|
||||
* with the verification-required signal. The CUSTOMER approves the 3DS
|
||||
* challenge in their banking app; the operator's screen shows the waiting
|
||||
* state. 'verified' retries the SAME sale line with the fresh verification_token
|
||||
* and its SAME cached idempotency key (never regenerated here); 'sca-unavailable'
|
||||
* demotes 2FA from backup to the available gate; 'challenge-cancelled' /
|
||||
* 'sca-failed' keep the pending row retryable (the idempotency key stays
|
||||
* cached). Throws to stop the whole sale on any non-verified outcome.
|
||||
*/
|
||||
async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void> {
|
||||
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
|
||||
// The till body carries the amount in POUNDS (the backend multiplies by
|
||||
// 100); the SCA challenge binds to pence, so convert for the challenge.
|
||||
const amountPence = Math.round((Number(body.amount) || 0) * 100);
|
||||
awaitingSCA = true;
|
||||
try {
|
||||
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
|
||||
email: selectedCustomer?.email
|
||||
});
|
||||
} catch (err) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
throw err;
|
||||
}
|
||||
lastSCAOutcome = result.outcome;
|
||||
if (result.outcome === 'verified') {
|
||||
const retry = await submitPaymentWithRetry(() =>
|
||||
apiFetch('/api/admin/till/sale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
verification_token: result.verificationToken
|
||||
})
|
||||
})
|
||||
);
|
||||
if (!retry.ok) {
|
||||
const errText = await retry.text();
|
||||
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
|
||||
if (!squareCardId) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
throw new Error(VERIFICATION_REQUIRED_MESSAGE);
|
||||
}
|
||||
return;
|
||||
let result: SavedCardVerificationResult;
|
||||
try {
|
||||
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
|
||||
email: selectedCustomer?.email
|
||||
});
|
||||
} catch (err) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
throw err;
|
||||
}
|
||||
lastSCAOutcome = result.outcome;
|
||||
if (result.outcome === 'verified') {
|
||||
const retry = await submitPaymentWithRetry(() =>
|
||||
apiFetch('/api/admin/till/sale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
verification_token: result.verificationToken
|
||||
})
|
||||
})
|
||||
);
|
||||
if (!retry.ok) {
|
||||
const errText = await retry.text();
|
||||
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
twoFactor.reveal = true;
|
||||
if (result.outcome === 'sca-unavailable') {
|
||||
throw new Error(`${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`);
|
||||
}
|
||||
throw new Error(CARD_VERIFICATION_RETRY_MESSAGE);
|
||||
} finally {
|
||||
awaitingSCA = false;
|
||||
}
|
||||
twoFactor.reveal = true;
|
||||
if (result.outcome === 'sca-unavailable') {
|
||||
throw new Error(
|
||||
`${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
|
||||
);
|
||||
} finally {
|
||||
awaitingSCA = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border bg-card">
|
||||
@@ -585,13 +590,13 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
|
||||
>
|
||||
</div>
|
||||
{#if giftCardAmountTooHigh}
|
||||
<p class="text-xs text-red-700"
|
||||
>Gift card amount exceeds maximum (£{GIFT_CARD_MAX_AMOUNT})</p
|
||||
>
|
||||
<p class="text-xs text-red-700">
|
||||
Gift card amount exceeds maximum (£{GIFT_CARD_MAX_AMOUNT})
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground"
|
||||
>Gift card limit £{GIFT_CARD_MAX_AMOUNT} per transaction</p
|
||||
>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Gift card limit £{GIFT_CARD_MAX_AMOUNT} per transaction
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
@@ -885,9 +890,9 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p class="text-xs text-amber-800">
|
||||
Your card issuer will ask you to approve this payment in your banking app.
|
||||
</p>
|
||||
<p class="text-xs text-amber-800">
|
||||
Your card issuer will ask you to approve this payment in your banking app.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -928,7 +933,9 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
|
||||
{/if}
|
||||
|
||||
{#if awaitingSCA}
|
||||
<div class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6">
|
||||
<div
|
||||
class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6"
|
||||
>
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
|
||||
></div>
|
||||
|
||||
Reference in New Issue
Block a user