Service price overrides in PaymentModal were only used for frontend
calculations but not persisted to the backend. This caused receipts
and subsequent payments to use original prices instead of overridden
ones.
Added saveServiceOverrides() function that calls PUT
/api/admin/bookings/{id}/services before payment to persist any
price changes. Called in both handleCardPayment and
handleSavedCardPayment before applyLoyaltyRedemption().
PaymentModal now allows partial payments for card and saved card:
- Added amount input fields with validation (max 2 decimal places)
- Default to full amount when left blank
- Validate amount is > 0 and <= totalDue
- Button label updates to show charge amount
- Validation errors shown inline
UserPaymentModal already had partial payment functionality, so no
changes needed there.
This allows customers to pay part of the balance now and pay the
rest later online.
The Email field was 50px while First Name/Last Name/Phone were 70px
because they contain Edit buttons with min-h-11 (44px). Changed Email
field to min-h-[70px] for consistent visual height across all profile
fields.
- Email field: add min-h-[44px] to match other profile fields height
- Remove /gdpr from Policies section (belongs in Data Privacy only)
- Remove /gdpr from footer (belongs in Data Privacy only)
- Button: add min-h-11 (44px) for mobile touch targets
- NavBar: add safe-area-inset-top for notched phones
- NavBar mobile menu: increase link padding from py-2 to py-3
- Footer: increase text size from text-xs to text-sm (16px minimum)
- Footer: add px-4 for better mobile spacing
- DatePicker: increase calendar cell size on mobile to 44px
- Checkbox: add p-3 -m-3 on mobile for 44px touch target (desktop unchanged)
- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate
- webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table
- cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password
- lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env
- erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added
- env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency
- auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO
- frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
- TWO_FACTOR_ALLOW_LOG_DELIVERY production opt-in removed from every doc: log delivery reframed as a local DEV ONLY feature while email/SMS is implemented
- Technical Manual: 2FA state + delivery, /api/verify/generate route table, future-work item
- Feature Catalog: SCA posture + verification-code feature
- payments and money processes: relay model + env var reference (removed)
- Overview, User Manual: delivery posture
- test counts, refresh-token grace, session lifetime, deposit advance, gift-card expiry, patch-test notice kept accurate
- TWO_FACTOR_ALLOW_LOG_DELIVERY production opt-in REMOVED: plaintext codes are written to the stdout log ([2FA]/[VERIFY]) only in dev/test builds as a local DEV ONLY feature while email/SMS delivery (P6) is implemented. Production builds have no delivery channel and code issuance fails closed (503) under any configuration — no silent log-based code leak
- verification/2FA codes hashed at rest (HMAC-SHA256 via TWO_FACTOR_PEPPER, CHAR(64)); [VERIFY] dev log relay; per-user brute-force budget; password_reset purpose clears lockout for self-service recovery; dummy-bcrypt on login no-user path kills timing oracle
- sabredav weak-password list + entropy gate; .env.example ships fail-closed DAV_ADMIN_PASSWORD
- delete-account re-auth (current_password + fresh 2FA code when enforced)
- prod-tag suite (run-prod-tag-tests.sh) compiles and runs the production 2FA issuance gate: production ALWAYS reports no delivery channel and refuses issuance after the pepper check
- startup_checks_test SNAPSHOT_ENC_KEY values built at runtime so gitleaks sees no secret-shaped literals
- env-docs parity updated (flag removed, 38 vars)
- README: payments/2FA sections rewritten for the SCA-only posture (no
TWO_FACTOR_FALLBACK, tokenize-result wire contract, 402 refusal), deposit
carve-out clarified, gift-card 12-hex codes + 14-day cancellation, flood-cap
insert sites enumerated, escalating lockout tiers documented, ICO
registration note, updated test counts (2,555 backend + 129 frontend).
- local-dev-2.sh: fail-closed dev secret bootstrap (auto-generates
JWT_SECRET_KEY / TWO_FACTOR_PEPPER into the gitignored .env), kills stale
backends holding :8080 before the tmux reset, passes
RUSTFS_ENDPOINT/GO_TESTING=1 to the dev backend, and backdates seeded patch
tests 60 days so past gel bookings pass the 24h notice gate.
- Obsidian manuals (Technical/Admin/User/Feature Catalog/Overview/Gift Card
T&C/Privacy/T&C/Testing Architecture/payments and money processes + p14 plan
+ workspace state) updated to the post-round-2 state.
Tests lock the TRUST_PROXY_HEADERS behavior: a trusted CF-Connecting-IP
becomes the rate-limit key (per-IP and per-user+IP), an origin-exposed header
on an untrusted proxy is ignored, and the unauthenticated per-user limiter
honors header mode. Mirrors the middleware contract (finding 4a / Loop B).
The frontend's cross-tab coordination (auth.svelte.ts REFRESH_LOCK_TTL_MS=15s +
20s wait-for-timeout) guarantees only ONE tab rotates and every sibling adopts
the rotated pair, so the only legitimately-arriving replays are same-tick
races (sub-second). The old 60s window handed a stolen refresh token a full
minute of freshness before reuse detection fired; 20s keeps comfortable margin
over the coordination bound while cutting the undetected-theft window to a
third. The ideal fix (kill only when the replay's IP/UA differs) still needs
rotation-origin persistence the locked schema cannot express.
- profile.go updateCardDAV now writes directly to the shared dav_cards table via
dav.Service (mirroring registration) instead of PUTting a vCard to
DAV_BASE_URL over HTTP — the Go backend no longer needs the SabreDAV URL,
only the sabredav PHP container uses the server-side credential.
- dav/types.go: ContactInput gains PhotoURL; GenerateVCard emits the
PHOTO;VALUE=URI line when set, so the profile photo syncs into the address
book. DAV_BASE_URL is retained in .env.example for reference only.
- till.go: the saved-card till charge carries the C6 consent fields and enforces
them on the (unreachable) 2FA fallback path; the card ownership SELECT became
owner-agnostic with the owner read at the gate (F1 — no charge surface can
act on a card it does not own); a till sale's gift-card creation/top-up now
runs under the SAME per-admin daily-cap advisory lock as the admin API
surfaces (F5) so two concurrent distinct sales cannot overshoot the £5,000
day ceiling; money-F4: an expired gift card can never be topped up (expiry
gate mirrors RedeemGiftCard's DB-clock comparison) — the top-up would
otherwise resurrect a card the nightly cleanup already forfeited.
- charge_helpers_test.go: TestResolveChargeSource_SCATokenizeResult_UsesTokenAsSource
pins the SCA tokenize-result wire contract (token as source, card row for the
customer).
- errors_test.go: token-less saved-card charges are refused 402
verification_required under 2FA enforcement (create-payment, gift-card buy +
save-card), even for a user with 2FA enabled — the homegrown gate can never
substitute for SCA.
- Terms & Conditions: DRAFT badge removed, updated to August 2026; SCA / 3-D
Secure disclosure (online + till refusal behaviour), chargeback section,
non-refundable-gift-card position, Liability and Acceptable Use sections.
- New /gift-card-terms route with the full gift-card position (14-day online
cancellation, non-refundable except as the law requires, SPV VAT treatment);
linked from the Terms page and the new footer.
- Footer now links Privacy Policy / Terms / Cancellation Policy / Gift Card
Terms / Your Data instead of a bare copyright line.
- GDPR export page: verification-codes card removed — verification codes are
authentication tokens excluded from the export, so the card could never
populate (parity with the backend scrub).
- square.ts: shouldFallbackTo2FA replaced by shouldShowSCARefusal — a genuine
'sca-unavailable' now drives the REFUSAL path (the customer is told the
payment cannot complete and to pay online later), never the 2FA code fallback
(PSR 2017 SCA is non-waivable; merchant liability is not cured by consent).
SCA_REFUSAL_MESSAGE_ONLINE/TILL copy added; SCA_FALLBACK_CONSENT_VERSION 'v1'
+ scaFallbackConsentFields() carry the versioned consent on the explicit
opt-in path only (shipped surfaces send none). SquareTokenizeResult docs
updated: tokenize-result token is the charge source, tokenless OK proceeds
token-less under the backend's SCA-only gate.
- C1 wire contract on every saved-card surface (booking, tip, gift-card buy,
till, account): the proactive SCA tokenize-result is sent as new_card_token
(the charge SOURCE alongside the saved-card ref), never the legacy
verification_token; 402 verification-required now means the tokenize-result
was consumed/expired between tokenize and charge.
- New ScaFallbackConsentDialog surfaces the refusal notice; the code input
(useTwoFactorCodeForSavedCard scaAvailable: () => true) only ever appears via
a backend gate rejection (defensive/opt-in).
- Till (M10): proactive saved-card SCA runs per sale line BEFORE the first
charge; sca-unavailable aborts the whole sale before any charge.
- Card save (M11/M12): STORE-intent tokenizeForStore with SCA at tokenization;
402 verification-required on save surfaces SCA-first guidance instead of a
generic failure.
- C5: new_booking, pending_booking, cancelled_booking and edit_requested admin
notifications are flood-capped per reason (pre-check logs suppression; atomic
fold inside the INSERT), so a booking/cancellation flood cannot bury the
operator's notification centre.
- C4: EvictPendingReleaseOverlapping no longer re-sells a slot over a
customer's money. Evicted pending_release bookings that carry a paid deposit
are refunded FIRST — through payments.ProcessCancellationRefundTx (exported
cancellation-refund machinery) inside the same transaction, full-refund
override (business is re-selling the slot) — and only THEN flipped to
'deposit_lapsed'. Rows are SELECTed FOR UPDATE first so the guard predicate
stays true; a refund failure aborts the eviction so the caller rolls the
whole transaction back. Card refunds record 'pending' and settle via the
pending-refund sweep post-commit.
- Reschedule-fee audit payload whitespace alignment fix.
- adminnotify: MaxUnacknowledgedCriticalLogs global cap exposed as
CriticalLogsCapExceeded — a pre-check helper every insert site pairs with the
atomic fold inside its INSERT (count-then-insert is atomic, closing the
TOCTOU where concurrent inserts could both read a below-cap count).
- jobs/cleanup.go ScanCriticalPaymentLogs: capped at the shared cap, pre-check
skips the scan and logs the suppression.
- scheduling: 1_week_no_pay, 1_month_no_pay, default_hours_changed,
deposit_not_paid_by_deadline and the Square-erasure critical notification all
flood-capped with pre-check + atomic fold (per-booking/per-user dedup kept).
- time-blockers.go CleanupExpiredGiftCards (M4): the expiry SELECT now runs
under FOR UPDATE row locks so the read-expired-then-zero window is atomic —
a concurrent top-up either commits before the SELECT (refreshed last_used_at
drops the card out of the predicate) or blocks until the sweep's tx ends and
revives the zeroed card via its own expiry refresh; the top-up value can
never be destroyed by the sweep.
- flood-cap tests added for 1_week_no_pay; adminnotify unit coverage added.
- MEDIUM-3a audit coverage: the refund sweep's re-issue of manual refund rows
now records the admin actor, payment, pence amount and reason under
action_type 'admin_refund' via the shared InsertAdminAuditCharge helper
(best-effort own-transaction, non-fatal; distinct from the booking-level
'admin_booking_refund'); legacy rows with NULL created_by fail harmlessly.
- C5 flood cap: the unacknowledged 'refund_failed' notification queue is capped
at adminnotify.MaxUnacknowledgedCriticalLogs — pre-check logs the suppression,
the fold inside the INSERT enforces it atomically, and the (reason,
booking_id) NOT EXISTS dedup is preserved.
- M6: RevertGiftCardFunding no longer silently drops unreclaimable money. A
partially-spent create/top-up claws back everything still on the card/balance
(GREATEST(0, ...) clamp instead of the old guarded 0-row block), inserts a
CRITICAL admin notification, and returns errClawbackPartiallyReversed so every
caller (till handler, stale-pending sweep, webhook) surfaces the residual
without forking the money logic; balance comparisons use pence (penceLess).
- M7: the £5,000/day admin gift-card value cap (create/top-up/transfer) is now
serialized per-admin under a bounded advisory try-lock
(acquireGiftCardDailyCapLock) so two concurrent operations cannot both read
the day's value before either writes and over-issue value.
- M4/M3: every gift-card expiry comparison now reads the DATABASE clock
(giftCardExpired -> SELECT NOW()), the same clock that wrote expiry_date, so
app-clock drift can neither extend nor shorten card life; applied on redeem,
cancellation assessment, cancel-for-user and the reversal re-verification.
- C6 consent fields carried on BuyGiftCardRequest and enforced on the
(now unreachable) 2FA fallback audit path; fallback audit row captures the
versioned consent.
- M2: a stale pending payment COMPLETED at Square on a cancelled/lapsed/no-show
booking no longer just fails the row + admin-notifies: an automatic pending
refund row for the full stranded charge is created (same shape/origin as
ProcessCancellationRefundTx, deterministic idempotency key, square_payment_id
written when missing) so the pending-refund sweep issues it at Square.
- M5: sweep rescues re-apply VAT — rescued till sales run ApplyVATToTillSale and
rescued payments apply ApplyVATToBookingPayment per record after the align
UPDATE (which no longer NULLs the VAT fields), keeping rescued charges in VAT
reporting. Both SQL functions are idempotent (guarded on vat_amount IS NULL).
- M3: every age-guard cutoff in the sweep is computed from clock.Now() and
passed into SQL as parameters (never a DB NOW()-derived comparison) so the
23h/24h Square idempotency-key retention decision cannot flip on clock skew;
replayRescueUpperBoundSkew (5s) stops a legit same-key retry that raced the
sweep from being misclassified as the sweep's own replay-created duplicate.
- C2: till cash/giftcard charges now serialize under the same
crussell:payment:<bookingID> advisory lock as the online path (bounded
try-lock) so remaining-balance checks can never both pass.
- webhooks_completion_asymmetry_test: webhook-first completion + sweep rescue
double-complete race locked end-to-end through the real handler.
The CURRENT saved-card SCA contract (Square card.tokenize(verificationDetails,
cardId)) returns a one-time tokenize-result that must be sent as the charge
SOURCE (source_id), not a separate verification_token.
- square_dev.go: the mock validates the WIRE BODY (mockPaymentWireBody — an
independently assembled copy of buildCreatePaymentBody) so it accepts exactly
the request shape the real client emits. SimulateSavedCardVerificationRequired
now demands SCA on every saved-card charge in both wire shapes: (a) a genuine
tokenize-result (cnon:sca-... — isSCATokenizeResultSource) as source_id +
customer_id is ACCEPTED (the token IS the buyer verification); a RAW
card.tokenize() nonce in the tokenize-result slot is REJECTED
CARD_DECLINED_VERIFICATION_REQUIRED (money-F2 — the mock is the enforcement
point that stops the forged shape); (b) legacy ccof: + verification_token is
kept for backward-compat.
- square_http_client.go: byte-identical body assembly shared with the mock, so
TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical pins the mock and the
real client emit identical CreatePayment bodies (a wire drift fails the test
before reaching prod).
PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated
stored-credential charges; a merchant-side 2FA check cannot legally substitute
for it (authorising a token-less charge via 2FA leaves the MERCHANT liable for
ECI 7 / SLI 210 chargebacks and reg 77(6) compensation regardless of consent).
- payments/twofa.go: the homegrown 2FA fallback for token-less saved-card
charges is REMOVED ENTIRELY. requireTwoFactorForCardAccess is now SCA-only:
a non-empty Square verification_token (charge surfaces, token forwarded to
Square) skips the gate; anything else is refused 402 verification_required.
enforceSCAFallbackConsent is a compile-compatible no-op (fallback never runs).
- New requireTwoFactorForCardAccessWithTokenValidation distinguishes surfaces
where the token IS forwarded to Square (charge — Square validates it) from
card-SAVE surfaces (token client-asserted, never forwarded: a non-empty token
must NOT skip the save gate, auth-F1).
- SCA tokenize-result wire contract (C1): a saved card charged with a fresh
one-time tokenize-result sends the token as the charge SOURCE (new_card_token
-> source_id) alongside saved_card_id, never a separate verification_token.
resolveChargeSource resolves the saved-card branch FIRST (customer from the
card row, token as source) so combined token+card requests are SCA-clean.
- C6 consent fields (consent_version / consent_accepted) added to the booking/
tip/till/gift-card charge requests, enforced server-side before any fallback
charge could reach Square and recorded on the 2fa_fallback_charge audit row;
logVerificationTokenProvenance traces minted tokens to their charge.
- user 2FA issuance gate refactored into pure build-agnostic functions
(twoFAPepperConfigured / twoFADeliveryChannelConfigured /
twoFAEnsureIssueAllowedStrict) shared with the payments re-issue path and
exercised directly by the test,dev suite; TWO_FACTOR_FALLBACK switch and
.env.example entry removed; startup posture notes updated.
- Test coverage: fail-closed 2FA production gates (pepper/delivery), token
validation on save vs charge surfaces, completion idempotency, idempotency
key determinism, refund-policy 72h/24h epsilon boundaries, VAT parity.
F5.5 brute-force hardening:
- internal/twofa.Check consume is now a conditional UPDATE (WHERE id AND
two_factor_pending_code_hash) reporting rows affected: two concurrent
verifications of the same code on different instances both match the digest,
but only the first conditional UPDATE can affect a row — the loser sees 0
rows and fails MissingOrExpired, so one code authorizes exactly ONE operation
across instances (the per-user mutex only serialized within one process).
- /login lockout is now indistinguishable from a wrong password: a locked
account returns the same uniform 401 'invalid credentials' and burns the same
constant-time bcrypt compare (via the shared semaphore), removing the
account-existence oracle and lockout-probing signal of the old 429.
- Lockout tiers escalate 15m (5+) / 30m (7+) / 60m (10+): an attacker who keeps
guessing past each unlock makes the lock LONGER, raising the repeat-DoS
effort while the response stays uniform.
Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed:
MONEY:
- CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) —
a rejected auto-refund no longer re-replays the expired key every sweep run
(which minted a stacking unauthorized charge each time); FAILED-webhook
demotion respects the cap; never re-replay a key whose B1 refund failed
- HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible
campaign discount rows immediately (capped) instead of skipping with no
discount recorded — no more promised-discount-not-recorded overcharge
- MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed
new-card+save_card charges (re-issue guard now covers req.SaveCard)
- LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining
now matches the authoritative tip-excluded balance)
SECURITY:
- MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap
on critical_payment_log + refresh_token_reuse rows)
- MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer
clears LastMintAt on gate-verify; cleared on terminal charge success)
- MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked
state instead of a fresh 5-guess budget per request
- MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs
instead of sleeping unboundedly; login bcrypt concurrency semaphore added
- LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check;
email-verification per-user attempt counter
DUP/MOD:
- formatCurrency single source (frontend format.ts, 7 files consolidated);
SquareRefundStatusToLocal single source (errors.go, all sites); admin
audit-log helper dedup; SCA retry model unified (proactive on all 6
surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from
backend; generateUUID at all card-form sites; magic numbers named
(defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card
terminal charges now audited; DAV_SKIP_INIT documented in manuals
Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet
tags, frontend tests+build, env-docs 42/42.
Quick wins from the Loop B close-out:
- backend/internal/dav/service_prod.go: package-level init() connected to
postgres and panicked when the DB was unreachable — breaking bare-shell
'go test -tags test,!dev' and any CI test-prod run without a live service.
init() now skips connecting under GO_TESTING (the pipeline sets it) or
DAV_SKIP_INIT, and defaults POSTGRES_HOST to 127.0.0.1 (the pipeline
postgres-service address, matching testutils/testdb) so real prod builds
still connect. The test suite wires its own pool in TestMain.
- README.md:21: corrected stale 'Gift card SPV/MPV VAT treatment configurable'
to the SPV-only posture (a stored MPV is overridden to SPV at read time).
Verified: 26/26 dev packages, both vet tags clean.