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
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 4abcb324c9
commit 2459ddc919
13 changed files with 133 additions and 35 deletions
@@ -199,8 +199,10 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
const isTipNewCardExpiryPast = $derived(
tipNewCardExpiryParts !== null &&
(() => {
const expiryDate = new SvelteDate(tipNewCardExpiryParts.year, tipNewCardExpiryParts.month);
return expiryDate < new SvelteDate();
const expiryYearMonth = tipNewCardExpiryParts.year * 12 + tipNewCardExpiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);
const hasTipNewCardInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) && tipNewCardExpiryParts === null);
@@ -322,8 +324,6 @@ import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
if (tipShowNewCard || tipSavedCards.length === 0) {
body.new_card_token = tipNewCardNumber.replace(/\s/g, '');
body.card_expiry = tipNewCardExpiry;
body.card_cvc = tipNewCardCVC;
body.save_card = tipSaveCardFuture;
} else {
body.card_id = tipSelectedCardId;
@@ -136,7 +136,7 @@
const cardError = $derived(
cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched && expiryParts !== null && (() => { const d = new SvelteDate(expiryParts.year, expiryParts.month); return d < new SvelteDate(); })()
: cardExpiryTouched && expiryParts !== null && (() => { const em = expiryParts.year * 12 + expiryParts.month; const now2 = new SvelteDate(); const cm = now2.getFullYear() * 12 + now2.getMonth() + 1; return em < cm; })()
? 'This card has expired'
: cardExpiryTouched && !/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiry.length > 0
? 'Enter expiry as MM/YY'
@@ -79,8 +79,10 @@
const isExpiryInPast = $derived(
expiryParts !== null &&
(() => {
const expiryDate = new SvelteDate(expiryParts.year, expiryParts.month);
return expiryDate < new SvelteDate();
const expiryYearMonth = expiryParts.year * 12 + expiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);