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:
@@ -941,7 +941,11 @@ jobs:
|
||||
run: apk add --no-cache docker-cli docker-compose
|
||||
|
||||
- name: Create env file for compose validation
|
||||
run: cp .env.example backend/.env
|
||||
# compose.yml reads ./.env at the REPO ROOT (env_file paths resolve
|
||||
# relative to the compose file — compose.yml:25), not backend/.env.
|
||||
# A backend/.env file would leave the interpolation vars unset and the
|
||||
# config validation would not exercise the real file the stack uses.
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Validate compose.yml
|
||||
run: docker compose -f compose.yml config --quiet
|
||||
|
||||
@@ -12,6 +12,7 @@ paths = [
|
||||
# Test fixtures with mock data
|
||||
"backend/testutils/",
|
||||
"backend/handlers/.*_test.go",
|
||||
"backend/internal/square/.*_test.go",
|
||||
# Example env file with placeholder values
|
||||
".env.example",
|
||||
# SabreDAV dependency files
|
||||
|
||||
@@ -72,6 +72,15 @@ Notes:
|
||||
- Spot-checks on 13 Aug 2026 confirmed the tree matches the recorded statuses for C1–C3, H1–H5, B1–B11, D7, D9, D10, D11, D12, D13. Any residual uncertainty is limited to the exact frontend styling diffs (F1–F14, M1–M3), which batch-1 verified; treat those as "verify on final visual pass" if in doubt.
|
||||
- The only genuinely open item is **T1** (frontend automated tests), scheduled for batch 2.
|
||||
|
||||
### Round A re-review (14 Aug 2026)
|
||||
|
||||
A fresh-eyes round-A review (legal/ops/docs + money-path) of commit `e9315c9` re-opened **two entries this table marked RESOLVED**:
|
||||
|
||||
- **F1 — admin overcharge (re-opened).** The round-1 frontend F1–F14 batch (touch-target sizing etc.) is verified, but the round-A **money-path** review found a separate live money bug still present in the same admin payment surface: campaign credit can be applied on terminal/cash/saved-card admin payments (`CreateTerminalPayment` / till / saved-card paths), producing a charge that exceeds the remaining booking balance. Owned by the money-path fix round; not closed by the round-1 batch.
|
||||
- **/terms tiers & refund-method wording (D6/D10 — re-opened).** Round-1 marked the terms-page wording reconciliation (D6) and "refunds to the original payment method" verification (D10) RESOLVED, but the round-A legal/docs review found the `/terms` route still contradicted the code: §3 claimed the deposit is forfeited only under 24 hours' notice (the code keeps up to 50% of the subtotal on 24–72h notice and everything under 24h — `CalculateRefundForCancellation`, `handlers/payments/refunds.go`), and §4 overbroadly claimed refunds go to "the original payment method" (cash refunds are credited to the account balance, gift-card refunds to the balance when no `gift_card_id`, only card goes back via Square). The `/terms` route has now been aligned to the verified three-tier schedule and per-method refund disclosures (Aug 2026).
|
||||
|
||||
No other RESOLVED entries were contradicted by round A.
|
||||
|
||||
---
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
@@ -4,7 +4,7 @@ Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL
|
||||
|
||||
## Features
|
||||
|
||||
**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: Centralised cron scheduler (`backend/internal/jobs/`) runs 25 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, staged default hours auto-apply, GDPR anonymization, financial aggregation, and token/code cleanup. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation (`closing_time.go`) resolves both current and staged default hours.
|
||||
**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: Centralised cron scheduler (`backend/internal/jobs/`) runs 26 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, staged default hours auto-apply, GDPR anonymization, financial aggregation, and token/code cleanup. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation (`closing_time.go`) resolves both current and staged default hours.
|
||||
|
||||
**Payments**: Square Terminal (in-person, via `CreateTerminalCheckout`) + online card payments via saved cards or new cards tokenized through the Square Web Payments SDK (`cnon:` nonces — new-card entry falls back to `CardEntryUnavailable` only when neither mock mode nor Square credentials are configured). The backend accepts only tokens, never raw PANs (PCI-DSS parity, mirrored in the dev mock). Cash till sales record the gift-card value and are marked completed, with no tendered/change fields. Any change or overpayment is handled manually by the admin at the counter. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment >20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first 50% of each payment is always carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. A bounded PostgreSQL advisory try-lock (`pg_try_advisory_lock`, ~30 × 100ms ≈ 3s bound) serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record (same-key retries reuse it). Three background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds (with a 23h age guard), `sweep-stale-pending-payments` fails stale pending payments/till-sales so a late retry cannot issue a second charge, and `sweep-stale-terminal-checkouts` cancels card-machine checkouts still pending at Square after an hour so a never-polled checkout cannot complete into an invisible, untracked charge.
|
||||
|
||||
@@ -62,6 +62,8 @@ docker compose up --build -d
|
||||
|
||||
`SQUARE_ALLOW_REAL_API` is a dev-build safety valve: a `//go:build dev` build **HARD-FAILS** (panics) when `SQUARE_ENVIRONMENT=production` unless this is set to `1`, so a typo'd or leftover production value in a dev shell cannot create real charges. Sandbox is allowed in a dev build (with a loud banner). Never set it in a deployed production build.
|
||||
|
||||
`TRUST_PROXY_HEADERS` (backend `.env`) defaults to `false`. The backend sits behind a trusted proxy in every real deployment — the nginx in `compose.yml` and/or the Cloudflare edge — which overwrites `X-Real-IP` / `CF-Connecting-IP` with the real client IP. Set `TRUST_PROXY_HEADERS=true` for those deployments: without it every per-IP rate-limit key collapses onto the proxy's IP, so any one client can exhaust the shared per-IP budget and throttle the whole surface for everyone (and per-IP limiter protection is effectively bypassed). Keep it `false` only when the backend is origin-exposed. `compose.yml` deliberately never sets it — the operator decides per deployment (the value is passed through the repo-root `.env` via `env_file`).
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| Frontend | http://localhost |
|
||||
@@ -95,7 +97,7 @@ Default logins (password: `password`):
|
||||
```bash
|
||||
cd backend && go build -o bin/backend ./main.go
|
||||
cd frontend && npm ci && npm run build
|
||||
cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,269 tests compiled under the test,dev tags, as of 13 Aug 2026 (~2min)
|
||||
cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,333 tests compiled under the test,dev tags, as of 14 Aug 2026 (~2min)
|
||||
cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min)
|
||||
# NOTE: -count=N>1 is unreliable for handlers/payments and handlers/webhooks —
|
||||
# those suites share package-global state (Square mock ledger, in-memory webhook
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -103,10 +104,18 @@ func createTestCampaign(t *testing.T, name, campaignType string, percent float64
|
||||
|
||||
func insertInPersonCardPayment(t *testing.T, bookingID string, ctx context.Context) {
|
||||
t.Helper()
|
||||
_, err := db.Conn.Exec(ctx, `
|
||||
// F1 fixture: the in-person card payment covers the DEPOSIT (half the
|
||||
// booking total), not the full amount. A full-amount fixture would now
|
||||
// trigger the completion-time over-credit cap (capDiscountToRemainingObligation),
|
||||
// which correctly skips the stacked campaign discounts these tests assert.
|
||||
var total float64
|
||||
err := db.Conn.QueryRow(ctx, `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&total)
|
||||
require.NoError(t, err)
|
||||
deposit := math.Round(total*100/2) / 100
|
||||
_, err = db.Conn.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
VALUES ($1, 'full', 'in_person_card', $2, 'completed', NOW(), NOW())
|
||||
`, bookingID, deposit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crussell/db"
|
||||
"errors"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -126,24 +127,35 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
|
||||
`).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
|
||||
`, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
// F1: never over-credit at completion. The admin "Take Payment"
|
||||
// flow can charge the FULL amount while a campaign is still
|
||||
// eligible — the discount must be capped (or skipped when real
|
||||
// money already covers the total) so paid + discounts never exceed
|
||||
// the booking total.
|
||||
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
|
||||
discountAmount = capped
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, userID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
|
||||
`, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, campaignID); err != nil {
|
||||
log.Printf("ALERT: failed to update discount campaign usage: %v", err)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, userID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, campaignID); err != nil {
|
||||
log.Printf("ALERT: failed to update discount campaign usage: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Skipping time_based campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", campaignID, bookingID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,22 +179,28 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
|
||||
|
||||
if milestoneCampaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
|
||||
`, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, userID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, milestoneCampaignID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
// F1 over-credit guard — see the time-based block above.
|
||||
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
|
||||
discountAmount = capped
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
|
||||
`, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, userID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, milestoneCampaignID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Skipping per-user milestone campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", milestoneCampaignID, bookingID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,22 +235,28 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
|
||||
|
||||
if globalCampaignID != "" {
|
||||
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
|
||||
`, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, userID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, globalCampaignID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
// F1 over-credit guard — see the time-based block above.
|
||||
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
|
||||
discountAmount = capped
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
|
||||
`, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, userID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, globalCampaignID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Skipping global milestone campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", globalCampaignID, bookingID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,22 +306,28 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
|
||||
}
|
||||
if matches {
|
||||
discountAmount := roundTo2(bookingTotal * c.pct / 100)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
|
||||
`, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, userID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, c.id); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
// F1 over-credit guard — see the time-based block above.
|
||||
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
|
||||
discountAmount = capped
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
|
||||
`, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount); err != nil {
|
||||
log.Printf("ALERT: failed to insert booking discount: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
||||
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
||||
`, bookingID, discountAmount, userID); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
||||
`, c.id); err != nil {
|
||||
log.Printf("ALERT: failed to insert payment record: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Skipping anniversary campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", c.id, bookingID)
|
||||
}
|
||||
break // apply longest matching only
|
||||
}
|
||||
@@ -369,6 +399,70 @@ func bookingIsFullyPaid(ctx context.Context, q db.Querier, bookingID string) boo
|
||||
return fullyPaid
|
||||
}
|
||||
|
||||
// discountHeadroomPence returns how much of the booking's total obligation is
|
||||
// still uncovered — the largest a NEW discount row may carry before the ledger
|
||||
// over-credits the customer (F1). Over-credit records real money + discounts
|
||||
// beyond the booking total, minting an orphaned credit the refund system can
|
||||
// never return: the admin "Take Payment" flow (frontend PaymentModal) sends
|
||||
// payment_type='full' with the FULL amount (subtotal minus discounts already
|
||||
// applied client-side), while applyEligibleCampaignsAtPayment auto-applies any
|
||||
// eligible campaign — without this guard the ledger would record £55 against a
|
||||
// £50 total. The correct fix is the frontend sending the discounted amount
|
||||
// (as the customer modal already does); this headroom computation is the
|
||||
// server-side money-safety half that caps/skips the discount instead.
|
||||
//
|
||||
// Headroom is:
|
||||
//
|
||||
// total - (completed real payments + completed discount rows + pending charge)
|
||||
//
|
||||
// where "real" excludes tip / discount / on_the_house rows (the same
|
||||
// classification bookingIsFullyPaid uses). The pending charge is the payment
|
||||
// completing in the caller's transaction, whose amount is not yet a completed
|
||||
// row when applyEligibleCampaignsAtPayment runs — it is read from the pending
|
||||
// row's stored amount (the amount the charge is being recorded at, i.e.
|
||||
// req.Amount, which is what the charge will settle for). A failed read returns
|
||||
// 0 (conservative: skip rather than over-credit).
|
||||
func discountHeadroomPence(ctx context.Context, q db.Querier, bookingID string) int64 {
|
||||
var totalPence, realPaidPence, discountPence, pendingPence int64
|
||||
err := q.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE(ROUND((SELECT total_amount FROM bookings WHERE id = $1) * 100), 0),
|
||||
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'completed'
|
||||
AND payment_type != 'tip' AND payment_method NOT IN ('discount', 'on_the_house')) * 100), 0),
|
||||
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'completed'
|
||||
AND payment_method = 'discount') * 100), 0),
|
||||
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'pending') * 100), 0)
|
||||
`, bookingID).Scan(&totalPence, &realPaidPence, &discountPence, &pendingPence)
|
||||
if err != nil {
|
||||
log.Printf("Failed to compute discount headroom for booking %s: %v", bookingID, err)
|
||||
return 0
|
||||
}
|
||||
headroom := totalPence - realPaidPence - discountPence - pendingPence
|
||||
if headroom < 0 {
|
||||
return 0
|
||||
}
|
||||
return headroom
|
||||
}
|
||||
|
||||
// capDiscountToRemainingObligation caps a discount amount (pounds) so the
|
||||
// booking's ledger never over-credits: real money paid + discounts recorded +
|
||||
// the charge in flight must never exceed the booking total. Returns the capped
|
||||
// amount and whether the discount may still be applied; a false second return
|
||||
// means real money already covers the obligation and the discount must be
|
||||
// skipped entirely (applying it would mint a phantom credit). The capped value
|
||||
// is the headroom in pence, so it can never round up past the obligation.
|
||||
func capDiscountToRemainingObligation(ctx context.Context, q db.Querier, bookingID string, discountAmount float64) (float64, bool) {
|
||||
discountPence := int64(math.Round(discountAmount * 100))
|
||||
headroom := discountHeadroomPence(ctx, q, bookingID)
|
||||
if discountPence <= headroom {
|
||||
return discountAmount, true
|
||||
}
|
||||
if headroom <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return float64(headroom) / 100.0, true
|
||||
}
|
||||
|
||||
// completeActiveBookingFromPayment transitions an active booking to
|
||||
// 'completed' and runs the completion side-effects, all within tx. It is a
|
||||
// no-op if the booking is not in an active (completable) status, so cancelled,
|
||||
|
||||
@@ -363,13 +363,13 @@ func TestBookingPayment_ConcurrentPartials_SingleCharge(t *testing.T) {
|
||||
|
||||
// The fixture service costs £50, so the booking's remaining balance is 5000
|
||||
// pence. Two £30 partials sum to £60 > £50 — only one may succeed.
|
||||
var remainingCents int64
|
||||
var remainingPence int64
|
||||
if err := db.Conn.QueryRow(context.Background(),
|
||||
`SELECT ROUND(total_amount * 100)::bigint FROM bookings WHERE id = $1`, bookingID).Scan(&remainingCents); err != nil {
|
||||
`SELECT ROUND(total_amount * 100)::bigint FROM bookings WHERE id = $1`, bookingID).Scan(&remainingPence); err != nil {
|
||||
t.Fatalf("failed to read booking total: %v", err)
|
||||
}
|
||||
if remainingCents != 5000 {
|
||||
t.Fatalf("expected fixture booking total of 5000 pence, got %d", remainingCents)
|
||||
if remainingPence != 5000 {
|
||||
t.Fatalf("expected fixture booking total of 5000 pence, got %d", remainingPence)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
@@ -443,8 +443,8 @@ func TestBookingPayment_ConcurrentPartials_SingleCharge(t *testing.T) {
|
||||
bookingID).Scan(&paidPence); err != nil {
|
||||
t.Fatalf("failed to sum paid amount: %v", err)
|
||||
}
|
||||
if paidPence > remainingCents {
|
||||
t.Errorf("overpayment recorded: paid %d pence exceeds remaining balance %d pence", paidPence, remainingCents)
|
||||
if paidPence > remainingPence {
|
||||
t.Errorf("overpayment recorded: paid %d pence exceeds remaining balance %d pence", paidPence, remainingPence)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -513,10 +513,12 @@ func TestDiscountPreview_PaymentLock(t *testing.T) {
|
||||
t.Fatalf("failed to create campaign: %v", err)
|
||||
}
|
||||
|
||||
// First payment — discount should be applied
|
||||
// First payment — discount should be applied. A £10 deposit on the £50
|
||||
// booking leaves enough headroom for the 10% (£5) discount to pass the F1
|
||||
// over-credit cap (a full/overpaid fixture would now correctly skip it).
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'deposit', 'online_square', 1000, 'completed', NOW(), NOW())
|
||||
VALUES ($1, 'deposit', 'online_square', 10.00, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create first payment: %v", err)
|
||||
@@ -531,10 +533,12 @@ func TestDiscountPreview_PaymentLock(t *testing.T) {
|
||||
t.Errorf("expected 1 discount after first payment, got %d", discountCount)
|
||||
}
|
||||
|
||||
// Second payment — NO new discounts should be added (lock active)
|
||||
// Second payment — NO new discounts should be added (lock active). The £40
|
||||
// balance completes the £50 booking; the 2-completed-payment lock in
|
||||
// ComputeEligibleDiscounts is what refuses the second application.
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'full', 'online_square', 4000, 'completed', NOW(), NOW())
|
||||
VALUES ($1, 'full', 'online_square', 40.00, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create second payment: %v", err)
|
||||
|
||||
@@ -652,7 +652,31 @@ func TestBookingPayment_Overflow_PostStart_Succeeds(t *testing.T) {
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&payCount); err != nil {
|
||||
t.Fatalf("failed to count payments: %v", err)
|
||||
}
|
||||
if payCount != 1 {
|
||||
t.Errorf("expected exactly 1 completed payment after the post-start overflow, got %d", payCount)
|
||||
if payCount != 2 {
|
||||
t.Errorf("expected 2 completed payments after the post-start overflow (booking portion + F3 tip carve), got %d", payCount)
|
||||
}
|
||||
|
||||
// F3: the £10 overflow beyond the £50 booking is carved as its own
|
||||
// payment_type='tip' record (gratuity), mirroring buildTerminalSplitRecords.
|
||||
var tipCount int
|
||||
var tipAmount float64
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&tipCount, &tipAmount); err != nil {
|
||||
t.Fatalf("failed to query tip records: %v", err)
|
||||
}
|
||||
if tipCount != 1 {
|
||||
t.Errorf("expected exactly 1 tip record for the post-start overflow, got %d", tipCount)
|
||||
}
|
||||
if tipAmount < 9.995 || tipAmount > 10.005 {
|
||||
t.Errorf("expected the tip to equal the £10 overflow, got %.2f", tipAmount)
|
||||
}
|
||||
|
||||
// The booking portion is the remaining £50 (payment_type='full', the
|
||||
// original request type — no deposit/balance split post-start).
|
||||
var bookingPortion float64
|
||||
if err := tx.QueryRow(ctx, `SELECT amount FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'full'`, bookingID).Scan(&bookingPortion); err != nil {
|
||||
t.Fatalf("failed to query booking portion: %v", err)
|
||||
}
|
||||
if bookingPortion < 49.995 || bookingPortion > 50.005 {
|
||||
t.Errorf("expected the booking portion to be £50.00, got %.2f", bookingPortion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1278,6 +1278,20 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.Status == "completed" {
|
||||
// RE-VALIDATE the matched purchase payment's refund state before
|
||||
// reporting it as success (same guard as the CreateBookingPayment
|
||||
// completed-dedup branches): a refunded purchase's money is no
|
||||
// longer live, and a same-key retry must not claim the purchase
|
||||
// succeeded when the money was already returned.
|
||||
if refunded, rErr := paymentHasLiveRefund(ctx, db.Conn, existing.ID); rErr != nil {
|
||||
log.Printf("Failed to re-validate gift-card dedup hit %s against refunds: %v", existing.ID, rErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if refunded {
|
||||
log.Printf("Gift card retry rejected: purchase payment %s (key %q) was refunded — refusing to report a refunded purchase as success", existing.ID, req.IdempotencyKey)
|
||||
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(existing); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
@@ -1292,6 +1306,19 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Amount does not match the pending gift card payment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Defense-in-depth refund guard on the pending-reuse path too: a
|
||||
// pending purchase cannot normally carry a refund (refunds attach
|
||||
// to completed charges), but if one ever exists the money is in
|
||||
// flight/returned and re-attempting the charge must not proceed.
|
||||
if refunded, rErr := paymentHasLiveRefund(ctx, db.Conn, existing.ID); rErr != nil {
|
||||
log.Printf("Failed to re-validate pending gift-card reuse %s against refunds: %v", existing.ID, rErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if refunded {
|
||||
log.Printf("Gift card retry rejected: pending purchase payment %s (key %q) was refunded — refusing to reuse a refunded payment", existing.ID, req.IdempotencyKey)
|
||||
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
reusePendingID = existing.ID
|
||||
log.Printf("[PAYMENTS] Reusing pending payment %s for idempotent gift-card retry (key %s)", existing.ID, req.IdempotencyKey)
|
||||
}
|
||||
@@ -1522,6 +1549,16 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
// with an IDENTICAL body under the same key — Square compares the whole
|
||||
// request on key reuse, and a reconstructed body returns
|
||||
// IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
|
||||
//
|
||||
// The write is INTENTIONALLY unconditional (WHERE id = $2, no
|
||||
// snapshot-is-null guard like the booking/tip/terminal flows): the reuse
|
||||
// branch above (B6) already refreshed square_request_snapshot in the SAME
|
||||
// transaction as the square_source_id refresh, and this post-commit write
|
||||
// stores the fresh full body for THIS attempt. Both paths converge on a
|
||||
// correct snapshot, so a guard would either be dead (first attempt) or
|
||||
// wrongly skip this write on the reuse path when the in-tx refresh failed
|
||||
// best-effort. Do NOT "fix" this into the guarded form without reworking
|
||||
// the reuse-branch refresh.
|
||||
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
||||
log.Printf("Failed to marshal square_request_snapshot for gift-card payment %s: %v", buyPaymentID, mErr)
|
||||
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
|
||||
|
||||
@@ -612,7 +612,13 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// deterministic key).
|
||||
scKey := req.IdempotencyKey
|
||||
if scKey == "" {
|
||||
scKey = bookingID + "-sc-" + req.PaymentType + "-" + strconv.FormatInt(amount, 10) + "-" + *req.UserSavedCardID
|
||||
// The candidate is built verbatim, then routed through
|
||||
// truncateIdempotencyKey so it can never exceed Square's 45-char
|
||||
// /v2/payments limit (a 400 would strand the payment). The truncation
|
||||
// is deterministic, so identical inputs still derive the SAME key and
|
||||
// the dedup SELECT below keeps working; candidates at or under 45
|
||||
// chars (the current bookingID+cardID shape) pass through byte-identical.
|
||||
scKey = truncateIdempotencyKey("sc", bookingID+"-sc-"+req.PaymentType+"-"+strconv.FormatInt(amount, 10)+"-"+*req.UserSavedCardID)
|
||||
}
|
||||
|
||||
// Idempotency switch inside the lock: completed → dedup; pending →
|
||||
@@ -627,7 +633,20 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
paymentID := ""
|
||||
switch {
|
||||
case err == nil && existingStatus.String == "completed":
|
||||
// Dedup — return the existing completed payment.
|
||||
// Dedup — return the existing completed payment. First RE-VALIDATE
|
||||
// the matched row's refund state (same guard as the CreateBookingPayment
|
||||
// completed-dedup branches): a refunded payment's money is no longer
|
||||
// live, so reporting it as "success" would let a same-key retry claim
|
||||
// a payment that was already returned to the customer.
|
||||
if refunded, rErr := paymentHasLiveRefund(r.Context(), db.Conn, existingID.String); rErr != nil {
|
||||
log.Printf("Failed to re-validate saved-card dedup hit %s against refunds: %v", existingID.String, rErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if refunded {
|
||||
log.Printf("Payment retry rejected: saved-card payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, scKey)
|
||||
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(CheckoutResponse{
|
||||
CheckoutID: existingID.String,
|
||||
Status: "COMPLETED",
|
||||
@@ -812,6 +831,16 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// F6: a fully-paid saved-card charge completes the booking exactly like
|
||||
// the terminal path (recordTerminalPaymentTx → completeFullyPaidBooking,
|
||||
// sweep.go:1632). Runs in its OWN transaction after the status commit
|
||||
// above, so the completion side-effects (loyalty, campaign discounts,
|
||||
// deposits_required) are atomic and a booking paid in full by a
|
||||
// saved-card charge leaves the admin's Current Appointment view. The
|
||||
// eligible-discount application happens inside the completion
|
||||
// side-effects, guarded by the same over-credit cap as every other path.
|
||||
completeFullyPaidBooking(r.Context(), bookingID)
|
||||
|
||||
// Return the card details the frontend reads for the success state
|
||||
// (MINOR-R2) — CheckoutResponse alone leaves card_brand/card_last4 blank.
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
@@ -1349,13 +1378,13 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// for how the fallback distinguishes "same live operation retried" (dedup)
|
||||
// from "new operation that happens to have equal amount" (new charge).
|
||||
if req.PaymentType == "partial" {
|
||||
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
|
||||
remainingPence, err := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get remaining balance: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := ValidatePartialAmount(req.Amount, remainingCents); err != nil {
|
||||
if err := ValidatePartialAmount(req.Amount, remainingPence); err != nil {
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
@@ -1512,13 +1541,13 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// already committed — reject before any pending record is inserted or
|
||||
// Square is hit.
|
||||
if req.PaymentType == "partial" {
|
||||
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
|
||||
remainingPence, err := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get remaining balance: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := ValidatePartialAmount(req.Amount, remainingCents); err != nil {
|
||||
if err := ValidatePartialAmount(req.Amount, remainingPence); err != nil {
|
||||
log.Printf("Payment rejected: %v", err)
|
||||
http.Error(w, "Partial amount exceeds remaining balance", http.StatusConflict)
|
||||
return
|
||||
@@ -1638,9 +1667,9 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
if err := tx.QueryRow(r.Context(), `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal); err != nil {
|
||||
log.Printf("Failed to load booking total for discount computation: %v", err)
|
||||
}
|
||||
var eligibleDiscountCents int64
|
||||
var eligibleDiscountPence int64
|
||||
for _, d := range ComputeEligibleDiscounts(r.Context(), tx, bookingID, userID, bookingTotal) {
|
||||
eligibleDiscountCents += int64(math.Round(d.Amount * 100))
|
||||
eligibleDiscountPence += int64(math.Round(d.Amount * 100))
|
||||
}
|
||||
|
||||
// M4/M7: cap pay-early at 100%. A payment that exceeds the booking's
|
||||
@@ -1659,14 +1688,14 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// within the discounted remaining is covered by the discount — it is NOT an
|
||||
// overflow into tip territory.
|
||||
if req.PaymentType != "tip" {
|
||||
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
|
||||
remainingPence, err := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get remaining balance: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
discountedRemainingCents := remainingCents + eligibleDiscountCents
|
||||
if req.Amount > discountedRemainingCents {
|
||||
discountedRemainingPence := remainingPence + eligibleDiscountPence
|
||||
if req.Amount > discountedRemainingPence {
|
||||
var bookingStartTime time.Time
|
||||
if sErr := db.Conn.QueryRow(r.Context(), `SELECT start_time FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStartTime); sErr != nil {
|
||||
log.Printf("Failed to get booking start time: %v", sErr)
|
||||
@@ -1674,14 +1703,14 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !req.ConfirmOverflowTip && !bookingStartTime.Before(clock.Now()) {
|
||||
log.Printf("Overflow requires confirmation: amount %d exceeds discounted remaining %d for booking %s (not started, not confirmed)", req.Amount, discountedRemainingCents, bookingID)
|
||||
log.Printf("Overflow requires confirmation: amount %d exceeds discounted remaining %d for booking %s (not started, not confirmed)", req.Amount, discountedRemainingPence, bookingID)
|
||||
mw.RespondJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "The extra amount will be recorded as a tip. Confirm to continue.",
|
||||
"code": "overflow_tip_confirmation_required",
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("Overflow accepted as tip: amount %d exceeds discounted remaining %d for booking %s (confirmed=%v)", req.Amount, discountedRemainingCents, bookingID, req.ConfirmOverflowTip)
|
||||
log.Printf("Overflow accepted as tip: amount %d exceeds discounted remaining %d for booking %s (confirmed=%v)", req.Amount, discountedRemainingPence, bookingID, req.ConfirmOverflowTip)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1696,9 +1725,28 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// charge here: the deposit is charged at req.Amount minus the discount and
|
||||
// the residual balance payment settles the rest, so the total across the
|
||||
// deposit→balance flow is the discounted price.
|
||||
//
|
||||
// ADMIN-FLOW ASYMMETRY (F1): the admin "Take Payment" PaymentModal sends
|
||||
// payment_type='full' with the FULL amount (subtotal minus discounts
|
||||
// ALREADY applied, no client-side campaign preview) — it does NOT
|
||||
// pre-subtract an eligible campaign. That means a full admin charge is NOT
|
||||
// reduced below, and applyEligibleCampaignsAtPayment would auto-apply the
|
||||
// campaign → ledger £55 vs £50 total, orphaned £5 credit. The frontend
|
||||
// PaymentModal MUST therefore send the discounted amount exactly like the
|
||||
// customer modal (amount_due minus the eligible-campaign preview) so the
|
||||
// full ledger reconciles to the booking total; the server-side over-credit
|
||||
// guard (capDiscountToRemainingObligation in completion.go) protects
|
||||
// against a client that does not.
|
||||
chargeAmount := req.Amount
|
||||
if req.PaymentType == "deposit" && eligibleDiscountCents > 0 {
|
||||
chargeAmount = req.Amount - eligibleDiscountCents
|
||||
if req.PaymentType == "deposit" && eligibleDiscountPence > 0 {
|
||||
chargeAmount = req.Amount - eligibleDiscountPence
|
||||
// A6: when the eligible discount is >= the deposit itself, chargeAmount
|
||||
// clamps UP to the full (undiscounted) deposit. The customer still pays
|
||||
// the full deposit up front — the discount credit applies to the
|
||||
// residual balance via the discount row created by
|
||||
// applyEligibleCampaignsAtPayment (which runs regardless of
|
||||
// chargeAmount), so no discount is ever lost and the ledger can never
|
||||
// charge a negative amount.
|
||||
if chargeAmount <= 0 {
|
||||
chargeAmount = req.Amount
|
||||
}
|
||||
@@ -1987,7 +2035,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// Tip rows are excluded (they are gratuity, not payment toward the booking)
|
||||
// as are discount/on_the_house rows (no real money moved).
|
||||
var depositMet bool
|
||||
if err := tx2.QueryRow(r.Context(), `
|
||||
if err := tx2.QueryRow(r.Context(), fmt.Sprintf(`
|
||||
WITH booking_total AS (
|
||||
SELECT total_amount * 100 AS total_cents FROM bookings WHERE id = $1
|
||||
),
|
||||
@@ -1998,9 +2046,9 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
AND payment_type != 'tip'
|
||||
AND payment_method NOT IN ('discount', 'on_the_house')
|
||||
)
|
||||
SELECT pt.paid_cents >= ROUND(bt.total_cents * 0.2)
|
||||
SELECT pt.paid_cents >= ROUND(bt.total_cents * %f)
|
||||
FROM booking_total bt, paid_total pt
|
||||
`, bookingID).Scan(&depositMet); err != nil {
|
||||
`, depositPromotionMinPct), bookingID).Scan(&depositMet); err != nil {
|
||||
log.Printf("Failed to check deposit threshold for booking %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
@@ -2061,6 +2109,22 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingI
|
||||
}
|
||||
|
||||
for _, d := range ComputeEligibleDiscounts(ctx, q, bookingID, userID, bookingTotal) {
|
||||
// F1: never over-credit. This runs inside the post-charge transaction
|
||||
// BEFORE the charge's split records are written, so the paid ledger
|
||||
// visible here is real money + discounts already on the booking plus
|
||||
// the in-flight charge (the pending row read inside
|
||||
// discountHeadroomPence). Capping each discount to the uncovered
|
||||
// obligation keeps the admin "Take Payment" full-amount flow from
|
||||
// creating an orphaned credit when a campaign is eligible: the correct
|
||||
// fix is the frontend PaymentModal sending the discounted amount (like
|
||||
// the customer modal does); this guard is the server-side money-safety
|
||||
// half.
|
||||
capped, ok := capDiscountToRemainingObligation(ctx, q, bookingID, d.Amount)
|
||||
if !ok {
|
||||
log.Printf("Skipping %s discount %s for booking %s — booking obligation already covered by real money (would over-credit)", d.Source, d.SourceID, bookingID)
|
||||
continue
|
||||
}
|
||||
d.Amount = capped
|
||||
ApplyEligibleDiscount(ctx, q, bookingID, userID, bookingTotal, d)
|
||||
}
|
||||
}
|
||||
@@ -2095,9 +2159,32 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingI
|
||||
// simplification, not an overcharge) — the partition still equals the charged
|
||||
// amount. See TestBuildSplitRecords_DiscountBooking_TipOverflow_SumNeverExceedsCharge.
|
||||
func buildSplitRecords(primary PaymentRecord, reqPaymentType string, info *BookingPaymentInfo, paymentAmount float64) []PaymentRecord {
|
||||
// After the booking starts there is no deposit protection window —
|
||||
// record the payment as a single entry with its original type.
|
||||
// After the booking starts there is no deposit protection window, but an
|
||||
// overpayment beyond the remaining booking value is still gratuity and must
|
||||
// be carved out as its own payment_type='tip' record (F3) — mirroring
|
||||
// buildTerminalSplitRecords' post-start carve. A post-start charge AT OR
|
||||
// BELOW the remaining value records as a single entry with its original
|
||||
// type, exactly as before; only the overflow becomes a tip. The tip record
|
||||
// zeroes fees and derives a -split-tip idempotency key, and the records
|
||||
// still partition paymentAmount exactly (booking portion + tip).
|
||||
if clock.Now().After(info.StartTime) {
|
||||
remaining := math.Max(0, info.TotalAmount-info.TotalPaid)
|
||||
bookingPortion := math.Min(paymentAmount, remaining)
|
||||
bookingPortion = math.Round(bookingPortion*100) / 100
|
||||
tipPortion := math.Round((paymentAmount-bookingPortion)*100) / 100
|
||||
if tipPortion > 0.004 {
|
||||
records := []PaymentRecord{primary}
|
||||
records[0].Amount = bookingPortion
|
||||
tip := primary
|
||||
tip.PaymentType = "tip"
|
||||
tip.Amount = tipPortion
|
||||
tip.Fees = 0
|
||||
if primary.IdempotencyKey != nil {
|
||||
k := *primary.IdempotencyKey + "-split-tip"
|
||||
tip.IdempotencyKey = &k
|
||||
}
|
||||
return append(records, tip)
|
||||
}
|
||||
return []PaymentRecord{primary}
|
||||
}
|
||||
|
||||
@@ -2470,7 +2557,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// This handler decodes into RefundRequest without running the struct
|
||||
// validator, so enforce the limit explicitly — a longer key would 400 at
|
||||
// Square and be misclassified as a definitive refund decline.
|
||||
if len(req.IdempotencyKey) > 45 {
|
||||
if len(req.IdempotencyKey) > maxIdempotencyKeyLength {
|
||||
http.Error(w, "Invalid request: idempotency_key exceeds 45 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -3118,14 +3205,14 @@ func AdminRefundBooking(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Cap: the refund amount must not exceed the refundable total (completed
|
||||
// non-tip payments minus already refunded). Tips are not refundable.
|
||||
refundableCents, err := service.GetBookingRefundableAmountCents(r.Context(), bookingID)
|
||||
refundablePence, err := service.GetBookingRefundableAmountPence(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get refundable amount for booking %s: %v", bookingID, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if req.Amount > refundableCents {
|
||||
log.Printf("Admin refund rejected: amount %d exceeds refundable %d for booking %s", req.Amount, refundableCents, bookingID)
|
||||
if req.Amount > refundablePence {
|
||||
log.Printf("Admin refund rejected: amount %d exceeds refundable %d for booking %s", req.Amount, refundablePence, bookingID)
|
||||
http.Error(w, "Refund amount exceeds the refundable amount for this booking", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -3208,7 +3295,7 @@ func AdminRefundBooking(w http.ResponseWriter, r *http.Request) {
|
||||
// cardRefunds tracks card refunds that need a post-commit Square call.
|
||||
type cardRefund struct {
|
||||
refundID string
|
||||
amountCents int64
|
||||
amountPence int64
|
||||
squareID string
|
||||
reason string
|
||||
key string
|
||||
@@ -3344,7 +3431,7 @@ func AdminRefundBooking(w http.ResponseWriter, r *http.Request) {
|
||||
if refundKey != nil && p.SquarePaymentID != nil {
|
||||
cardRefunds = append(cardRefunds, cardRefund{
|
||||
refundID: refundID,
|
||||
amountCents: int64(math.Round(portion * 100)),
|
||||
amountPence: int64(math.Round(portion * 100)),
|
||||
squareID: *p.SquarePaymentID,
|
||||
reason: req.Reason,
|
||||
key: *refundKey,
|
||||
@@ -3366,7 +3453,7 @@ func AdminRefundBooking(w http.ResponseWriter, r *http.Request) {
|
||||
status := "completed"
|
||||
result, rErr := SquareClient.RefundPayment(r.Context(), square.RefundPaymentReq{
|
||||
PaymentID: cf.squareID,
|
||||
Amount: cf.amountCents,
|
||||
Amount: cf.amountPence,
|
||||
IdempotencyKey: cf.key,
|
||||
Reason: cf.reason,
|
||||
})
|
||||
@@ -3607,13 +3694,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
cardPart = *req.CardID
|
||||
}
|
||||
idempotencyKey = fmt.Sprintf("tip-%s-%d-%s-%d", bookingID, req.Amount, cardPart, completedTips+1)
|
||||
if len(idempotencyKey) > 45 {
|
||||
// Hash long keys to fit Square's 45-char limit — the hash stays
|
||||
// deterministic, so a retry still derives the same key.
|
||||
hash := sha256.Sum256([]byte(idempotencyKey))
|
||||
idempotencyKey = fmt.Sprintf("tip-%x", hash[:16])
|
||||
}
|
||||
idempotencyKey = truncateIdempotencyKey("tip", fmt.Sprintf("tip-%s-%d-%s-%d", bookingID, req.Amount, cardPart, completedTips+1))
|
||||
}
|
||||
|
||||
// Check idempotency inside the transaction.
|
||||
@@ -3639,7 +3720,20 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
reusePendingRecord := false
|
||||
switch {
|
||||
case err == nil && existingStatus.String == "completed":
|
||||
// Idempotent dedup — return the already-completed payment.
|
||||
// Idempotent dedup — return the already-completed payment. First
|
||||
// RE-VALIDATE the matched row's refund state (same guard as the
|
||||
// CreateBookingPayment completed-dedup branches): a refunded payment's
|
||||
// money is no longer live, so reporting it as success would let a
|
||||
// same-key retry claim money that was already returned.
|
||||
if refunded, rErr := paymentHasLiveRefund(r.Context(), tx, existingID.String); rErr != nil {
|
||||
log.Printf("Failed to re-validate tip dedup hit %s against refunds: %v", existingID.String, rErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if refunded {
|
||||
log.Printf("Tip retry rejected: tip payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, idempotencyKey)
|
||||
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(PaymentResponse{
|
||||
ID: existingID.String,
|
||||
BookingID: existingBookingID.String,
|
||||
@@ -4115,13 +4209,16 @@ func ReleasePaymentLock(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// uniqueChargeKey generates a unique idempotency key under the given prefix
|
||||
// (e.g. "tip-", "till-") where the client did not supply one. Client-supplied
|
||||
// keys handle retry dedup; this fallback only needs uniqueness so two
|
||||
// legitimate identical requests never collapse on the same key. Deliberately
|
||||
// NOT derived from request fields — two identical requests would hash to the
|
||||
// same key (the "tip" fallback must not dedupe two distinct equal tips on one
|
||||
// booking). Shared by the tip/till flows, which used to carry two identical
|
||||
// copies (uniqueTipKey/uniqueTillKey) differing only in the prefix string.
|
||||
// (e.g. "till-") where the client did not supply one. Client-supplied keys
|
||||
// handle retry dedup; this fallback only needs uniqueness so two legitimate
|
||||
// identical requests never collapse on the same key. Deliberately NOT derived
|
||||
// from request fields — two identical requests would hash to the same key.
|
||||
// Current callers (A7): the terminal-payment idempotency key in
|
||||
// CreateTerminalPayment (handlers.go:343, fresh admin actions never
|
||||
// network-retried) and the till cash/on_the_house no-client-key fallback
|
||||
// (till.go:406, two identical keyless cash gift-card sales are distinct
|
||||
// operations). The tip flow no longer uses it — its no-client-key fallback is
|
||||
// derived deterministically from the completed-tip count (see CreateTipPayment).
|
||||
func uniqueChargeKey(prefix string) string {
|
||||
return prefix + rand.Text()
|
||||
}
|
||||
@@ -4162,14 +4259,12 @@ func uniqueChargeKey(prefix string) string {
|
||||
func deriveBookingPaymentIdempotencyKey(ctx context.Context, q db.Querier, bookingID, paymentType string, amount int64, cardPart string) (string, error) {
|
||||
baseKey := fmt.Sprintf("pay-%s-%s-%d-%s", bookingID, paymentType, amount, cardPart)
|
||||
for seq := 0; ; seq++ {
|
||||
candidate := baseKey
|
||||
if seq > 0 {
|
||||
candidate = fmt.Sprintf("%s-%d", baseKey, seq)
|
||||
}
|
||||
if len(candidate) > 45 {
|
||||
hash := sha256.Sum256([]byte(candidate))
|
||||
candidate = fmt.Sprintf("pay-%x", hash[:16])
|
||||
}
|
||||
// nextIdempotencyCandidate (idempotency_helpers.go) reproduces the
|
||||
// historical candidate exactly: the base key at seq 0, "base-seq" at
|
||||
// seq ≥ 1, sha256-truncated to the 45-char limit under the "pay-"
|
||||
// prefix when the verbatim form overflows — the key stays deterministic
|
||||
// so a same-key retry still dedups (A3).
|
||||
candidate := nextIdempotencyCandidate(baseKey, seq)
|
||||
var completedID string
|
||||
err := q.QueryRow(ctx, `
|
||||
SELECT id FROM payments
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"crussell/internal/square"
|
||||
)
|
||||
|
||||
// maxIdempotencyKeyLength caps idempotency keys at Square's /v2/payments limit
|
||||
@@ -12,7 +15,9 @@ import (
|
||||
// 45-char cap applies even where a destination (e.g. CreateCheckout) allows 64.
|
||||
// Client-supplied keys are validated against it ("omitempty,max=45") and
|
||||
// server-derived keys are truncated to it via truncateIdempotencyKey.
|
||||
const maxIdempotencyKeyLength = 45
|
||||
// Aliased from the square package — the client to Square, whose limit this is —
|
||||
// so there is a single source of the constant, not a per-package drift surface.
|
||||
const maxIdempotencyKeyLength = square.MaxIdempotencyKeyLength
|
||||
|
||||
// truncateIdempotencyKey applies the deterministic >45-char sha256 truncation
|
||||
// shared by the derive* idempotency-key helpers: a candidate longer than
|
||||
@@ -49,3 +54,22 @@ func nextIdempotencyCandidate(base string, seq int) string {
|
||||
}
|
||||
return truncateIdempotencyKey(prefix, candidate)
|
||||
}
|
||||
|
||||
// IsExplicitDevOrMockEnv reports whether SQUARE_ENVIRONMENT explicitly selects
|
||||
// the dev/mock Square stack. Only these exact values are treated as dev; an
|
||||
// empty or unknown value is NOT dev (fail-closed), because in production an
|
||||
// unset/mistyped env var must never bypass the 2FA gate or decrypt/encrypt
|
||||
// snapshot expectations (A9). It lives here — the neutral idempotency helper
|
||||
// file — because it gates far more than 2FA: snapshot encryption
|
||||
// (charge_helpers.go), the sweep's replay checks and snapshot decryption
|
||||
// (sweep.go), the till snapshot refresh (till.go), the gift-card reuse
|
||||
// snapshot handling (giftcards.go), and main.go's startup warnings. The
|
||||
// exported name is stable for main.go; in-package callers use it directly.
|
||||
func IsExplicitDevOrMockEnv() bool {
|
||||
switch os.Getenv("SQUARE_ENVIRONMENT") {
|
||||
case "mock", "dev", "development", "test":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,10 +249,12 @@ func TestCampaignAutoApply_TimeBased(t *testing.T) {
|
||||
t.Fatalf("failed to create campaign: %v", err)
|
||||
}
|
||||
|
||||
// Insert a deposit payment to trigger campaign auto-apply
|
||||
// Insert a deposit payment to trigger campaign auto-apply (£25 on the £50
|
||||
// booking — leaves headroom so the F1 over-credit cap still lets the 10%
|
||||
// discount through).
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
|
||||
VALUES ($1, 'deposit', 'online_square', 25.00, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
@@ -302,10 +304,10 @@ func TestCampaignAutoApply_UserMilestone(t *testing.T) {
|
||||
t.Fatalf("failed to create campaign: %v", err)
|
||||
}
|
||||
|
||||
// Insert a payment
|
||||
// Insert a payment (£25 deposit leaves F1 cap headroom for the 15% discount)
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
|
||||
VALUES ($1, 'deposit', 'online_square', 25.00, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
@@ -386,10 +388,11 @@ func TestCampaignAutoApply_GlobalMilestoneAppliedInPerson(t *testing.T) {
|
||||
t.Fatalf("failed to create campaign: %v", err)
|
||||
}
|
||||
|
||||
// Insert an IN-PERSON payment
|
||||
// Insert an IN-PERSON payment (£40 on the £50 booking — leaves the £10
|
||||
// headroom the 20% global-milestone discount needs to pass the F1 cap).
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW())
|
||||
VALUES ($1, 'full', 'in_person_card', 40.00, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
@@ -485,10 +488,10 @@ func TestCampaignAutoApply_ReferralDiscount(t *testing.T) {
|
||||
t.Fatalf("failed to insert referral discount: %v", err)
|
||||
}
|
||||
|
||||
// Insert payment to trigger auto-apply
|
||||
// Insert payment to trigger auto-apply (£25 deposit leaves F1 cap headroom)
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
|
||||
VALUES ($1, 'deposit', 'online_square', 25.00, 'completed', NOW(), NOW())
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
|
||||
@@ -318,7 +318,7 @@ func TestGetCheckoutStatus_TerminalTipSplit(t *testing.T) {
|
||||
|
||||
// Refundable total must be £50 (the booking portion), not £55.
|
||||
svc := NewPaymentService()
|
||||
refundable, err := svc.GetBookingRefundableAmountCents(ctx, bookingID)
|
||||
refundable, err := svc.GetBookingRefundableAmountPence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5000), refundable, "tips must not be part of the refundable total")
|
||||
}
|
||||
@@ -479,7 +479,7 @@ func TestGetBookingRefundableAmountCents_ExcludesTips(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewPaymentService()
|
||||
refundable, err := svc.GetBookingRefundableAmountCents(ctx, bookingID)
|
||||
refundable, err := svc.GetBookingRefundableAmountPence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(5000), refundable, "tips must not count toward the refundable amount")
|
||||
}
|
||||
|
||||
@@ -387,7 +387,7 @@ func TestGetBookingRemainingBalanceCents_ExcludesTips(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewPaymentService()
|
||||
initial, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
initial, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Positive(t, initial)
|
||||
|
||||
@@ -400,7 +400,7 @@ func TestGetBookingRemainingBalanceCents_ExcludesTips(t *testing.T) {
|
||||
Amount: 20.00,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
afterPartial, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
afterPartial, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, initial-2000, afterPartial)
|
||||
|
||||
@@ -414,7 +414,7 @@ func TestGetBookingRemainingBalanceCents_ExcludesTips(t *testing.T) {
|
||||
Amount: 5.00,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
afterTip, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
afterTip, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, afterPartial, afterTip, "a tip must not count toward the paid balance")
|
||||
}
|
||||
|
||||
@@ -3203,8 +3203,8 @@ func TestDeletePaymentMethod_WrongOwnerRejected(t *testing.T) {
|
||||
func TestValidatePartialAmount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
amountCents int64
|
||||
remainingCents int64
|
||||
amountPence int64
|
||||
remainingPence int64
|
||||
expectErr bool
|
||||
}{
|
||||
{"valid partial", 500, 1000, false},
|
||||
@@ -3216,7 +3216,7 @@ func TestValidatePartialAmount(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidatePartialAmount(tt.amountCents, tt.remainingCents)
|
||||
err := ValidatePartialAmount(tt.amountPence, tt.remainingPence)
|
||||
if tt.expectErr && err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
@@ -3227,7 +3227,7 @@ func TestValidatePartialAmount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBookingRemainingBalanceCents(t *testing.T) {
|
||||
func TestGetBookingRemainingBalancePence(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
@@ -3248,7 +3248,7 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) {
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
initialRemaining, err := service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
initialRemaining, err := service.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -3267,12 +3267,12 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
afterPartial, err := service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
afterPartial, err := service.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if afterPartial != initialRemaining-2000 {
|
||||
t.Errorf("expected %d cents remaining after £20 payment, got %d", initialRemaining-2000, afterPartial)
|
||||
t.Errorf("expected %d pence remaining after £20 payment, got %d", initialRemaining-2000, afterPartial)
|
||||
}
|
||||
|
||||
_, err = service.CreatePaymentRecord(ctx, PaymentRecord{
|
||||
@@ -3286,12 +3286,12 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
afterFull, err := service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
afterFull, err := service.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if afterFull != 0 {
|
||||
t.Errorf("expected 0 cents remaining after full payment, got %d", afterFull)
|
||||
t.Errorf("expected 0 pence remaining after full payment, got %d", afterFull)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3327,7 +3327,7 @@ func TestGetBookingRemainingBalanceCents_RefundsReopenCapacity(t *testing.T) {
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
remaining, err := service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
remaining, err := service.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), remaining, "a fully-paid booking must have 0 remaining")
|
||||
|
||||
@@ -3342,7 +3342,7 @@ func TestGetBookingRemainingBalanceCents_RefundsReopenCapacity(t *testing.T) {
|
||||
`, payRowID, bookingID, float64(refundAmount)/100.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
remaining, err = service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
remaining, err = service.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, refundAmount, remaining, "a completed refund must re-open the remaining balance by its amount")
|
||||
|
||||
@@ -3353,7 +3353,7 @@ func TestGetBookingRemainingBalanceCents_RefundsReopenCapacity(t *testing.T) {
|
||||
`, payRowID, bookingID, bookingTotal)
|
||||
require.NoError(t, err)
|
||||
|
||||
remaining, err = service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
remaining, err = service.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, bookingTotal, remaining, "the remaining balance must never exceed the booking total")
|
||||
}
|
||||
@@ -3909,14 +3909,14 @@ func TestGetBookingPaymentInfo_Found(t *testing.T) {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Service layer: GetBookingRemainingBalanceCents
|
||||
// Service layer: GetBookingRemainingBalancePence
|
||||
// =============================================================================
|
||||
|
||||
func TestGetBookingRemainingBalanceCents_NotFound(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_ = tx
|
||||
svc := NewPaymentService()
|
||||
_, err := svc.GetBookingRemainingBalanceCents(ctx, "000000000001")
|
||||
_, err := svc.GetBookingRemainingBalancePence(ctx, "000000000001")
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent booking ID")
|
||||
}
|
||||
@@ -3926,7 +3926,7 @@ func TestGetBookingRemainingBalanceCents_FullBalance(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
svc := NewPaymentService()
|
||||
cents, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
cents, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,15 @@ const (
|
||||
ProtectedDepositMaxPct = 0.50
|
||||
RequiredDepositPct = 0.20
|
||||
|
||||
// depositPromotionMinPct is the share of the booking total a payment must
|
||||
// cover before a pending_release booking is promoted back to 'confirmed'
|
||||
// (A10) — the deposit-promotion threshold in the CreateBookingPayment
|
||||
// deposit-promotion query. Named separately from RequiredDepositPct (the
|
||||
// deposit REQUIRED at booking time, used by bookings.go): the promotion
|
||||
// threshold is about already-paid money, not the amount to demand up front,
|
||||
// even though both are 20% today.
|
||||
depositPromotionMinPct = 0.2
|
||||
|
||||
LoyaltyStampCost = 10
|
||||
LoyaltyDiscountPercent = 10.0
|
||||
)
|
||||
|
||||
@@ -66,6 +66,14 @@ const maxManualRefundAttempts = 3
|
||||
// inserted so the owner learns the reconcile is hard-failing.
|
||||
const maxConsecutiveReconcileFailures = 5
|
||||
|
||||
// maxTrackedReconcileFailures caps the in-memory consecutive-failure counter
|
||||
// so the map never grows past a sane value AND the ==maxConsecutiveReconcileFailures
|
||||
// notification check can never be skipped by an odd increment pattern (A5): a
|
||||
// row whose counter is already at the cap stops growing, but the notification
|
||||
// fired when it first crossed maxConsecutiveReconcileFailures and is deduped by
|
||||
// the NOT EXISTS guard, so capping loses no visibility.
|
||||
const maxTrackedReconcileFailures = 10
|
||||
|
||||
// manualReconcileFailures counts consecutive cap-time reconcile failures per
|
||||
// refund row (keyed by refunds.id). resolveManualRefundAtCap and the
|
||||
// charge-group cap path re-arm a row under the attempt cap on a reconcile
|
||||
@@ -93,7 +101,13 @@ func trackReconcileFailureReArm(ctx context.Context, ids []string) {
|
||||
manualReconcileFailureMu.Lock()
|
||||
notify := false
|
||||
for _, id := range ids {
|
||||
manualReconcileFailures[id]++
|
||||
// A5: cap the counter so the map never grows unbounded and the
|
||||
// ==maxConsecutiveReconcileFailures check below is never skipped by an
|
||||
// odd increment pattern — a counter that grew past 5 without a reset
|
||||
// (e.g. to 6) would silently stop firing the notification forever.
|
||||
if manualReconcileFailures[id] < maxTrackedReconcileFailures {
|
||||
manualReconcileFailures[id]++
|
||||
}
|
||||
if manualReconcileFailures[id] == maxConsecutiveReconcileFailures {
|
||||
notify = true
|
||||
}
|
||||
@@ -164,7 +178,7 @@ func notifyCriticalReconcileFailure(ctx context.Context, ids []string) {
|
||||
// (id, nil) — exact COMPLETED refund found
|
||||
// (nil, nil) — genuinely no exact match
|
||||
// (nil, err) — reconcile failed (network/API error)
|
||||
func reconcileRefundAtSquareExact(ctx context.Context, chargeID string, amountCents int64) (*string, error) {
|
||||
func reconcileRefundAtSquareExact(ctx context.Context, chargeID string, amountPence int64) (*string, error) {
|
||||
refunds, err := SquareClient.ListPaymentRefunds(ctx, chargeID, time.Time{})
|
||||
if err != nil {
|
||||
log.Printf("Failed to reconcile charge %s against Square: %v", chargeID, err)
|
||||
@@ -172,7 +186,7 @@ func reconcileRefundAtSquareExact(ctx context.Context, chargeID string, amountCe
|
||||
}
|
||||
for i := range refunds {
|
||||
r := &refunds[i]
|
||||
if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == amountCents {
|
||||
if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == amountPence {
|
||||
return &r.ID, nil
|
||||
}
|
||||
}
|
||||
@@ -206,7 +220,8 @@ type paymentRow struct {
|
||||
// - 24-72 hours notice: Keep protected deposit (up to 50%), refund the rest
|
||||
// - <24 hours or no-show: Keep all pre-payments
|
||||
//
|
||||
// The "protected deposit" is defined as min(totalPrePaid, subtotal * 0.50).
|
||||
// The "protected deposit" is defined as min(totalPrePaid, subtotal *
|
||||
// ProtectedDepositMaxPct).
|
||||
// This means up to 50% of the subtotal is always treated as a deposit for
|
||||
// refund purposes, regardless of whether deposit_required was set on the booking.
|
||||
//
|
||||
@@ -945,7 +960,7 @@ func isRefundAmountInvalid(err error) bool {
|
||||
// COMPLETED refund on the same charge is a manual per-record refund,
|
||||
// attributing it would mark our rows completed when the aggregate money never
|
||||
// moved.
|
||||
func reconcileRefundAtSquare(ctx context.Context, chargeID string, totalCents int64, oldestCreatedAt time.Time) (*string, error) {
|
||||
func reconcileRefundAtSquare(ctx context.Context, chargeID string, totalPence int64, oldestCreatedAt time.Time) (*string, error) {
|
||||
refunds, err := SquareClient.ListPaymentRefunds(ctx, chargeID, oldestCreatedAt)
|
||||
if err != nil {
|
||||
log.Printf("Failed to reconcile charge %s against Square: %v", chargeID, err)
|
||||
@@ -953,7 +968,7 @@ func reconcileRefundAtSquare(ctx context.Context, chargeID string, totalCents in
|
||||
}
|
||||
for i := range refunds {
|
||||
r := &refunds[i]
|
||||
if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == totalCents {
|
||||
if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == totalPence {
|
||||
return &r.ID, nil
|
||||
}
|
||||
}
|
||||
@@ -964,6 +979,24 @@ func reconcileRefundAtSquare(ctx context.Context, chargeID string, totalCents in
|
||||
// notification centre — one row per affected booking. The sweep only processes
|
||||
// 'pending' rows, so this fires once per row transition (no spam); the
|
||||
// NOT EXISTS guard prevents duplicates on re-runs.
|
||||
//
|
||||
// The webhooks package carries the SINGULAR variant of this same insert —
|
||||
// insertRefundFailedNotification (handlers/webhooks/square.go) — which demotes
|
||||
// a single webhook-surfaced FAILED refund to the identical 'refund_failed'
|
||||
// row. The two share the same NOT EXISTS dedup guard on
|
||||
// (reason='refund_failed', booking_id), so a refund resolved by either path
|
||||
// can never be double-notified; keep the reason string and dedup predicate in
|
||||
// lockstep when either changes.
|
||||
// InsertRefundFailedNotifications is the EXPORTED single source for surfacing
|
||||
// failed refunds in the admin notification centre, consumed by both the sweep
|
||||
// path (sweep.go) and the webhook path (handlers/webhooks/square.go). The
|
||||
// webhook package calls this instead of maintaining its own copy, so the SQL
|
||||
// and the (reason='refund_failed', booking_id) dedup predicate live in exactly
|
||||
// one place. It delegates to the package-internal insertRefundFailedNotifications.
|
||||
func InsertRefundFailedNotifications(ctx context.Context, refundIDs []string) {
|
||||
insertRefundFailedNotifications(ctx, refundIDs)
|
||||
}
|
||||
|
||||
func insertRefundFailedNotifications(ctx context.Context, refundIDs []string) {
|
||||
if len(refundIDs) == 0 {
|
||||
return
|
||||
@@ -1117,12 +1150,12 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
|
||||
oldest = pr.CreatedAt
|
||||
}
|
||||
}
|
||||
var totalCents int64
|
||||
var totalPence int64
|
||||
for _, pr := range pending {
|
||||
totalCents += int64(math.Round(pr.Amount * 100))
|
||||
totalPence += int64(math.Round(pr.Amount * 100))
|
||||
}
|
||||
if clock.Now().Sub(oldest) > stalePendingRefundAge {
|
||||
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalCents, oldest)
|
||||
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalPence, oldest)
|
||||
switch {
|
||||
case rcErr != nil:
|
||||
// Reconcile failed — unknown whether Square refunded. Leave rows
|
||||
@@ -1137,6 +1170,8 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
|
||||
`, *sqRefundID, idsOf(pending)); upErr != nil {
|
||||
log.Printf("Failed to mark aged pending refunds completed after Square reconcile (charge %s): %v", chargeID, upErr)
|
||||
}
|
||||
// A5: terminal resolution — clear the consecutive-failure counter.
|
||||
resetReconcileFailureCount(idsOf(pending)...)
|
||||
log.Printf("Aged card refunds for charge %s reconciled at Square — COMPLETED refund %s found, marked completed", chargeID, *sqRefundID)
|
||||
return len(pending), nil
|
||||
default:
|
||||
@@ -1146,6 +1181,8 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
|
||||
`, idsOf(pending)); upErr != nil {
|
||||
log.Printf("Failed to mark aged pending refunds failed (charge %s): %v", chargeID, upErr)
|
||||
}
|
||||
// A5: terminal resolution — clear the consecutive-failure counter.
|
||||
resetReconcileFailureCount(idsOf(pending)...)
|
||||
insertRefundFailedNotifications(ctx, idsOf(pending))
|
||||
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
|
||||
// system lands; until then the admin_notifications row above is the only
|
||||
@@ -1179,7 +1216,7 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
|
||||
// retention may have lapsed.
|
||||
sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
|
||||
PaymentID: chargeID,
|
||||
Amount: totalCents,
|
||||
Amount: totalPence,
|
||||
IdempotencyKey: chargeAggKey(chargeID),
|
||||
Reason: reason,
|
||||
})
|
||||
@@ -1193,7 +1230,7 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
|
||||
// does NOT — marking the rows completed would claim the full amount was
|
||||
// refunded when only part of it was.
|
||||
if isRefundAmountInvalid(sqErr) {
|
||||
sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, chargeID, totalCents)
|
||||
sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, chargeID, totalPence)
|
||||
switch {
|
||||
case rcErr != nil:
|
||||
// Reconcile failed — unknown money state. Keep the client's own
|
||||
@@ -1290,7 +1327,7 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
|
||||
// responses — reconcile BEFORE marking failed (same bug class as the
|
||||
// age guard). An exact COMPLETED refund resolves to completed.
|
||||
if capIDs := pendingRowsAtAttemptCap(ctx, idsOf(pending)); len(capIDs) > 0 {
|
||||
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalCents, oldest)
|
||||
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalPence, oldest)
|
||||
switch {
|
||||
case rcErr != nil:
|
||||
// Reconcile failed — unknown whether Square refunded. Leave
|
||||
@@ -1377,7 +1414,7 @@ func aggRefundKeySuffix(ids []string) string {
|
||||
// second charge's refund (lost money).
|
||||
func chargeAggKey(chargeID string) string {
|
||||
key := chargeID + "-square-agg"
|
||||
if len(key) <= 45 {
|
||||
if len(key) <= maxIdempotencyKeyLength {
|
||||
return key
|
||||
}
|
||||
return aggRefundKeySuffix([]string{chargeID}) + "-square-agg"
|
||||
@@ -1640,8 +1677,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
processedAged := 0
|
||||
for i := range pending {
|
||||
pr := &pending[i]
|
||||
amountCents := int64(math.Round(pr.Amount * 100))
|
||||
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
|
||||
amountPence := int64(math.Round(pr.Amount * 100))
|
||||
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt)
|
||||
switch {
|
||||
case rcErr != nil:
|
||||
// Reconcile failed — unknown whether Square refunded. Leave
|
||||
@@ -1655,6 +1692,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
`, *sqRefundID, pr.ID); upErr != nil {
|
||||
log.Printf("Failed to mark aged manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
|
||||
}
|
||||
// A5: terminal resolution — clear the consecutive-failure counter.
|
||||
resetReconcileFailureCount(pr.ID)
|
||||
processedAged++
|
||||
default:
|
||||
if _, upErr := db.Conn.Exec(ctx, `
|
||||
@@ -1663,6 +1702,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
`, pr.ID); upErr != nil {
|
||||
log.Printf("Failed to mark aged manual refund %s failed: %v", pr.ID, upErr)
|
||||
}
|
||||
// A5: terminal resolution — clear the consecutive-failure counter.
|
||||
resetReconcileFailureCount(pr.ID)
|
||||
insertRefundFailedNotifications(ctx, []string{pr.ID})
|
||||
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
|
||||
// system lands; until then the admin_notifications row above is the only
|
||||
@@ -1676,7 +1717,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
processed := 0
|
||||
for i := range pending {
|
||||
pr := &pending[i]
|
||||
amountCents := int64(math.Round(pr.Amount * 100))
|
||||
amountPence := int64(math.Round(pr.Amount * 100))
|
||||
|
||||
// A payment with NO booking is a gift-card purchase (BuyGiftCard
|
||||
// inserts without a booking) — the handler rejects these outright, so a
|
||||
@@ -1688,7 +1729,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
// the over-refund guard and the customer is handled via the gift-card
|
||||
// section.
|
||||
if pr.BookingID == "" {
|
||||
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
|
||||
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt)
|
||||
switch {
|
||||
case rcErr != nil:
|
||||
log.Printf("Reconcile failed for gift-card-purchase manual refund %s (%v) — leaving pending for the next sweep", pr.ID, rcErr)
|
||||
@@ -1699,6 +1740,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
`, *sqRefundID, pr.ID); upErr != nil {
|
||||
log.Printf("Failed to mark gift-card-purchase manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
|
||||
}
|
||||
// A5: terminal resolution — clear the consecutive-failure counter.
|
||||
resetReconcileFailureCount(pr.ID)
|
||||
processed++
|
||||
default:
|
||||
if _, upErr := db.Conn.Exec(ctx, `
|
||||
@@ -1707,6 +1750,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
`, pr.ID); upErr != nil {
|
||||
log.Printf("Failed to mark gift-card-purchase manual refund %s failed: %v", pr.ID, upErr)
|
||||
}
|
||||
// A5: terminal resolution — clear the consecutive-failure counter.
|
||||
resetReconcileFailureCount(pr.ID)
|
||||
insertRefundFailedNotifications(ctx, []string{pr.ID})
|
||||
log.Printf("Gift-card-purchase manual refund %s (payment %s) blocked — payment has no booking; Square shows no COMPLETED refund — marked failed, customer must be refunded via the gift-card section", pr.ID, pr.PaymentID)
|
||||
}
|
||||
@@ -1723,7 +1768,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
// that would let the over-refund guard exclude money that may have
|
||||
// moved). Mirrors the stalePendingRefundAge age-guard branch above.
|
||||
if pr.SquareRefundID != "" {
|
||||
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
|
||||
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt)
|
||||
switch {
|
||||
case rcErr != nil:
|
||||
log.Printf("Reconcile failed for pending manual refund %s (square_refund_id %s, %v) — leaving pending for the next sweep", pr.ID, pr.SquareRefundID, rcErr)
|
||||
@@ -1754,7 +1799,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
// helper persists a generated fallback to the row first, so every retry
|
||||
// reuses the SAME key — a lost-response retry can never issue a second
|
||||
// refund (Square dedups same-key retries).
|
||||
idemKey, keyErr := ensureRefundKey(ctx, pr.ID, pr.PaymentID, amountCents, pr.IdempotencyKey)
|
||||
idemKey, keyErr := ensureRefundKey(ctx, pr.ID, pr.PaymentID, amountPence, pr.IdempotencyKey)
|
||||
if keyErr != nil {
|
||||
// The key could not be persisted — Square must not be called with an
|
||||
// empty/unknown key. Leave the row pending for the next sweep (never
|
||||
@@ -1765,7 +1810,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
}
|
||||
sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
|
||||
PaymentID: pr.SquarePaymentID,
|
||||
Amount: amountCents,
|
||||
Amount: amountPence,
|
||||
IdempotencyKey: idemKey,
|
||||
Reason: pr.Reason,
|
||||
})
|
||||
@@ -1778,7 +1823,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
// applies whether the client classified the code as a definitive
|
||||
// decline or as already-processed.
|
||||
if isRefundAmountInvalid(sqErr) {
|
||||
sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, pr.SquarePaymentID, amountCents)
|
||||
sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, pr.SquarePaymentID, amountPence)
|
||||
switch {
|
||||
case rcErr != nil:
|
||||
// Reconcile failed — unknown money state. Keep the client's
|
||||
@@ -1829,7 +1874,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
log.Printf("Failed to increment attempts for manual refund %s: %v", pr.ID, upErr)
|
||||
}
|
||||
if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= maxManualRefundAttempts {
|
||||
resolveManualRefundAtCap(ctx, pr, amountCents)
|
||||
resolveManualRefundAtCap(ctx, pr, amountPence)
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -1842,7 +1887,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
log.Printf("Failed to increment attempts for manual refund %s: %v", pr.ID, upErr)
|
||||
}
|
||||
if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= maxManualRefundAttempts {
|
||||
resolveManualRefundAtCap(ctx, pr, amountCents)
|
||||
resolveManualRefundAtCap(ctx, pr, amountPence)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1855,8 +1900,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
||||
// resolves the row to completed; otherwise mark failed and notify the admin.
|
||||
// The row is never re-issued here (reconcile is a read), so the cap cannot
|
||||
// cause a double refund.
|
||||
func resolveManualRefundAtCap(ctx context.Context, pr *manualPendingRow, amountCents int64) {
|
||||
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
|
||||
func resolveManualRefundAtCap(ctx context.Context, pr *manualPendingRow, amountPence int64) {
|
||||
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt)
|
||||
switch {
|
||||
case rcErr != nil:
|
||||
// Reconcile failed — unknown whether Square refunded. NEVER mark failed
|
||||
|
||||
@@ -402,11 +402,11 @@ func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID
|
||||
return int64(math.Round(amount * 100)), nil
|
||||
}
|
||||
|
||||
// GetBookingRefundableAmountCents returns the total refundable amount (in
|
||||
// GetBookingRefundableAmountPence returns the total refundable amount (in
|
||||
// pence) for a booking: the sum of completed non-tip payments minus already
|
||||
// refunded (completed + pending). Tips are excluded — they are gratuity above
|
||||
// the booking total and are not refundable via the admin refund endpoint.
|
||||
func (s *PaymentService) GetBookingRefundableAmountCents(ctx context.Context, bookingID string) (int64, error) {
|
||||
func (s *PaymentService) GetBookingRefundableAmountPence(ctx context.Context, bookingID string) (int64, error) {
|
||||
var amount float64
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(p.amount), 0) - COALESCE((
|
||||
@@ -502,8 +502,8 @@ func (s *PaymentService) GetBookingUserID(ctx context.Context, bookingID string)
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bookingID string) (int64, error) {
|
||||
var remainingCents int64
|
||||
func (s *PaymentService) GetBookingRemainingBalancePence(ctx context.Context, bookingID string) (int64, error) {
|
||||
var remainingPence int64
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
WITH booking_total AS (
|
||||
SELECT total_amount AS total_pounds FROM bookings WHERE id = $1
|
||||
@@ -530,11 +530,11 @@ func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bo
|
||||
-- (or over-paid) booking can never be charged again.
|
||||
SELECT GREATEST(0, ROUND(LEAST(bt.total_pounds - pt.paid_pounds + rt.refunded_pounds, bt.total_pounds) * 100))::bigint
|
||||
FROM booking_total bt, paid_total pt, refunded_total rt
|
||||
`, bookingID).Scan(&remainingCents)
|
||||
`, bookingID).Scan(&remainingPence)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return remainingCents, nil
|
||||
return remainingPence, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID string) ([]SavedCard, error) {
|
||||
|
||||
@@ -67,9 +67,10 @@ func SweepStalePendingPayments(ctx context.Context) (int, error) {
|
||||
cutoff := clock.Now().Add(-stalePendingPaymentAge)
|
||||
keyedCutoff := clock.Now().Add(-stalePendingKeyedAge)
|
||||
|
||||
// Pass 0: stranded Square-less MANUAL refund rows at the 3-attempt cap.
|
||||
// The Square-less pre-pass inside sweepManualPendingSquareRefunds
|
||||
// (refunds.go) reconciles only rows with refund_attempts < 3; legacy rows
|
||||
// Pass 0: stranded Square-less MANUAL refund rows at the attempt cap
|
||||
// (maxManualRefundAttempts). The Square-less pre-pass inside
|
||||
// sweepManualPendingSquareRefunds (refunds.go) reconciles only rows with
|
||||
// refund_attempts < maxManualRefundAttempts; legacy rows
|
||||
// that already hit the cap are never reconciled by it and would stay
|
||||
// 'pending' forever, permanently blocking the over-refund guard. Such rows
|
||||
// can never be refunded via Square (no square_payment_id), so they are
|
||||
@@ -191,12 +192,12 @@ type staleRow struct {
|
||||
// CreatedBy is the payments row's created_by user id (gift-card purchases
|
||||
// always carry the purchaser), used to attribute the critical-payment admin
|
||||
// notification.
|
||||
CreatedBy *string
|
||||
ItemID string // till_sales.item_id — the gift card ("" when NULL / non-gift-card)
|
||||
RedeemToUserID *string // gift_cards.redeemed_by — user credited by a create-with-redeem
|
||||
IsCreate bool // true when this sale created the gift card (timestamps equal)
|
||||
HasGiftCard bool // false when the LEFT JOIN found no gift_cards row (gc.id IS NULL)
|
||||
TotalAmount float64 // till_sales.total_amount — the funding this sale added
|
||||
CreatedBy *string
|
||||
ItemID string // till_sales.item_id — the gift card ("" when NULL / non-gift-card)
|
||||
RedeemToUserID *string // gift_cards.redeemed_by — user credited by a create-with-redeem
|
||||
IsCreate bool // true when this sale created the gift card (timestamps equal)
|
||||
HasGiftCard bool // false when the LEFT JOIN found no gift_cards row (gc.id IS NULL)
|
||||
TotalAmount float64 // till_sales.total_amount — the funding this sale added
|
||||
}
|
||||
|
||||
// sweepStaleRows resolves the stale pending rows of one table. Rows with a
|
||||
@@ -643,13 +644,55 @@ func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// replayRescueClockSkew is the margin by which a replayed payment's CreatedAt
|
||||
// may lag the pending row's CreatedAt and still be the ORIGINAL charge under a
|
||||
// retained idempotency key. Square creates the payment at the same instant the
|
||||
// app creates the pending row (the same transaction), so a replayed payment
|
||||
// created AFTER the row by more than this margin cannot be the original — it is
|
||||
// a NEW charge Square made with an expired key (finding A1).
|
||||
const replayRescueClockSkew = time.Hour
|
||||
// replayLegitimateRetryWindow is the maximum lag between the pending row's
|
||||
// creation and a replayed payment's creation for the payment to be the REAL
|
||||
// charge under a legitimately replayed key. A same-key retry — the documented
|
||||
// retry path (handlers.go:1579-1591) — creates its charge somewhere between
|
||||
// the row's creation and the sweep's 22h keyed cutoff (stalePendingKeyedAge),
|
||||
// so any COMPLETED payment created within [row.CreatedAt, row.CreatedAt +
|
||||
// replayLegitimateRetryWindow] can be that retry charge and must be rescued.
|
||||
// A payment created LATER than 21h after the row (i.e. within ~1-3h of the
|
||||
// sweep's own replay, which runs at row age 22h+) is the classic expired-key
|
||||
// replay-induced charge — the sweep just created it by replaying the still
|
||||
// valid saved-card source under a key Square no longer retains — and rescuing
|
||||
// it would hide the duplicate charge behind the original row (finding A1).
|
||||
// 21h is a clear margin: a legitimate retry cannot occur after the sweep has
|
||||
// already picked the row up at the 22h cutoff.
|
||||
const replayLegitimateRetryWindow = 21 * time.Hour
|
||||
|
||||
// replayMatchesRowAmount reports whether the replayed payment charged the same
|
||||
// amount the pending row records — the amount the sweep's replay body repeats
|
||||
// and the amount any same-key retry MUST reuse (the retry path rejects a
|
||||
// different amount). A replayed payment carrying a DIFFERENT amount cannot be
|
||||
// the charge this row is waiting on and must never be rescued onto it. A
|
||||
// payment with no amount (zero — test fixtures; real Square payments always
|
||||
// carry one) is not refused here: the lag window below is the primary guard.
|
||||
func replayMatchesRowAmount(r staleRow, pr *square.PaymentResult) bool {
|
||||
return pr == nil || pr.Amount == 0 || pr.Amount == r.AmountPence
|
||||
}
|
||||
|
||||
// replayWithinLegitimateWindow reports whether a replayed COMPLETED payment is
|
||||
// the REAL charge this pending row is waiting on — the ORIGINAL charge under a
|
||||
// retained key (created ~at row creation) or a later SAME-KEY RETRY charge
|
||||
// (created between the row's creation and the 22h sweep cutoff, F2). The
|
||||
// amount must match the row (a retry can never change it) and the payment must
|
||||
// have been created within replayLegitimateRetryWindow of the row. The source
|
||||
// is matched by construction: the replay body is rebuilt from the row's stored
|
||||
// square_request_snapshot with the LIVE square_source_id override, so a payment
|
||||
// returned by the replay necessarily charged the row's source (Square's
|
||||
// PaymentResult does not echo the source id back, so it cannot be compared
|
||||
// directly). A payment created very near the sweep time (lag > 21h) is the
|
||||
// expired-key replay-induced charge and is NOT legitimate.
|
||||
func replayWithinLegitimateWindow(r staleRow, pr *square.PaymentResult) bool {
|
||||
if !replayMatchesRowAmount(r, pr) {
|
||||
return false
|
||||
}
|
||||
created, ok := parseReplayedCreatedAt(pr)
|
||||
if !ok || r.CreatedAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return !created.Before(r.CreatedAt) && !created.After(r.CreatedAt.Add(replayLegitimateRetryWindow))
|
||||
}
|
||||
|
||||
// isSavedCardSource reports whether a Square source id is a card-on-file
|
||||
// (saved-card) reference. Only a ccof: source stays valid for recharging long
|
||||
@@ -682,10 +725,14 @@ func parseReplayedCreatedAt(pr *square.PaymentResult) (time.Time, bool) {
|
||||
// the same instant the pending row was created; a NEW charge made by an
|
||||
// expired-key replay (Square's ~24h key retention is UNVERIFIED —
|
||||
// square_http_client.go:626) against the still-valid ccof: source is created
|
||||
// ~22h later. Refusal is money-safe: a replayed payment that cannot be proven
|
||||
// to be the original is never rescued (the row stays pending, a CRITICAL log is
|
||||
// raised and an admin notification inserted), so a hidden second charge can
|
||||
// never masquerade as the original one.
|
||||
// ~22h later. A same-key RETRY (handlers.go:1579-1591) is a legitimate
|
||||
// exception: the retry's charge is created between the row's creation and the
|
||||
// 22h sweep cutoff, so a replayed payment inside replayLegitimateRetryWindow
|
||||
// is the REAL charge and must be rescued (F2). Refusal is money-safe: a
|
||||
// replayed payment that cannot be proven to be the original (or a retry
|
||||
// within the legitimate window) is never rescued (the row stays pending, a
|
||||
// CRITICAL log is raised and an admin notification inserted), so a hidden
|
||||
// second charge can never masquerade as the original one.
|
||||
//
|
||||
// The check runs ONLY against real Square timestamps: it is gated off in an
|
||||
// explicit dev/mock env because the dev mock returns payments whose CreatedAt
|
||||
@@ -704,7 +751,7 @@ func replayRevealsNewCharge(r staleRow, pr *square.PaymentResult) (newCharge boo
|
||||
// rescue rather than hide a possible second charge.
|
||||
return true, created, createdOK
|
||||
}
|
||||
return created.After(r.CreatedAt.Add(replayRescueClockSkew)), created, true
|
||||
return !replayWithinLegitimateWindow(r, pr), created, true
|
||||
}
|
||||
|
||||
// reconcileStalePaymentByKey asks Square for the authoritative status of the
|
||||
@@ -782,8 +829,7 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
|
||||
if !fallbackBody && !IsExplicitDevOrMockEnv() {
|
||||
dec, err := decryptSnapshot(snapshot)
|
||||
if err != nil {
|
||||
log.Printf("CRITICAL: stale pending %s reconcile by key: failed to decrypt the stored request snapshot for row %s (%v) — leaving pending — MANUAL RECONCILIATION REQUIRED", table, r.ID, err)
|
||||
return staleReconcileLeavePending, ""
|
||||
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: failed to decrypt the stored request snapshot for row %s (%v) — leaving pending — MANUAL RECONCILIATION REQUIRED", table, r.ID, err)
|
||||
}
|
||||
snapshot = dec
|
||||
}
|
||||
@@ -836,9 +882,7 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
|
||||
// single-use nonce cannot be recharged), keeping the proven-failed
|
||||
// path and its clawback.
|
||||
if isSavedCardSource(r.SquareSourceID) {
|
||||
log.Printf("CRITICAL: stale pending %s reconcile by key: Square rejected the identical-body replay (no payment under the stored key) but the row's source is a still-valid saved card (ccof:) — the replay may have landed a NEW charge under an expired idempotency key — leaving row %s PENDING without failing/clawing back — MANUAL RECONCILIATION REQUIRED: verify at Square whether a charge exists before re-issuing", table, r.ID)
|
||||
notifyStaleRowCritical(ctx, r)
|
||||
return staleReconcileLeavePending, ""
|
||||
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: Square rejected the identical-body replay (no payment under the stored key) but the row's source is a still-valid saved card (ccof:) — the replay may have landed a NEW charge under an expired idempotency key — leaving row %s PENDING without failing/clawing back — MANUAL RECONCILIATION REQUIRED: verify at Square whether a charge exists before re-issuing", table, r.ID)
|
||||
}
|
||||
log.Printf("Stale pending %s reconcile by key: Square has no payment under the stored idempotency key (identical-body replay rejected) — marking failed; the charge provably never happened", table)
|
||||
return staleReconcileDefinitivelyFailed, ""
|
||||
@@ -850,11 +894,9 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
|
||||
// retained-key replay while the dev mock (source-aware only)
|
||||
// rescues it. The stranded row would otherwise be invisible
|
||||
// until the 24h blind-fail — surface it now (finding 2).
|
||||
log.Printf("CRITICAL: stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED via the minimal snapshot-less fallback body (key=%s) — real Square compares the WHOLE request (reference_id/customer_id/note/buyer_email_address absent from the rebuilt body) and rejects the identical-key replay, stranding the row pending; the dev mock would rescue it — dev-vs-prod observability divergence, NOT proof the charge never happened — MANUAL RECONCILIATION REQUIRED", table, r.IdempotencyKey)
|
||||
} else {
|
||||
log.Printf("CRITICAL: stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED — the stored square_source_id differs from the original charge's source (data bug); this is NOT proof the charge never happened — leaving pending — MANUAL RECONCILIATION REQUIRED", table)
|
||||
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED via the minimal snapshot-less fallback body (key=%s) — real Square compares the WHOLE request (reference_id/customer_id/note/buyer_email_address absent from the rebuilt body) and rejects the identical-key replay, stranding the row pending; the dev mock would rescue it — dev-vs-prod observability divergence, NOT proof the charge never happened — MANUAL RECONCILIATION REQUIRED", table, r.IdempotencyKey)
|
||||
}
|
||||
return staleReconcileLeavePending, ""
|
||||
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED — the stored square_source_id differs from the original charge's source (data bug); this is NOT proof the charge never happened — leaving pending — MANUAL RECONCILIATION REQUIRED", table)
|
||||
}
|
||||
log.Printf("Stale pending %s reconcile by idempotency key hit an ambiguous error (%v) — leaving pending for a later sweep run", table, err)
|
||||
return staleReconcileLeavePending, ""
|
||||
@@ -881,9 +923,7 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
|
||||
if createdOK {
|
||||
lag = created.Sub(r.CreatedAt).Round(time.Minute).String()
|
||||
}
|
||||
log.Printf("CRITICAL: stale pending %s reconcile by key: the replayed COMPLETED payment %s was created after the pending row %s (lag %s) — a NEW charge under an expired idempotency key (likely a second charge against a still-valid saved card), NOT the original charge — leaving the row PENDING without rescue — MANUAL RECONCILIATION REQUIRED: check Square for both charges and refund the duplicate", table, pr.ID, r.ID, lag)
|
||||
notifyStaleRowCritical(ctx, r)
|
||||
return staleReconcileLeavePending, ""
|
||||
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: the replayed COMPLETED payment %s was created after the pending row %s (lag %s) — a NEW charge under an expired idempotency key (likely a second charge against a still-valid saved card), NOT the original charge — leaving the row PENDING without rescue — MANUAL RECONCILIATION REQUIRED: check Square for both charges and refund the duplicate", table, pr.ID, r.ID, lag)
|
||||
}
|
||||
return staleReconcileCompleted, pr.ID
|
||||
case "CANCELED", "FAILED":
|
||||
@@ -915,7 +955,10 @@ func leaveGiftCardPurchasePending(ctx context.Context, r staleRow) {
|
||||
// stand-in for the un-watched CRITICAL payment logs). bookingID is set when the
|
||||
// issue ties to a booking (untracked terminal charges); userID is set when it
|
||||
// ties to a user (gift-card purchases). The NOT EXISTS guard keeps ONE
|
||||
// notification per issue instead of one per sweep run.
|
||||
// notification per issue instead of one per sweep run, and requires the prior
|
||||
// notification to be unacknowledged (acknowledged_at IS NULL) so that after an
|
||||
// admin acknowledges it, a NEW event for the same booking/user re-notifies —
|
||||
// matching the webhook copy's guard (handlers/webhooks/square.go) exactly.
|
||||
func insertCriticalPaymentNotification(ctx context.Context, bookingID, userID *string) {
|
||||
tag, err := db.Conn.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id, created_at)
|
||||
@@ -925,6 +968,7 @@ func insertCriticalPaymentNotification(ctx context.Context, bookingID, userID *s
|
||||
WHERE an.reason = 'critical_payment_log'
|
||||
AND an.booking_id IS NOT DISTINCT FROM $1
|
||||
AND an.user_id IS NOT DISTINCT FROM $2
|
||||
AND an.acknowledged_at IS NULL
|
||||
)
|
||||
`, bookingID, userID)
|
||||
if err != nil {
|
||||
@@ -955,6 +999,21 @@ func notifyStaleRowCritical(ctx context.Context, r staleRow) {
|
||||
insertCriticalPaymentNotification(ctx, nil, userID)
|
||||
}
|
||||
|
||||
// leavePendingCritical is the shared terminal outcome for a stale pending row
|
||||
// that cannot be safely resolved by the sweep: it logs a CRITICAL line (the
|
||||
// caller's existing message), raises the deduped critical-payment admin
|
||||
// notification via notifyStaleRowCritical, and returns
|
||||
// staleReconcileLeavePending so the row stays pending for a human. Every
|
||||
// "do not touch, an operator must reconcile" branch — snapshot-decrypt
|
||||
// failure, the ccof replay rejection, IDEMPOTENCY_KEY_REUSED and the
|
||||
// replayed-new-charge case (A4) — now runs the identical log + notification
|
||||
// outcome through this one helper.
|
||||
func leavePendingCritical(ctx context.Context, r staleRow, format string, args ...any) (staleReconcileResult, string) {
|
||||
notifyStaleRowCritical(ctx, r)
|
||||
log.Printf("CRITICAL: "+format, args...)
|
||||
return staleReconcileLeavePending, ""
|
||||
}
|
||||
|
||||
// staleReconcileResult is the tri-state outcome of reconciling one stale
|
||||
// pending row against Square. Only a definitively-resolved outcome touches the
|
||||
// row: an ambiguous answer (transport error / 5xx) leaves it pending so a
|
||||
@@ -1056,26 +1115,27 @@ func squareHasCode(err error, codes ...string) bool {
|
||||
}
|
||||
|
||||
// sweepSquarelessManualRefundsAtAttemptCap reconciles Square-less MANUAL refund
|
||||
// rows that have already hit the 3-attempt cap. The Square-less pre-pass inside
|
||||
// sweepManualPendingSquareRefunds (refunds.go) filters refund_attempts < 3, so
|
||||
// a legacy manual refund on a payment with no square_payment_id that reached
|
||||
// the cap (attempts incremented by pre-guard Square attempts) is never
|
||||
// reconciled by it and would stay 'pending' forever, permanently blocking the
|
||||
// over-refund guard (F10). Such rows can never be refunded via Square, so they
|
||||
// are marked 'failed' and surfaced in the admin notification centre for
|
||||
// in-person arrangement — the same terminal treatment the pre-pass gives rows
|
||||
// under the cap. Returns the number of rows marked failed.
|
||||
// rows that have already hit the attempt cap (maxManualRefundAttempts). The
|
||||
// Square-less pre-pass inside sweepManualPendingSquareRefunds (refunds.go)
|
||||
// filters refund_attempts < maxManualRefundAttempts, so a legacy manual refund
|
||||
// on a payment with no square_payment_id that reached the cap (attempts
|
||||
// incremented by pre-guard Square attempts) is never reconciled by it and would
|
||||
// stay 'pending' forever, permanently blocking the over-refund guard (F10).
|
||||
// Such rows can never be refunded via Square, so they are marked 'failed' and
|
||||
// surfaced in the admin notification centre for in-person arrangement — the
|
||||
// same terminal treatment the pre-pass gives rows under the cap. Returns the
|
||||
// number of rows marked failed.
|
||||
func sweepSquarelessManualRefundsAtAttemptCap(ctx context.Context) (int, error) {
|
||||
rows, err := db.Conn.Query(ctx, `
|
||||
rows, err := db.Conn.Query(ctx, fmt.Sprintf(`
|
||||
UPDATE refunds r SET status = 'failed'
|
||||
FROM payments p
|
||||
WHERE p.id = r.payment_id
|
||||
AND r.status = 'pending' AND r.refund_attempts >= 3
|
||||
AND r.status = 'pending' AND r.refund_attempts >= %d
|
||||
AND p.payment_method IN ('online_square', 'in_person_card')
|
||||
AND p.square_payment_id IS NULL
|
||||
AND r.origin = 'manual'
|
||||
RETURNING r.id
|
||||
`)
|
||||
`, maxManualRefundAttempts))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -1091,7 +1151,7 @@ func sweepSquarelessManualRefundsAtAttemptCap(ctx context.Context) (int, error)
|
||||
return 0, err
|
||||
}
|
||||
if len(failedIDs) > 0 {
|
||||
log.Printf("Marked %d Square-less manual refund(s) at the 3-attempt cap failed (in-person arrangement needed)", len(failedIDs))
|
||||
log.Printf("Marked %d Square-less manual refund(s) at the %d-attempt cap failed (in-person arrangement needed)", len(failedIDs), maxManualRefundAttempts)
|
||||
}
|
||||
insertRefundFailedNotifications(ctx, failedIDs)
|
||||
return len(failedIDs), nil
|
||||
|
||||
@@ -837,11 +837,17 @@ func TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues(t *testing
|
||||
t.Fatalf("failed to age the stale payment: %v", err)
|
||||
}
|
||||
|
||||
// The replayed payment is the ORIGINAL — created at the same instant as the
|
||||
// pending row (~23h ago), as a retained-key dedup returns. The env is
|
||||
// flipped to production for the sweep so the A1 cross-check runs; the dev
|
||||
// mock is constructed BEFORE the flip (NewDevClient refuses production
|
||||
// without SQUARE_ALLOW_REAL_API).
|
||||
// The replayed payment is the ORIGINAL — created at the SAME instant as the
|
||||
// pending row, as a retained-key dedup returns. Seeding CreatedAt from the
|
||||
// row's own timestamp makes the F2 lag ~0 deterministically (a fixed
|
||||
// clock.Now()-relative offset would race the DB NOW() microsecond
|
||||
// truncation). The env is flipped to production for the sweep so the A1
|
||||
// cross-check runs; the dev mock is constructed BEFORE the flip
|
||||
// (NewDevClient refuses production without SQUARE_ALLOW_REAL_API).
|
||||
var rowCreatedAt time.Time
|
||||
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
|
||||
t.Fatalf("failed to read aged payment created_at: %v", err)
|
||||
}
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient()
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
||||
@@ -849,7 +855,7 @@ func TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues(t *testing
|
||||
Status: "COMPLETED",
|
||||
ID: "pay_original_under_key",
|
||||
SquarePayID: "pay_original_under_key",
|
||||
CreatedAt: clock.Now().Add(-23 * time.Hour).Format(time.RFC3339),
|
||||
CreatedAt: rowCreatedAt.Format(time.RFC3339Nano),
|
||||
}}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
|
||||
@@ -63,11 +63,22 @@ type TillSaleResponse struct {
|
||||
// request-stable key instead.
|
||||
|
||||
// definitivePaymentDeclineCodes are Square payment error codes meaning the
|
||||
// card charge can never succeed (declined / expired / not supported). They are
|
||||
// matched against the formatted Square API error so a DEFINITIVE rejection can
|
||||
// claw back a gift card funded earlier in the same till-sale request. Anything
|
||||
// else (transport errors, 5xx, unknown) is treated as ambiguous: the sale is
|
||||
// left pending for the stale-pending sweep, which may still resolve it.
|
||||
// card charge can never succeed (declined / expired / not supported / SCA
|
||||
// verification required). They are matched against the formatted Square API
|
||||
// error message so a DEFINITIVE rejection can claw back a gift card funded
|
||||
// earlier in the same till-sale request. Anything else (transport errors, 5xx,
|
||||
// unknown) is treated as ambiguous: the sale is left pending for the
|
||||
// stale-pending sweep, which may still resolve it.
|
||||
//
|
||||
// A1: this list is the MESSAGE-MATCH fallback ONLY. The authoritative
|
||||
// structured-code classification lives in the square package's
|
||||
// definitivePaymentCodes / IsDefinitivePaymentError
|
||||
// (square_http_client.go:777-818) — the single source of truth that also
|
||||
// carries the SCA buyer-verification codes — and isDefinitiveChargeFailure
|
||||
// delegates to it FIRST. The list below must stay a subset-compatible mirror
|
||||
// for errors that carry no structured code (the dev mock's plain errors, a
|
||||
// non-JSON failure body), where the formatted "[CATEGORY/CODE]" message is the
|
||||
// only signal available.
|
||||
var definitivePaymentDeclineCodes = []string{
|
||||
"CARD_DECLINED",
|
||||
"CARD_EXPIRED",
|
||||
@@ -81,27 +92,60 @@ var definitivePaymentDeclineCodes = []string{
|
||||
"INSUFFICIENT_FUNDS",
|
||||
"ADDRESS_VERIFICATION_FAILURE",
|
||||
"TRANSACTION_LIMIT",
|
||||
// Square's specific CARD_DECLINED_* decline reasons. This list must stay a
|
||||
// SUPERSET-MATCHED MIRROR of square_http_client.go:definitivePaymentCodes
|
||||
// (the single source of truth) — every variant the square package treats as
|
||||
// definitive must also match here, so a dev-mock plain error carrying e.g.
|
||||
// CARD_DECLINED_INSUFFICIENT_FUNDS in its formatted message classifies
|
||||
// definitively. Add any new definitivePaymentCodes entry here too.
|
||||
"CARD_DECLINED_CALL_ISSUER",
|
||||
"CARD_DECLINED_AVS_FAILURE",
|
||||
"CARD_DECLINED_CVV_FAILURE",
|
||||
"CARD_DECLINED_INSUFFICIENT_FUNDS",
|
||||
"CARD_DECLINED_INVALID_ACCOUNT",
|
||||
"CARD_DECLINED_INVALID_AMOUNT",
|
||||
"CARD_DECLINED_CARD_EXPIRED",
|
||||
"CARD_DECLINED_PIN_RETRIES_EXCEEDED",
|
||||
// SCA / buyer-verification codes — the buyer must re-verify or the card be
|
||||
// re-tokenized before the charge can succeed; retrying is pointless. Kept
|
||||
// in the message fallback so the dev mock's plain errors classify exactly
|
||||
// like the real client's structured codes.
|
||||
"CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
"VERIFICATION_TOKEN_EXPIRED",
|
||||
"VERIFICATION_TOKEN_INVALID",
|
||||
"CVV_VERIFICATION_REQUIRED",
|
||||
"ADDRESS_VERIFICATION_REQUIRED",
|
||||
"MISSING_PIN",
|
||||
"MISSING_VERIFICATION_TOKEN",
|
||||
}
|
||||
|
||||
// isDefinitiveChargeFailure reports whether a Square CreatePayment error is a
|
||||
// definitive business rejection (declined/expired) rather than an ambiguous
|
||||
// transport/server error. The real HTTP client surfaces declines as a
|
||||
// structured squareAPIError carrying the Square error Code (and Category), so
|
||||
// the classification matches those EXACTLY against definitivePaymentDeclineCodes
|
||||
// — a Square message-wording change can never silently flip the
|
||||
// definitive↔retryable decision that drives the gift-card funding clawback.
|
||||
// Only errors that carry NO structured code (the dev mock's plain errors, or a
|
||||
// non-JSON failure body) fall back to the legacy formatted-message match
|
||||
// ("square: POST /v2/payments: [CATEGORY/CODE] ..."), which is the only signal
|
||||
// available for them.
|
||||
// definitive business rejection (declined/expired/SCA-required) rather than an
|
||||
// ambiguous transport/server error.
|
||||
//
|
||||
// A1 — single source of truth: the classification delegates FIRST to the
|
||||
// square package's exported IsDefinitivePaymentError (square_http_client.go
|
||||
// definitivePaymentCodes), which is the union of the card decline codes and
|
||||
// the SCA buyer-verification codes. Delegating means the two parallel lists
|
||||
// can never drift again — an SCA rejection (e.g. CVV_VERIFICATION_REQUIRED)
|
||||
// now classifies as definitive here exactly as it does everywhere else, so the
|
||||
// till's gift-card clawback (till.go:1184) reverses the funding on a
|
||||
// verification failure the same way it does on a plain decline. Only errors
|
||||
// that carry NO structured code (the dev mock's plain errors, or a non-JSON
|
||||
// failure body) fall back to the legacy formatted-message match
|
||||
// ("square: POST /v2/payments: [CATEGORY/CODE] ...") against
|
||||
// definitivePaymentDeclineCodes — the only signal available for them.
|
||||
func isDefinitiveChargeFailure(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// The formatted message carries both [CATEGORY/CODE] and the legacy check
|
||||
// matched either, so compare the Code AND the Category exactly.
|
||||
if code := square.ErrorCode(err); code != "" {
|
||||
return declineCodeListContains(code) || declineCodeListContains(square.ErrorCategory(err))
|
||||
if square.IsDefinitivePaymentError(err) {
|
||||
return true
|
||||
}
|
||||
// Any other structured Square error code is authoritative — never
|
||||
// substring-match its message.
|
||||
if square.ErrorCode(err) != "" {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToUpper(err.Error())
|
||||
for _, code := range definitivePaymentDeclineCodes {
|
||||
@@ -112,17 +156,6 @@ func isDefinitiveChargeFailure(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// declineCodeListContains reports whether s is exactly one of the definitive
|
||||
// payment decline codes.
|
||||
func declineCodeListContains(s string) bool {
|
||||
for _, code := range definitivePaymentDeclineCodes {
|
||||
if s == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// deriveTillIdempotencyKey returns the deterministic no-client-key fallback
|
||||
// BASE idempotency key for a till sale: "till-" + sha256 over the canonical
|
||||
// request fields (action, created_by admin, amount in pence, and the gift card
|
||||
@@ -1119,6 +1152,15 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
// returns IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
|
||||
// The snapshot holds PII (buyer email + ccof token), so it is
|
||||
// encrypted at rest via encryptSnapshot (plaintext in dev/mock).
|
||||
//
|
||||
// The write is INTENTIONALLY unconditional (WHERE id = $2, no
|
||||
// snapshot-is-null guard like the booking/tip/terminal flows): the
|
||||
// pending-reuse branch above already refreshed square_request_snapshot
|
||||
// in the SAME transaction as the square_source_id refresh
|
||||
// (refreshTillSnapshotSource, B6), and this post-commit write stores
|
||||
// the fresh full body for THIS attempt. A guard would wrongly skip
|
||||
// this write on the reuse path when the in-tx refresh failed
|
||||
// best-effort — do NOT "fix" it into the guarded form.
|
||||
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
||||
log.Printf("Failed to marshal square_request_snapshot for till sale %s: %v", tillSaleID, mErr)
|
||||
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
|
||||
@@ -1156,6 +1198,15 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
// charge with an IDENTICAL body under the same key — Square
|
||||
// compares the whole request on key reuse, and a reconstructed body
|
||||
// returns IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
|
||||
//
|
||||
// The write is INTENTIONALLY unconditional (WHERE id = $2, no
|
||||
// snapshot-is-null guard like the booking/tip/terminal flows): the
|
||||
// pending-reuse branch above already refreshed square_request_snapshot
|
||||
// in the SAME transaction as the square_source_id refresh
|
||||
// (refreshTillSnapshotSource, B6), and this post-commit write stores
|
||||
// the fresh full body for THIS attempt. A guard would wrongly skip
|
||||
// this write on the reuse path when the in-tx refresh failed
|
||||
// best-effort — do NOT "fix" it into the guarded form.
|
||||
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
||||
log.Printf("Failed to marshal square_request_snapshot for till sale %s: %v", tillSaleID, mErr)
|
||||
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
|
||||
|
||||
@@ -32,26 +32,14 @@ func require2FADisabled() bool {
|
||||
// It is fail-closed: enforcement is ON unless 2FA has been explicitly disabled
|
||||
// (REQUIRE_2FA=false/0/off/no, case-insensitive — see require2FADisabled) or
|
||||
// SQUARE_ENVIRONMENT explicitly selects the dev/mock stack
|
||||
// (mock/dev/development/test). Empty or unknown SQUARE_ENVIRONMENT values are
|
||||
// (mock/dev/development/test — see IsExplicitDevOrMockEnv in
|
||||
// idempotency_helpers.go). Empty or unknown SQUARE_ENVIRONMENT values are
|
||||
// treated as production-enforced, so a mistyped env var can never silently
|
||||
// disarm the gate — main.go logs a startup warning for that misconfiguration.
|
||||
func twoFactorEnforced() bool {
|
||||
return !require2FADisabled() && !IsExplicitDevOrMockEnv()
|
||||
}
|
||||
|
||||
// IsExplicitDevOrMockEnv reports whether SQUARE_ENVIRONMENT explicitly selects
|
||||
// the dev/mock Square stack. Only these exact values are treated as dev; an
|
||||
// empty or unknown value is NOT dev (fail-closed), because in production an
|
||||
// unset/mistyped env var must never bypass the 2FA gate.
|
||||
func IsExplicitDevOrMockEnv() bool {
|
||||
switch os.Getenv("SQUARE_ENVIRONMENT") {
|
||||
case "mock", "dev", "development", "test":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TwoFactorEnforced is the exported form of twoFactorEnforced, so the user
|
||||
// package (settings endpoints) and the profile handler can report whether 2FA
|
||||
// is currently required without re-implementing the env logic.
|
||||
|
||||
@@ -26,12 +26,12 @@ func ValidateAmount(amount int64) error {
|
||||
}
|
||||
|
||||
// ValidatePartialAmount checks that the partial amount doesn't exceed the remaining balance
|
||||
func ValidatePartialAmount(amountCents int64, remainingCents int64) error {
|
||||
if amountCents > remainingCents {
|
||||
func ValidatePartialAmount(amountPence int64, remainingPence int64) error {
|
||||
if amountPence > remainingPence {
|
||||
return fmt.Errorf("partial amount (£%.2f) exceeds remaining balance (£%.2f)",
|
||||
float64(amountCents)/100, float64(remainingCents)/100)
|
||||
float64(amountPence)/100, float64(remainingPence)/100)
|
||||
}
|
||||
if amountCents <= 0 {
|
||||
if amountPence <= 0 {
|
||||
return errors.New("amount must be greater than 0")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1671,12 +1671,12 @@ func TestVAT_RemainingBalanceWithVAT(t *testing.T) {
|
||||
|
||||
// Remaining balance should be total - paid (gross) = 100 - 30 = 70
|
||||
svc := NewPaymentService()
|
||||
remaining, rErr := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
remaining, rErr := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
if rErr != nil {
|
||||
t.Fatalf("GetBookingRemainingBalanceCents failed: %v", rErr)
|
||||
t.Fatalf("GetBookingRemainingBalancePence failed: %v", rErr)
|
||||
}
|
||||
if remaining != 7000 {
|
||||
t.Errorf("expected remaining 7000 cents (£70), got %d", remaining)
|
||||
t.Errorf("expected remaining 7000 pence (£70), got %d", remaining)
|
||||
}
|
||||
|
||||
// Pay another £40 with VAT — remaining should be 100 - 70 = 30
|
||||
@@ -1700,12 +1700,12 @@ func TestVAT_RemainingBalanceWithVAT(t *testing.T) {
|
||||
t.Fatalf("second payment: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
remaining2, rErr2 := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
remaining2, rErr2 := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
if rErr2 != nil {
|
||||
t.Fatalf("GetBookingRemainingBalanceCents failed: %v", rErr2)
|
||||
t.Fatalf("GetBookingRemainingBalancePence failed: %v", rErr2)
|
||||
}
|
||||
if remaining2 != 3000 {
|
||||
t.Errorf("expected remaining 3000 cents (£30), got %d", remaining2)
|
||||
t.Errorf("expected remaining 3000 pence (£30), got %d", remaining2)
|
||||
}
|
||||
|
||||
// Pay the final £30 — remaining should be 0
|
||||
@@ -1729,9 +1729,9 @@ func TestVAT_RemainingBalanceWithVAT(t *testing.T) {
|
||||
t.Fatalf("third payment: expected 200, got %d: %s", w3.Code, w3.Body.String())
|
||||
}
|
||||
|
||||
remaining3, rErr3 := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
remaining3, rErr3 := svc.GetBookingRemainingBalancePence(ctx, bookingID)
|
||||
if rErr3 != nil {
|
||||
t.Fatalf("GetBookingRemainingBalanceCents failed: %v", rErr3)
|
||||
t.Fatalf("GetBookingRemainingBalancePence failed: %v", rErr3)
|
||||
}
|
||||
if remaining3 != 0 {
|
||||
t.Errorf("expected remaining 0, got %d", remaining3)
|
||||
|
||||
@@ -160,8 +160,10 @@ func webhookDBContext() (context.Context, context.CancelFunc) {
|
||||
// In dev/mock deployments — or an empty/unknown SQUARE_ENVIRONMENT, which the
|
||||
// rest of the backend treats as fail-closed production but is not a specific
|
||||
// real environment to compare against — the header is informational and a
|
||||
// mismatch is not rejectable, mirroring IsExplicitDevOrMockEnv
|
||||
// (handlers/payments/twofa.go) so the interpretation cannot diverge.
|
||||
// mismatch is not rejectable. The dev/mock determination delegates to the
|
||||
// shared payments.IsExplicitDevOrMockEnv (handlers/payments/twofa.go) rather
|
||||
// than re-implementing the env-value list, so this check and the 2FA gate can
|
||||
// never diverge on what counts as the dev/mock stack.
|
||||
//
|
||||
// An absent header is allowed through: real Square deliveries always send it,
|
||||
// so a missing header in an enforced deployment is a non-Square client (which
|
||||
@@ -179,14 +181,20 @@ func squareEnvironmentMismatch(headerEnv string) bool {
|
||||
if headerEnv == "" {
|
||||
return false
|
||||
}
|
||||
switch configured := strings.ToLower(strings.TrimSpace(os.Getenv("SQUARE_ENVIRONMENT"))); configured {
|
||||
case "production", "sandbox":
|
||||
return headerEnv != configured
|
||||
default:
|
||||
// Empty/unknown/dev/mock configured environment — no specific real
|
||||
// environment to enforce against.
|
||||
// Dev/mock deployments (mock/dev/development/test) are never enforced —
|
||||
// the shared predicate is the single source of truth for that
|
||||
// classification, so the webhook's interpretation matches the 2FA gate
|
||||
// exactly (handlers/payments/twofa.go:IsExplicitDevOrMockEnv).
|
||||
if payments.IsExplicitDevOrMockEnv() {
|
||||
return false
|
||||
}
|
||||
configured := strings.ToLower(strings.TrimSpace(os.Getenv("SQUARE_ENVIRONMENT")))
|
||||
if configured != "production" && configured != "sandbox" {
|
||||
// Empty/unknown configured environment — no specific real environment
|
||||
// to enforce against; the header is informational only.
|
||||
return false
|
||||
}
|
||||
return headerEnv != configured
|
||||
}
|
||||
|
||||
// HandleSquareWebhook verifies and dispatches Square webhook events.
|
||||
@@ -635,6 +643,24 @@ func disputeNotificationID(squareDisputeID string) string {
|
||||
// internal/jobs/cleanup.go). Dedup: one unacknowledged row per (reason,
|
||||
// booking_id) — acknowledging re-arms it.
|
||||
//
|
||||
// NOTE: this is the WEBHOOK-specific variant of a same-named helper in the
|
||||
// payments package (handlers/payments/sweep.go, insertCriticalPaymentNotification)
|
||||
// with DIFFERENT semantics: that one has the signature (ctx, bookingID,
|
||||
// userID *string), writes the user_id column, and dedups on (reason,
|
||||
// booking_id, user_id); this one takes (ctx, bookingID, disputeID string),
|
||||
// never writes user_id, and dedups on (reason, booking_id) or the
|
||||
// deterministic per-dispute id. They share the admin_notifications table and
|
||||
// the 'critical_payment_log' reason but serve different call paths (sweep vs
|
||||
// webhook) — do not merge them.
|
||||
//
|
||||
// AUTHORITATIVE NOT EXISTS GUARD: the booking-scoped branch below is the
|
||||
// canonical form of this dedup — `an.reason = 'critical_payment_log' AND
|
||||
// an.booking_id IS NOT DISTINCT FROM $1 AND an.acknowledged_at IS NULL` (a
|
||||
// notification blocks a re-notify until the admin acknowledges it, then a NEW
|
||||
// event re-arms). sweep.go's insertCriticalPaymentNotification mirrors this
|
||||
// predicate exactly (its copy is the same guard over (reason, booking_id,
|
||||
// user_id)); if the guard ever changes, sweep.go must be updated to match.
|
||||
//
|
||||
// Untracked disputes (no local payment row, booking_id NULL) pass disputeID
|
||||
// instead: each DISTINCT square dispute gets its OWN notification under the
|
||||
// deterministic id (disputeNotificationID), so a second distinct chargeback is
|
||||
@@ -687,7 +713,10 @@ func insertCriticalPaymentNotification(ctx context.Context, bookingID, disputeID
|
||||
// for an unhandled money-family/truly-unknown webhook event's
|
||||
// critical_payment_log notification: 'U' + 11 lowercase hex chars of a SHA-256
|
||||
// over 'unknown-event-<event_id>'. Mirrors disputeNotificationID (which uses an
|
||||
// uppercase 'D' prefix): generate_*_id (init-script.sql) only ever emits 12
|
||||
// uppercase 'D' prefix): the two are intentionally parallel — same
|
||||
// prefix + hex(sha256(input))[:11] scheme (44 bits), DIFFERENT prefixes, so a
|
||||
// dispute id and an unknown-event id can never collide even on identical hash
|
||||
// input. generate_*_id (init-script.sql) only ever emits 12
|
||||
// lowercase hex chars, so the uppercase 'U' prefix guarantees no collision with
|
||||
// a DB-generated id. The id is stable per event_id, giving
|
||||
// ON CONFLICT (id) DO NOTHING per-event idempotency across redeliveries.
|
||||
@@ -721,40 +750,6 @@ func insertUnknownEventNotification(ctx context.Context, eventType, eventID stri
|
||||
}
|
||||
}
|
||||
|
||||
// insertRefundFailedNotification surfaces a webhook-demoted FAILED refund in
|
||||
// the admin notification centre, replicating payments.insertRefundFailedNotifications
|
||||
// (handlers/payments/refunds.go) — the same 'refund_failed' row and the same
|
||||
// per-booking dedup. The payments helper is unexported (different package) and
|
||||
// the sweep-path notification it backs is permanently lost once THIS webhook
|
||||
// demotes pending→failed (the sweep only processes 'pending' rows), so the
|
||||
// webhook must raise the notification itself. Dedup: one row per
|
||||
// (reason='refund_failed', booking_id), matching the sweep's guard so a later
|
||||
// sweep run can never duplicate it. Best-effort: a failed insert is logged,
|
||||
// never a dispatch error. The caller supplies a bounded context
|
||||
// (webhookDBContext).
|
||||
func insertRefundFailedNotification(ctx context.Context, refundID string) {
|
||||
if refundID == "" {
|
||||
return
|
||||
}
|
||||
tag, err := db.Conn.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (reason, booking_id, created_at)
|
||||
SELECT DISTINCT 'refund_failed'::admin_notification_reason, booking_id, NOW()
|
||||
FROM refunds
|
||||
WHERE id = ANY($1) AND status = 'failed'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM admin_notifications an
|
||||
WHERE an.reason = 'refund_failed' AND an.booking_id = refunds.booking_id
|
||||
)
|
||||
`, []string{refundID})
|
||||
if err != nil {
|
||||
log.Printf("[SQUARE-WEBHOOK] Failed to insert refund_failed admin notification for refund %s: %v", refundID, err)
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
log.Printf("[SQUARE-WEBHOOK] Inserted refund_failed admin notification (refund_id=%s)", refundID)
|
||||
}
|
||||
}
|
||||
|
||||
// markPaymentFailed flips a payment to 'failed' after a lost dispute — the
|
||||
// money was charged back, so the row must not read as collected. 'refunded'
|
||||
// rows are left alone (the money was returned by refund, not charged back).
|
||||
@@ -988,9 +983,11 @@ func handleRefundUpdated(data json.RawMessage) error {
|
||||
case "failed":
|
||||
// A5d: this webhook demotes a 'pending' row to 'failed' BEFORE the sweep
|
||||
// ever sees it — the sweep only processes 'pending' rows, so its
|
||||
// failed-refund admin notification (insertRefundFailedNotifications in
|
||||
// refunds.go) would be permanently lost. Raise the same 'refund_failed'
|
||||
// notification here, guarded to the actually-demoted row.
|
||||
// failed-refund admin notification would be permanently lost. Raise the
|
||||
// same 'refund_failed' notification here (via the shared exported
|
||||
// payments.InsertRefundFailedNotifications — the single source for the
|
||||
// SQL and its (reason='refund_failed', booking_id) dedup guard), guarded
|
||||
// to the actually-demoted row.
|
||||
var rowID string
|
||||
err := db.Conn.QueryRow(ctx,
|
||||
`UPDATE refunds SET status = 'failed' WHERE square_refund_id = $1 AND status = 'pending' RETURNING id`,
|
||||
@@ -1005,7 +1002,7 @@ func handleRefundUpdated(data json.RawMessage) error {
|
||||
return err
|
||||
}
|
||||
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s → local status failed (row %s)", refund.ID, rowID)
|
||||
insertRefundFailedNotification(ctx, rowID)
|
||||
payments.InsertRefundFailedNotifications(ctx, []string{rowID})
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -55,6 +55,12 @@ func (p *ProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
||||
return refundPaymentHTTP(ctx, req)
|
||||
}
|
||||
|
||||
// PaymentWasRefunded has ZERO production callers (grep across the repo
|
||||
// confirms the only users are this package's tests) and is kept on the
|
||||
// SquareClient interface solely so the dev mock's refund-reconciliation
|
||||
// parity tests can exercise the COMPLETED/APPROVED/PENDING status set.
|
||||
// Production reconciliation uses the package-level
|
||||
// paymentRefundedExactlyWithClient inside refundPaymentHTTP instead.
|
||||
func (p *ProdClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
|
||||
return paymentWasRefundedWithClient(ctx, paymentID, newHTTPClient())
|
||||
}
|
||||
|
||||
@@ -188,6 +188,9 @@ func (d *devProdClient) CancelCheckout(ctx context.Context, checkoutID string) e
|
||||
func (d *devProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
return refundPaymentHTTP(ctx, req)
|
||||
}
|
||||
// PaymentWasRefunded has ZERO production callers — kept only to satisfy the
|
||||
// SquareClient interface for the dev mock's refund-reconciliation parity
|
||||
// tests. Production reconciliation uses paymentRefundedExactlyWithClient.
|
||||
func (d *devProdClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
|
||||
return PaymentWasRefunded(ctx, paymentID)
|
||||
}
|
||||
@@ -311,11 +314,13 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
}
|
||||
}
|
||||
// Square's idempotency-key limit for POST /v2/payments is 45 characters
|
||||
// (64 only for /v2/terminals/checkouts). Real Square rejects an oversized
|
||||
// key with a 400 VALUE_TOO_LONG; the mock mirrors the rejection with the
|
||||
// same structured error so dev parity catches over-length keys (the real
|
||||
// client always derives ≤45-char keys, so this only fires on a caller bug).
|
||||
if len(req.IdempotencyKey) > 45 {
|
||||
// (64 only for /v2/terminals/checkouts) — MaxIdempotencyKeyLength
|
||||
// (square_http_client.go), the single source the payments package also
|
||||
// aliases. Real Square rejects an oversized key with a 400
|
||||
// VALUE_TOO_LONG; the mock mirrors the rejection with the same structured
|
||||
// error so dev parity catches over-length keys (the real client always
|
||||
// derives ≤45-char keys, so this only fires on a caller bug).
|
||||
if len(req.IdempotencyKey) > MaxIdempotencyKeyLength {
|
||||
return nil, &squareAPIError{
|
||||
Code: "VALUE_TOO_LONG",
|
||||
Detail: "idempotency_key must be 45 characters or fewer",
|
||||
@@ -872,7 +877,10 @@ func (m *MockClient) RefundKeyCount() int {
|
||||
// any refund with status COMPLETED, APPROVED, or PENDING exists for the payment
|
||||
// (FAILED/REJECTED refunds never moved money and are ignored). Shares the exact
|
||||
// status set the real client's paymentWasRefundedWithClient uses so handler
|
||||
// reconciliation behaves identically in dev/mock and production.
|
||||
// reconciliation behaves identically in dev/mock and production. TEST-ONLY on
|
||||
// the SquareClient interface (no production callers — reconciliation uses the
|
||||
// package-level paymentRefundedExactlyWithClient); kept so this mock satisfies
|
||||
// the interface and its refund-status parity tests can exercise the set.
|
||||
func (m *MockClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@@ -41,6 +41,17 @@ const (
|
||||
// Handlers log these errors verbatim, so echoing more than a snippet risks
|
||||
// leaking PII that Square may have mirrored from the request.
|
||||
maxErrorBody = 500
|
||||
|
||||
// MaxIdempotencyKeyLength is Square's 45-character idempotency-key limit for
|
||||
// /v2/payments, /v2/cards and /v2/refunds (64 only for
|
||||
// /v2/terminals/checkouts). The square package is the client to Square, so
|
||||
// THIS is the SINGLE SOURCE of the cap: square_dev.go's mock rejection and
|
||||
// the card/customer key builders route through it, and the payments package
|
||||
// aliases it (handlers/payments/idempotency_helpers.go's
|
||||
// maxIdempotencyKeyLength = square.MaxIdempotencyKeyLength) rather than
|
||||
// declaring a second, drifting 45. Update this one constant if Square ever
|
||||
// changes the limit.
|
||||
MaxIdempotencyKeyLength = 45
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -624,10 +635,14 @@ func replayPaymentByKeyHTTP(ctx context.Context, snapshotJSON []byte) (*PaymentR
|
||||
// so the snapshot is never reconstructed from partial row data.
|
||||
//
|
||||
// TODO (UNVERIFIED ASSUMPTION): this codebase assumes Square retains
|
||||
// idempotency keys for ~24 hours (the stale-pending sweeps use a 23h/25h age
|
||||
// guard on that window). Square's public docs no longer state the exact
|
||||
// retention window — confirm the current value with Square support and update
|
||||
// the sweep age guards and this comment when confirmed.
|
||||
// idempotency keys for ~24 hours. The stale-pending sweeps guard on that
|
||||
// window with three named constants (defined in handlers/payments): the keyed
|
||||
// reconcile cutoff stalePendingKeyedAge (22h, sweep.go), the pass-2
|
||||
// blind-fail cutoff stalePendingPaymentAge (24h, sweep.go), and the refund
|
||||
// age guard stalePendingRefundAge (23h, refunds.go). Square's public docs no
|
||||
// longer state the exact retention window — confirm the current value with
|
||||
// Square support and update the sweep age guards and this comment when
|
||||
// confirmed.
|
||||
func replayPaymentByKeyHTTPWithClient(ctx context.Context, snapshotJSON []byte, hc *httpClient) (*PaymentResult, error) {
|
||||
var req CreatePaymentReq
|
||||
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
|
||||
@@ -775,17 +790,23 @@ func IsNotFound(err error) bool {
|
||||
}
|
||||
|
||||
// definitivePaymentCodes are Square CreatePayment error codes that mean the
|
||||
// charge can NEVER succeed as-is. This includes the card decline/expiry codes
|
||||
// and — critically for SCA — the buyer-verification codes
|
||||
// (CARD_DECLINED_VERIFICATION_REQUIRED, VERIFICATION_TOKEN_EXPIRED,
|
||||
// VERIFICATION_TOKEN_INVALID, CVV_VERIFICATION_REQUIRED,
|
||||
// ADDRESS_VERIFICATION_REQUIRED, MISSING_PIN, MISSING_VERIFICATION_TOKEN):
|
||||
// those mean the user must re-verify (3DS/SCA) or re-tokenize the card, NOT
|
||||
// that the same request should be retried. A same-request retry with the same
|
||||
// source/token can never succeed, so the failure is DEFINITIVE. This map is
|
||||
// the package-level source of truth; handlers mirror it via
|
||||
// IsDefinitivePaymentError / square.ErrorCode (the dev mock emits the same
|
||||
// codes so dev parity holds).
|
||||
// charge can NEVER succeed as-is. This is the SINGLE authoritative list of
|
||||
// definitive payment rejections — the exported
|
||||
// IsDefinitivePaymentError/square.ErrorCode accessors classify through it, the
|
||||
// dev mock (square_dev.go) emits the same codes so dev parity holds, and the
|
||||
// payments package (handlers/payments/till.go) is being migrated to delegate
|
||||
// to square.IsDefinitivePaymentError instead of its own legacy list. Do not
|
||||
// maintain a second decline-code list anywhere else: add codes HERE.
|
||||
//
|
||||
// The list is the COMPLETE union of Square's decline/expiry codes (including
|
||||
// the specific CARD_DECLINED_* decline reasons) and — critically for SCA —
|
||||
// the buyer-verification codes (CARD_DECLINED_VERIFICATION_REQUIRED,
|
||||
// VERIFICATION_TOKEN_EXPIRED, VERIFICATION_TOKEN_INVALID,
|
||||
// CVV_VERIFICATION_REQUIRED, ADDRESS_VERIFICATION_REQUIRED, MISSING_PIN,
|
||||
// MISSING_VERIFICATION_TOKEN): those mean the user must re-verify (3DS/SCA)
|
||||
// or re-tokenize the card, NOT that the same request should be retried. A
|
||||
// same-request retry with the same source/token can never succeed, so the
|
||||
// failure is DEFINITIVE.
|
||||
var definitivePaymentCodes = map[string]bool{
|
||||
"CARD_DECLINED": true,
|
||||
"CARD_EXPIRED": true,
|
||||
@@ -799,6 +820,17 @@ var definitivePaymentCodes = map[string]bool{
|
||||
"INSUFFICIENT_FUNDS": true,
|
||||
"ADDRESS_VERIFICATION_FAILURE": true,
|
||||
"TRANSACTION_LIMIT": true,
|
||||
// Square's specific CARD_DECLINED_* decline reasons — each is a definitive
|
||||
// rejection of the charge as-is (the issuer declined for a specific
|
||||
// reason), so retrying the same request is pointless.
|
||||
"CARD_DECLINED_CALL_ISSUER": true,
|
||||
"CARD_DECLINED_AVS_FAILURE": true,
|
||||
"CARD_DECLINED_CVV_FAILURE": true,
|
||||
"CARD_DECLINED_INSUFFICIENT_FUNDS": true,
|
||||
"CARD_DECLINED_INVALID_ACCOUNT": true,
|
||||
"CARD_DECLINED_INVALID_AMOUNT": true,
|
||||
"CARD_DECLINED_CARD_EXPIRED": true,
|
||||
"CARD_DECLINED_PIN_RETRIES_EXCEEDED": true,
|
||||
// SCA / buyer-verification codes — the buyer must re-verify or the card be
|
||||
// re-tokenized before the charge can succeed; retrying is pointless.
|
||||
"CARD_DECLINED_VERIFICATION_REQUIRED": true,
|
||||
@@ -979,12 +1011,13 @@ func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken, cust
|
||||
// Deterministic idempotency key derived from user + card (not time-based)
|
||||
// so that retries with the same details don't create duplicate cards.
|
||||
// SHA-256 hash prevents recovering the card token from the key itself.
|
||||
// Truncated to ≤45 chars — Square's idempotency-key limit is 45 chars for
|
||||
// /v2/cards, /v2/payments, and /v2/refunds (64 only for
|
||||
// /v2/terminals/checkouts).
|
||||
// The 38-hex tail (2-char margin: 5 prefix chars + 38 hex = 43, under the
|
||||
// 45-char limit via MaxIdempotencyKeyLength) is byte-identical to the
|
||||
// historical fixed slice — Square's limit for /v2/cards, /v2/payments, and
|
||||
// /v2/refunds is 45 chars (64 only for /v2/terminals/checkouts).
|
||||
ikHash := sha256.Sum256([]byte(userID + "|" + cardToken))
|
||||
body := sqCreateCardRequest{
|
||||
IdempotencyKey: "card-" + fmt.Sprintf("%x", ikHash)[:38],
|
||||
IdempotencyKey: "card-" + fmt.Sprintf("%x", ikHash)[:MaxIdempotencyKeyLength-2-len("card-")],
|
||||
SourceID: cardToken,
|
||||
Card: sqCardPayload{
|
||||
// reference_id is Square's free-form client reference, used to link
|
||||
@@ -1090,12 +1123,14 @@ func createCustomerHTTP(ctx context.Context, name, email string) (*CustomerResul
|
||||
func createCustomerHTTPWithClient(ctx context.Context, name, email string, hc *httpClient) (*CustomerResult, error) {
|
||||
// Deterministic idempotency key derived from the email (not time-based)
|
||||
// so retries with the same email don't create duplicate customers. SHA-256
|
||||
// prevents recovering the email from the key. Truncated to ≤45 chars —
|
||||
// Square's idempotency-key limit is 45 chars for /v2/cards, /v2/payments,
|
||||
// and /v2/refunds (64 only for /v2/terminals/checkouts).
|
||||
// prevents recovering the email from the key. The 35-hex tail (1-char
|
||||
// margin: 9 prefix chars + 35 hex = 44, under the 45-char limit via
|
||||
// MaxIdempotencyKeyLength) is byte-identical to the historical fixed slice —
|
||||
// Square's limit for /v2/cards, /v2/payments, and /v2/refunds is 45 chars
|
||||
// (64 only for /v2/terminals/checkouts).
|
||||
ikHash := sha256.Sum256([]byte(email))
|
||||
body := sqCreateCustomerRequest{
|
||||
IdempotencyKey: "customer-" + fmt.Sprintf("%x", ikHash)[:35],
|
||||
IdempotencyKey: "customer-" + fmt.Sprintf("%x", ikHash)[:MaxIdempotencyKeyLength-1-len("customer-")],
|
||||
EmailAddress: email,
|
||||
GivenName: name,
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ var ErrReplayKeyNotRetained = errors.New("square: no payment under idempotency k
|
||||
// CreatePaymentReq maps to Square's CreatePayment endpoint (POST /v2/payments).
|
||||
// Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment
|
||||
type CreatePaymentReq struct {
|
||||
Amount int64 // in pence (GBP cents)
|
||||
Amount int64 // in pence
|
||||
Currency string // "GBP"
|
||||
SourceID string // card token ("cnon:xxx" nonce) or card-on-file ID
|
||||
IdempotencyKey string
|
||||
@@ -217,13 +217,16 @@ type SquareClient interface {
|
||||
GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error)
|
||||
RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error)
|
||||
// PaymentWasRefunded reports whether Square holds any refund for the
|
||||
// payment (status COMPLETED, APPROVED, or PENDING). It is the
|
||||
// reconciliation source for deciding whether a REFUND_AMOUNT_INVALID
|
||||
// rejection means "already refunded" (money has already moved) vs "amount
|
||||
// invalid" (nothing happened). On the interface (not just the package
|
||||
// function) so handlers can reconcile through the injected client — a
|
||||
// package-level call constructs a real HTTP client even in dev/mock
|
||||
// builds, making the dev path dead code and untestable.
|
||||
// payment (status COMPLETED, APPROVED, or PENDING). TEST-ONLY: it has ZERO
|
||||
// production callers (grep across the repo confirms the only users are
|
||||
// this package's tests); the production reconciliation that decides
|
||||
// whether a REFUND_AMOUNT_INVALID rejection means "already refunded"
|
||||
// (money has already moved) vs "amount invalid" (nothing happened) uses
|
||||
// the package-level paymentRefundedExactlyWithClient inside
|
||||
// refundPaymentHTTP (square_http_client.go), not this interface method. It
|
||||
// is kept on the interface solely so the dev mock's refund-reconciliation
|
||||
// parity tests can exercise the COMPLETED/APPROVED/PENDING status set. Do
|
||||
// not add production callers without re-examining the interface surface.
|
||||
PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error)
|
||||
CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error)
|
||||
// GetCardsOnFile returns the enabled cards on file for a user. TEST-ONLY:
|
||||
|
||||
+4
-1
@@ -296,7 +296,10 @@ func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s3Message = "S3 in-memory fallback active: RUSTFS unreachable — portfolio/photo uploads use placeholder URLs and data is lost on restart"
|
||||
}
|
||||
|
||||
if env := os.Getenv("SQUARE_ENVIRONMENT"); env == "" || env == "mock" {
|
||||
// Display-only label: uses the shared env set PLUS the empty default (dev
|
||||
// builds fall back to the mock client when SQUARE_ENVIRONMENT is unset, but
|
||||
// IsExplicitDevOrMockEnv is intentionally fail-closed for empty).
|
||||
if payments.IsExplicitDevOrMockEnv() || os.Getenv("SQUARE_ENVIRONMENT") == "" {
|
||||
services["square_payments"] = "mock"
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
let refundReason = $state('');
|
||||
let refundLoading = $state(false);
|
||||
let refundIdempotencyKey = $state('');
|
||||
// Pence of the selected payment already returned via completed refunds —
|
||||
// the refund amount is pre-filled with the residual (amount − this).
|
||||
let refundAlreadyRefundedPence = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (open && bookingId) {
|
||||
@@ -342,15 +345,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openRefundModal(paymentId: string, amountPence: number) {
|
||||
refundPaymentId = paymentId;
|
||||
refundAmount = (amountPence / 100).toFixed(2);
|
||||
async function openRefundModal(payment: Payment) {
|
||||
refundPaymentId = payment.id;
|
||||
refundAmount = (payment.amount / 100).toFixed(2);
|
||||
refundAlreadyRefundedPence = 0;
|
||||
refundReason = '';
|
||||
// Unique per refund attempt so two equal partial refunds of the same
|
||||
// payment don't collide on the backend's amount-derived key; reused on
|
||||
// retry (the backend dedups on it) so a timeout can't double-refund.
|
||||
refundIdempotencyKey = crypto.randomUUID();
|
||||
showRefundModal = true;
|
||||
|
||||
// Pre-fill the refund with the RESIDUAL (payment.amount − already
|
||||
// refunded) and surface the already-refunded total, so a partially
|
||||
// refunded payment doesn't look fully refundable. The admin booking
|
||||
// detail doesn't include refunds, so fetch the payment summary.
|
||||
try {
|
||||
const res = await apiFetch(`/api/bookings/${bookingId}/payment-summary`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const alreadyRefunded = (
|
||||
(data.refunds ?? []) as Array<{
|
||||
payment_id: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
}>
|
||||
)
|
||||
.filter((r) => r.payment_id === payment.id && r.status === 'completed')
|
||||
.reduce((sum, r) => sum + r.amount, 0);
|
||||
if (alreadyRefunded > 0) {
|
||||
refundAlreadyRefundedPence = alreadyRefunded;
|
||||
refundAmount = (Math.max(0, payment.amount - alreadyRefunded) / 100).toFixed(2);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — the modal stays open with the full amount pre-filled.
|
||||
}
|
||||
}
|
||||
|
||||
async function processRefund() {
|
||||
@@ -611,7 +641,7 @@
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onclick={() => openRefundModal(payment.id, payment.amount)}
|
||||
onclick={() => openRefundModal(payment)}
|
||||
>
|
||||
Refund
|
||||
</Button>
|
||||
@@ -837,6 +867,12 @@
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
{#if refundAlreadyRefundedPence > 0}
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Already refunded: £{(refundAlreadyRefundedPence / 100).toFixed(2)} — the amount above is the
|
||||
remaining balance.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
|
||||
import { isSquareConfigured, submitPaymentWithRetry } from '$lib/square/square';
|
||||
import { range } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
@@ -37,7 +38,6 @@
|
||||
email: string;
|
||||
balance: number;
|
||||
updated_at: string;
|
||||
/* TODO: add previousFirstName/previousLastName when backend sends them */
|
||||
}
|
||||
|
||||
interface GiftCardSummary {
|
||||
@@ -167,6 +167,12 @@
|
||||
let onlineSquareCardReady = $state(false);
|
||||
let onlineSquareCardInput = $state<SquareCardInput | null>(null);
|
||||
let onlineSquareProcessing = $state(false);
|
||||
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
|
||||
// on the next microtask), so the reactive `onlineSquareProcessing` may not
|
||||
// propagate to the button's `disabled`/`loading` bindings before a fast
|
||||
// second click fires. This non-reactive flag is checked synchronously at
|
||||
// the start of the handler.
|
||||
let onlineSquareProcessingSync = false;
|
||||
|
||||
// Idempotency: the client deliberately sends NO idempotency_key. The backend
|
||||
// (CreateTillSale) derives a DETERMINISTIC key server-side from the canonical
|
||||
@@ -309,8 +315,8 @@
|
||||
toast.success('Balance claimed successfully');
|
||||
await fetchExpiredBalances();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
toast.error(err.error || 'Failed to claim balance');
|
||||
const err = await res.text();
|
||||
toast.error(extractErrorMessage(err) || 'Failed to claim balance');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error claiming balance');
|
||||
@@ -647,7 +653,9 @@
|
||||
}
|
||||
|
||||
async function handleEmbeddedOnlineSquarePayment(actionType: 'create' | 'topup', gcId?: string) {
|
||||
if (onlineSquareProcessingSync) return;
|
||||
if (!onlineSquareCardInput) return;
|
||||
onlineSquareProcessingSync = true;
|
||||
onlineSquareProcessing = true;
|
||||
paymentError = '';
|
||||
try {
|
||||
@@ -708,7 +716,12 @@
|
||||
setModalStep(actionType, 'error');
|
||||
} finally {
|
||||
onlineSquareProcessing = false;
|
||||
onlineSquareAction = null;
|
||||
onlineSquareProcessingSync = false;
|
||||
// Deliberately keep onlineSquareAction set: the card form stays
|
||||
// mounted for the payment step, so after a failure the "Try Again"
|
||||
// button (error step → payment step) returns to the SAME card form
|
||||
// instead of dropping back to the payment-method grid. It is cleared
|
||||
// by resetGenerateModal/resetTopUpModal when the step is left.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -843,7 +856,9 @@
|
||||
return (a.amount_remaining - b.amount_remaining) * mul;
|
||||
case 'created':
|
||||
return (
|
||||
(parseWallClockDate(a.created_at).getTime() - parseWallClockDate(b.created_at).getTime()) * mul
|
||||
(parseWallClockDate(a.created_at).getTime() -
|
||||
parseWallClockDate(b.created_at).getTime()) *
|
||||
mul
|
||||
);
|
||||
case 'status': {
|
||||
const aVal = a.redeemed_by ? 2 : a.amount_remaining === 0 ? 1 : 0;
|
||||
@@ -874,7 +889,9 @@
|
||||
return (a.balance - b.balance) * mul;
|
||||
case 'updated':
|
||||
return (
|
||||
(parseWallClockDate(a.updated_at).getTime() - parseWallClockDate(b.updated_at).getTime()) * mul
|
||||
(parseWallClockDate(a.updated_at).getTime() -
|
||||
parseWallClockDate(b.updated_at).getTime()) *
|
||||
mul
|
||||
);
|
||||
default:
|
||||
return 0;
|
||||
@@ -1124,11 +1141,7 @@
|
||||
</Button>
|
||||
{/if}
|
||||
{#if gc.cancellable}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openCancelCardModal(gc)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onclick={() => openCancelCardModal(gc)}>
|
||||
Cancel & refund
|
||||
</Button>
|
||||
{:else if gc.cancellation_reason}
|
||||
@@ -1341,9 +1354,7 @@
|
||||
{:else}
|
||||
{#each sortedBalances as ub (ub.user_id)}
|
||||
<tr class="border-b hover:bg-gray-50">
|
||||
<td class="py-3 font-medium text-gray-900"
|
||||
>{ub.name}<!-- TODO: add formerly name when previous name data is available --></td
|
||||
>
|
||||
<td class="py-3 font-medium text-gray-900">{ub.name}</td>
|
||||
<td class="py-3 text-gray-600">{ub.email}</td>
|
||||
<td class="py-3 font-semibold text-primary">{formatCurrency(ub.balance)}</td>
|
||||
<td class="py-3 text-gray-600">{formatDate(ub.updated_at)}</td>
|
||||
@@ -1372,9 +1383,7 @@
|
||||
{#each sortedBalances as ub (ub.user_id)}
|
||||
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-900"
|
||||
>{ub.name}<!-- TODO: add formerly name when previous name data is available --></span
|
||||
>
|
||||
<span class="font-medium text-gray-900">{ub.name}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 border-t border-b py-2 text-xs text-gray-600">
|
||||
<div class="col-span-2">
|
||||
@@ -1972,33 +1981,35 @@
|
||||
</svg>
|
||||
Cash
|
||||
</button>
|
||||
{#if isSquareConfigured()}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
|
||||
onclick={() => (onlineSquareAction = 'create')}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
|
||||
onclick={() => (onlineSquareAction = 'create')}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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="2" y="5" width="20" height="14" rx="2" />
|
||||
<line x1="2" y1="10" x2="22" y2="10" />
|
||||
</svg>
|
||||
Online Card
|
||||
</button>
|
||||
{/if}
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<line x1="2" y1="10" x2="22" y2="10" />
|
||||
</svg>
|
||||
Online Card
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if onlineSquareAction === 'create'}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<SquareCardInput
|
||||
bind:this={onlineSquareCardInput}
|
||||
onReady={(r) => (onlineSquareCardReady = r)}
|
||||
/>
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={onlineSquareCardInput}
|
||||
onReady={(r) => (onlineSquareCardReady = r)}
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
<Button
|
||||
class="mt-3 w-full"
|
||||
variant="outline"
|
||||
@@ -2245,33 +2256,35 @@
|
||||
</svg>
|
||||
Cash
|
||||
</button>
|
||||
{#if isSquareConfigured()}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
|
||||
onclick={() => (onlineSquareAction = 'topup')}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
|
||||
onclick={() => (onlineSquareAction = 'topup')}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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="2" y="5" width="20" height="14" rx="2" />
|
||||
<line x1="2" y1="10" x2="22" y2="10" />
|
||||
</svg>
|
||||
Online Card
|
||||
</button>
|
||||
{/if}
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<line x1="2" y1="10" x2="22" y2="10" />
|
||||
</svg>
|
||||
Online Card
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if onlineSquareAction === 'topup'}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<SquareCardInput
|
||||
bind:this={onlineSquareCardInput}
|
||||
onReady={(r) => (onlineSquareCardReady = r)}
|
||||
/>
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={onlineSquareCardInput}
|
||||
onReady={(r) => (onlineSquareCardReady = r)}
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
<Button
|
||||
class="mt-3 w-full"
|
||||
variant="outline"
|
||||
@@ -2447,7 +2460,8 @@
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<p class="font-semibold">14-day statutory cancellation right</p>
|
||||
<p class="mt-1 text-xs text-amber-700">
|
||||
The unspent balance will be refunded to the original payment method. This action cannot be undone.
|
||||
The unspent balance will be refunded to the original payment method. This action cannot be
|
||||
undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -45,9 +45,7 @@
|
||||
// selection and save-for-later are blocked. The new-card (nonce) path has
|
||||
// its own SCA via Square tokenizeWithVerification, so only the saved-card
|
||||
// list and the save toggle are gated here.
|
||||
const twoFactorBlocksSavedCards = $derived(
|
||||
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
|
||||
);
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
|
||||
// Auto-select the default saved card when cards first load. Guarded by
|
||||
// !showNewCardForm so the "Use a new card" click (selectedCardId = '') is
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
@@ -7,7 +8,13 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { submitPaymentWithRetry } from '$lib/square/square';
|
||||
import {
|
||||
campaignDiscountPence,
|
||||
isSavedCardVerificationRequired,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
@@ -59,9 +66,7 @@
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks charging a
|
||||
// customer's saved card online (the admin's own 2FA status gates it). The
|
||||
// card-machine and new-card paths have their own SCA.
|
||||
const twoFactorBlocksSavedCards = $derived(
|
||||
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
|
||||
);
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
|
||||
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
|
||||
let useLoyalty = $state(false);
|
||||
@@ -77,18 +82,21 @@
|
||||
useLoyalty ? Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) : 0
|
||||
);
|
||||
|
||||
// Campaign discount preview — fetched on mount, mirroring the customer flow
|
||||
// (UserPaymentModal). The backend AUTO-APPLIES eligible campaigns at payment
|
||||
// /completion, so the admin modal must show and charge the DISCOUNTED amount:
|
||||
// charging the pre-campaign total would over-credit the ledger (the backend
|
||||
// records the full payment AND the discount rows). `netTotal` therefore
|
||||
// subtracts these pence, and every charge handler derives from it.
|
||||
let discountPreview = $state<{
|
||||
eligible: boolean;
|
||||
discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
|
||||
original_total: number;
|
||||
discounted_total: number;
|
||||
} | null>(null);
|
||||
|
||||
let customerBalance = $state(0);
|
||||
let giftCardPaymentAmount = $state('');
|
||||
let savedCardList = $state<
|
||||
Array<{
|
||||
id: string;
|
||||
brand: string;
|
||||
last_4: string;
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
cardholder_name?: string;
|
||||
}>
|
||||
>([]);
|
||||
async function fetchCustomerGiftCardBalance() {
|
||||
const targetUserId = booking.user_id ?? booking.user?.id;
|
||||
if (!targetUserId) return;
|
||||
@@ -107,19 +115,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSavedCardList() {
|
||||
const targetUserId = booking.user_id ?? booking.user?.id;
|
||||
if (!targetUserId) return;
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/users/${targetUserId}/payment-methods`);
|
||||
if (res.ok) {
|
||||
savedCardList = await res.json();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
type ServiceOverride = {
|
||||
price: string;
|
||||
originalPrice: number;
|
||||
@@ -131,7 +126,7 @@
|
||||
const uid = booking.user_id ?? booking.user?.id;
|
||||
if (uid) {
|
||||
fetchCustomerGiftCardBalance();
|
||||
fetchSavedCardList();
|
||||
fetchSavedCards();
|
||||
}
|
||||
const services = booking.services ?? [];
|
||||
const overrides: Record<string, ServiceOverride> = {};
|
||||
@@ -145,17 +140,13 @@
|
||||
serviceOverrides = overrides;
|
||||
});
|
||||
|
||||
// Single shared sanitizer for all decimal money inputs: strips non-numeric
|
||||
// characters and keeps only the first decimal point (so "1.2.3" → "1.23").
|
||||
// Defined once in square.ts and imported here so the payment surfaces can't
|
||||
// drift.
|
||||
|
||||
function handlePriceInput(serviceId: string, value: string) {
|
||||
const cleaned = value.replace(/[^0-9.]/g, '');
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
let sanitized: string;
|
||||
if (firstDot !== -1) {
|
||||
const integerPart = cleaned.substring(0, firstDot);
|
||||
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
||||
sanitized = integerPart + '.' + decimalPart;
|
||||
} else {
|
||||
sanitized = cleaned;
|
||||
}
|
||||
const sanitized = sanitizeDecimalInput(value);
|
||||
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
|
||||
serviceOverrides = {
|
||||
...serviceOverrides,
|
||||
@@ -181,16 +172,7 @@
|
||||
|
||||
function handleCustomTipInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const cleaned = input.value.replace(/[^0-9.]/g, '');
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
let sanitized: string;
|
||||
if (firstDot !== -1) {
|
||||
const integerPart = cleaned.substring(0, firstDot);
|
||||
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
||||
sanitized = integerPart + '.' + decimalPart;
|
||||
} else {
|
||||
sanitized = cleaned;
|
||||
}
|
||||
const sanitized = sanitizeDecimalInput(input.value);
|
||||
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
|
||||
customTipAmount = sanitized;
|
||||
}
|
||||
@@ -213,7 +195,27 @@
|
||||
const discountSum = $derived(
|
||||
(booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0)
|
||||
);
|
||||
const netTotal = $derived(Math.max(0, subtotal - discountSum));
|
||||
// Campaign discounts apply automatically at payment/completion server-side,
|
||||
// so the charge must be the subtotal minus already-applied discounts minus
|
||||
// the eligible campaign credit — otherwise the customer is overcharged.
|
||||
//
|
||||
// NOTE (round-A-4 UX residual, deliberately NOT "fixed"): this ignores
|
||||
// payments already made against the booking. The admin backend path
|
||||
// (CreateTerminalPayment) treats the amount it receives as the charge to
|
||||
// record verbatim — it does NOT compute "remaining due" and subtract prior
|
||||
// payments server-side — and the booking object handed to this modal (from
|
||||
// /api/admin/today/current-next, AppointmentInfo) carries no amount_paid /
|
||||
// amount_due / payments fields to derive them client-side. Subtracting an
|
||||
// unverifiable prior-paid total would risk under-collecting. When a deposit
|
||||
// was already paid, charging the full subtotal here is money-safe server-side
|
||||
// (buildSplitRecords/buildTerminalSplitRecords carve any excess beyond the
|
||||
// remaining booking value into a payment_type='tip' record, so the ledger
|
||||
// still closes exactly at the booking total) but the excess lands as an
|
||||
// UNINTENDED tip. Revisit when the today endpoint exposes the booking's paid
|
||||
// total: netTotal = max(0, subtotal − discountSum − campaignDiscountPence − amountPaidPence).
|
||||
const netTotal = $derived(
|
||||
Math.max(0, subtotal - discountSum - campaignDiscountPence(discountPreview))
|
||||
);
|
||||
|
||||
const tipPercentages = $derived.by(() => {
|
||||
if (netTotal <= 0) return [];
|
||||
@@ -408,6 +410,20 @@
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch the eligible campaign discount preview once on mount. Mirrors the
|
||||
// customer flow (UserPaymentModal) so the admin modal charges the same
|
||||
// discounted amount the backend will auto-apply.
|
||||
onMount(async () => {
|
||||
try {
|
||||
const resp = await apiFetch(`/api/bookings/${booking.id}/discount-preview`);
|
||||
if (resp.ok) {
|
||||
discountPreview = await resp.json();
|
||||
}
|
||||
} catch (_err) {
|
||||
console.error('Failed to fetch discount preview:', _err);
|
||||
}
|
||||
});
|
||||
|
||||
let cashAmount = $state<string>('');
|
||||
const cashAmountNum = $derived(cashAmount === '' ? 0 : parseFloat(cashAmount));
|
||||
const changeDue = $derived(cashAmountNum > totalDue ? cashAmountNum - totalDue : 0);
|
||||
@@ -415,16 +431,7 @@
|
||||
|
||||
function handleCashInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const cleaned = input.value.replace(/[^0-9.]/g, '');
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
let sanitized: string;
|
||||
if (firstDot !== -1) {
|
||||
const integerPart = cleaned.substring(0, firstDot);
|
||||
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
||||
sanitized = integerPart + '.' + decimalPart;
|
||||
} else {
|
||||
sanitized = cleaned;
|
||||
}
|
||||
const sanitized = sanitizeDecimalInput(input.value);
|
||||
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
|
||||
cashAmount = sanitized;
|
||||
}
|
||||
@@ -563,7 +570,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
let payAmountCents = Math.round(giftDue * 100);
|
||||
let payAmountPence = Math.round(giftDue * 100);
|
||||
if (useAccountBalance) {
|
||||
const parsedAmt = parseFloat(giftCardPaymentAmount);
|
||||
if (isNaN(parsedAmt) || parsedAmt <= 0) {
|
||||
@@ -574,7 +581,7 @@
|
||||
toast.error('Payment amount exceeds available balance');
|
||||
return;
|
||||
}
|
||||
payAmountCents = Math.round(parsedAmt * 100);
|
||||
payAmountPence = Math.round(parsedAmt * 100);
|
||||
}
|
||||
|
||||
isProcessingPaymentSync = true;
|
||||
@@ -590,7 +597,7 @@
|
||||
payment_method: string;
|
||||
gift_card_id?: string;
|
||||
} = {
|
||||
amount: payAmountCents,
|
||||
amount: payAmountPence,
|
||||
payment_type: 'full',
|
||||
payment_method: 'giftcard'
|
||||
};
|
||||
@@ -660,7 +667,7 @@
|
||||
savedCards = [];
|
||||
selectedSavedCardId = null;
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/users/${booking.user_id}/payment-methods`);
|
||||
const res = await apiFetch(`/api/admin/users/${targetUserId}/payment-methods`);
|
||||
if (res.ok) {
|
||||
savedCards = await res.json();
|
||||
}
|
||||
@@ -710,6 +717,7 @@
|
||||
status = 'saved-card-processing';
|
||||
error = null;
|
||||
|
||||
let responseStatus = 0;
|
||||
try {
|
||||
await applyLoyaltyRedemption();
|
||||
|
||||
@@ -728,6 +736,7 @@
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
responseStatus = response.status;
|
||||
const errData = await response.text();
|
||||
throw new Error(extractErrorMessage(errData) || 'Failed to process saved card payment');
|
||||
}
|
||||
@@ -752,8 +761,15 @@
|
||||
onComplete(paymentResult);
|
||||
} catch (_err) {
|
||||
status = 'error';
|
||||
error = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
||||
toast.error(error ?? 'Unknown error');
|
||||
// Saved-card (ccof) charges skip the client-side SCA step, so a
|
||||
// definitive 402 on the saved-card path means the issuer still
|
||||
// requires verification — retrying the same saved card can never
|
||||
// succeed. Surface the fix instead of the generic backend text.
|
||||
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
||||
if (isSavedCardVerificationRequired(responseStatus, true))
|
||||
msg = SAVED_CARD_VERIFICATION_MESSAGE;
|
||||
error = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
isProcessingPaymentSync = false;
|
||||
}
|
||||
@@ -923,6 +939,19 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if discountPreview?.eligible && discountPreview.discounts.length > 0}
|
||||
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
|
||||
{#each discountPreview.discounts as d (d.name)}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">{d.name}</span>
|
||||
<span class="font-medium text-green-700"
|
||||
>-{formatCurrency(Math.round(d.amount * 100))}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tipEnabled}
|
||||
<div class="rounded-md border border-green-200 bg-green-50 p-3">
|
||||
<div class="flex justify-between">
|
||||
@@ -941,7 +970,7 @@
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="grid grid-cols-2 gap-3 {savedCardList.length > 0
|
||||
class="grid grid-cols-2 gap-3 {savedCards.length > 0
|
||||
? 'sm:grid-cols-4'
|
||||
: 'sm:grid-cols-3'}"
|
||||
>
|
||||
@@ -993,7 +1022,7 @@
|
||||
</svg>
|
||||
Cash
|
||||
</button>
|
||||
{#if savedCardList.length > 0 && !twoFactorBlocksSavedCards}
|
||||
{#if savedCards.length > 0 && !twoFactorBlocksSavedCards}
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
@@ -1049,17 +1078,19 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if twoFactorBlocksSavedCards && savedCardList.length > 0}
|
||||
{#if twoFactorBlocksSavedCards && savedCards.length > 0}
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<p class="text-sm text-amber-800">
|
||||
Two-factor authentication is required to use online card payments.
|
||||
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
|
||||
<a href={resolve('/account')} class="font-medium underline"
|
||||
>Enable it in your account settings</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-3 sm:hidden">
|
||||
{#if savedCardList.length > 0 && !twoFactorBlocksSavedCards}
|
||||
{#if savedCards.length > 0 && !twoFactorBlocksSavedCards}
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
@@ -1311,7 +1342,11 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button onclick={handleGiftCardPayment} class="flex-1" disabled={!giftCardValid || nothingToCharge}>
|
||||
<Button
|
||||
onclick={handleGiftCardPayment}
|
||||
class="flex-1"
|
||||
disabled={!giftCardValid || nothingToCharge}
|
||||
>
|
||||
Apply Gift Card
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
canSaveCardsForRole,
|
||||
isNonceStale,
|
||||
isSavedCardVerificationRequired,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
@@ -103,9 +104,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 isCardValid = $derived(cardSelectionValid);
|
||||
|
||||
@@ -183,16 +182,7 @@
|
||||
|
||||
function handleCustomTipInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const cleaned = input.value.replace(/[^0-9.]/g, '');
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
let sanitized: string;
|
||||
if (firstDot !== -1) {
|
||||
const integerPart = cleaned.substring(0, firstDot);
|
||||
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
||||
sanitized = integerPart + '.' + decimalPart;
|
||||
} else {
|
||||
sanitized = cleaned;
|
||||
}
|
||||
const sanitized = sanitizeDecimalInput(input.value);
|
||||
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
|
||||
customTip = sanitized;
|
||||
}
|
||||
|
||||
@@ -7,15 +7,18 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import type { Booking } from '$lib/types/booking';
|
||||
import type { UserSavedCard } from '$lib/types';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import {
|
||||
campaignDiscountPence,
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
@@ -45,9 +48,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);
|
||||
|
||||
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
|
||||
|
||||
@@ -86,9 +87,8 @@
|
||||
payment_type: string;
|
||||
} | null>(null);
|
||||
|
||||
// Card selection state
|
||||
let paymentMethods = $state<UserSavedCard[]>([]);
|
||||
let paymentMethodsLoading = $state(false);
|
||||
// Card selection state — cards and loading live in the shared savedCards
|
||||
// store so all payment surfaces fetch /api/user/payment-methods identically.
|
||||
let selectedCardId = $state('');
|
||||
let cardSelectionValid = $state(false);
|
||||
let cardSelection = $state<CardSelection | null>(null);
|
||||
@@ -140,15 +140,17 @@
|
||||
.reduce((sum, p) => sum + p.amount, 0) || 0
|
||||
);
|
||||
|
||||
const amountRemaining = $derived(booking.total_amount - totalPaid);
|
||||
|
||||
// Mirror of the backend's GetBookingRemainingBalanceCents (see
|
||||
// backend/handlers/payments/service.go): total − completed non-tip payments
|
||||
// + completed refunds, clamped to the booking total and floored at 0. The
|
||||
// backend rejects an unconfirmed pre-start overpayment when req.Amount >
|
||||
// this value, and records the excess (req.Amount − remainingCents) as a tip
|
||||
// once confirmed — so the overflow-confirmation prompt shows exactly that.
|
||||
const remainingBalanceCents = $derived.by(() => {
|
||||
// Tip-excluding remaining balance in pounds, mirroring the backend's
|
||||
// GetBookingRemainingBalancePence (see backend/handlers/payments/service.go):
|
||||
// total − completed non-tip payments + completed refunds, clamped to the
|
||||
// booking total and floored at 0. The backend rejects an unconfirmed
|
||||
// pre-start overpayment when req.Amount > this value, and records the excess
|
||||
// (req.Amount − remainingPence) as a tip once confirmed — so the
|
||||
// overflow-confirmation prompt shows exactly that. A completed TIP must not
|
||||
// reduce what the customer can still pay for the booking itself (gratuity,
|
||||
// not booking credit), so this deliberately differs from `totalPaid` (which
|
||||
// includes tips and drives the scenario labels / "Amount Paid" row).
|
||||
const remainingBalance = $derived.by(() => {
|
||||
const total = booking.total_amount ?? 0;
|
||||
const paid = (booking.payments ?? [])
|
||||
.filter((p) => p.status === 'completed' && p.payment_type !== 'tip')
|
||||
@@ -156,23 +158,29 @@
|
||||
const refunded = (booking.refunds ?? [])
|
||||
.filter((r) => r.status === 'completed')
|
||||
.reduce((sum, r) => sum + r.amount, 0);
|
||||
return Math.round(Math.max(0, Math.min(total - paid + refunded, total)) * 100);
|
||||
return Math.max(0, Math.min(total - paid + refunded, total));
|
||||
});
|
||||
|
||||
const remainingBalancePence = $derived(Math.round(remainingBalance * 100));
|
||||
|
||||
// Pre-start overpayment confirmation. The backend rejects a 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. The frontend caps amounts at
|
||||
// amountRemaining in normal flows, so this fires on STALE booking data
|
||||
// the remaining balance in normal flows, so this fires on STALE booking data
|
||||
// (multi-tab, admin-changed totals, refunds that reopened capacity) where
|
||||
// the user would otherwise be stuck with an unresolvable 400. On the guard
|
||||
// firing, the rejected request (amount, type, cached card tokens) is parked
|
||||
// here and a Confirm/Cancel prompt is shown; Confirm resends the SAME
|
||||
// request with the flag, Cancel returns to the amount-editing form.
|
||||
let overflowConfirm = $state<{
|
||||
amountCents: number;
|
||||
amountPence: number;
|
||||
paymentType: string;
|
||||
overflowCents: number;
|
||||
overflowPence: number;
|
||||
// Actual amount the backend will charge. For deposits the backend
|
||||
// charges req.Amount minus the eligible campaign credit (the frontend
|
||||
// sends deposits RAW), so this can differ from amountPence.
|
||||
chargePence?: number;
|
||||
cardId?: string;
|
||||
newCardToken?: string;
|
||||
verificationToken?: string;
|
||||
@@ -216,7 +224,7 @@
|
||||
partialAmount !== '' &&
|
||||
!isNaN(partialAmountNum) &&
|
||||
partialAmountNum > 0 &&
|
||||
partialAmountNum <= amountRemaining &&
|
||||
partialAmountNum <= remainingBalance &&
|
||||
/^\d+(\.\d{0,2})?$/.test(partialAmount)
|
||||
);
|
||||
|
||||
@@ -228,7 +236,7 @@
|
||||
? 'Invalid amount format'
|
||||
: partialAmountNum <= 0
|
||||
? 'Amount must be greater than 0'
|
||||
: partialAmountNum > amountRemaining
|
||||
: partialAmountNum > remainingBalance
|
||||
? 'Amount exceeds balance'
|
||||
: 'Invalid amount'
|
||||
: null
|
||||
@@ -241,12 +249,6 @@
|
||||
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
|
||||
);
|
||||
|
||||
function campaignDiscountCents(): number {
|
||||
return discountPreview?.eligible
|
||||
? discountPreview.discounts.reduce((sum, d) => sum + Math.round(d.amount * 100), 0)
|
||||
: 0;
|
||||
}
|
||||
|
||||
function formatCurrency(pence: number): string {
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
@@ -319,37 +321,12 @@
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
function generateIdempotencyKey(): string {
|
||||
const array = new Uint8Array(16);
|
||||
if (typeof window !== 'undefined' && window.crypto) {
|
||||
window.crypto.getRandomValues(array);
|
||||
} else {
|
||||
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
array[6] = (array[6] & 0x0f) | 0x40;
|
||||
array[8] = (array[8] & 0x3f) | 0x80;
|
||||
return [...array]
|
||||
.map((b, i) => {
|
||||
const hex = b.toString(16).padStart(2, '0');
|
||||
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
|
||||
return hex;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function fetchPaymentMethods() {
|
||||
// Cached payment methods come from the shared savedCards store (single
|
||||
// fetch of /api/user/payment-methods), so the account, booking and tip
|
||||
// surfaces can't drift on the API shape or the loading semantics.
|
||||
async function loadSavedCards() {
|
||||
if (!authStore.isAuthenticated) return;
|
||||
paymentMethodsLoading = true;
|
||||
try {
|
||||
const response = await apiFetch('/api/user/payment-methods');
|
||||
if (response.ok) {
|
||||
paymentMethods = await response.json();
|
||||
}
|
||||
} catch (_err) {
|
||||
console.error('Failed to fetch payment methods:', _err);
|
||||
} finally {
|
||||
paymentMethodsLoading = false;
|
||||
}
|
||||
await savedCardsStore.fetch();
|
||||
}
|
||||
|
||||
async function fetchLoyaltyData() {
|
||||
@@ -365,22 +342,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAmountInput(value: string): string {
|
||||
// Remove all non-numeric chars except .
|
||||
const cleaned = value.replace(/[^0-9.]/g, '');
|
||||
// Keep only the first .
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
if (firstDot !== -1) {
|
||||
const integerPart = cleaned.substring(0, firstDot);
|
||||
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
||||
return integerPart + '.' + decimalPart;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function handlePartialAmountInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const sanitized = sanitizeAmountInput(input.value);
|
||||
const sanitized = sanitizeDecimalInput(input.value);
|
||||
// Only update if the sanitized value passes the regex (max 2 decimal places)
|
||||
if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
|
||||
partialAmount = sanitized;
|
||||
@@ -392,7 +356,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function makePayment(paymentType: string, amountCents: number) {
|
||||
async function makePayment(paymentType: string, amountPence: number) {
|
||||
status = 'processing';
|
||||
error = null;
|
||||
|
||||
@@ -433,11 +397,11 @@
|
||||
if (
|
||||
!newCardNonce ||
|
||||
newCardTokenizedForSaveCard !== saveCard ||
|
||||
isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountCents)
|
||||
isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountPence)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await cardSelection.tokenizeWithVerification(
|
||||
amountCents,
|
||||
amountPence,
|
||||
{
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
@@ -447,7 +411,7 @@
|
||||
);
|
||||
newCardNonce = tokenized.nonce;
|
||||
newCardVerificationToken = tokenized.verificationToken ?? '';
|
||||
newCardTokenAmount = amountCents;
|
||||
newCardTokenAmount = amountPence;
|
||||
newCardTokenizedAt = Date.now();
|
||||
newCardTokenizedForSaveCard = saveCard;
|
||||
} catch (_err) {
|
||||
@@ -477,17 +441,24 @@
|
||||
const cardKey = cardId ?? 'new-card';
|
||||
if (
|
||||
!payIdempotencyKey ||
|
||||
payKeyedAmount !== amountCents ||
|
||||
payKeyedAmount !== amountPence ||
|
||||
payKeyedType !== paymentType ||
|
||||
payKeyedCard !== cardKey
|
||||
) {
|
||||
payIdempotencyKey = generateIdempotencyKey();
|
||||
payKeyedAmount = amountCents;
|
||||
payIdempotencyKey = generateUUID();
|
||||
payKeyedAmount = amountPence;
|
||||
payKeyedType = paymentType;
|
||||
payKeyedCard = cardKey;
|
||||
}
|
||||
|
||||
await submitBookingPayment(paymentType, amountCents, cardId, newCardToken, verificationToken, false);
|
||||
await submitBookingPayment(
|
||||
paymentType,
|
||||
amountPence,
|
||||
cardId,
|
||||
newCardToken,
|
||||
verificationToken,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// Submits a booking-payment request and processes the outcome. Shared by
|
||||
@@ -499,7 +470,7 @@
|
||||
// the key is still the correct dedup identity for this amount+type+card).
|
||||
async function submitBookingPayment(
|
||||
paymentType: string,
|
||||
amountCents: number,
|
||||
amountPence: number,
|
||||
cardId: string | undefined,
|
||||
newCardToken: string | undefined,
|
||||
verificationToken: string | undefined,
|
||||
@@ -512,7 +483,7 @@
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: amountCents,
|
||||
amount: amountPence,
|
||||
payment_type: paymentType,
|
||||
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
@@ -532,10 +503,19 @@
|
||||
// nonce + SCA verification token + idempotency key are NOT
|
||||
// cleared here — the confirm resend is the same logical charge.
|
||||
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(errData)) {
|
||||
// The backend's overflow guard compares against the DISCOUNTED
|
||||
// remaining (remaining + eligible campaign credit), and for a
|
||||
// DEPOSIT it charges req.Amount − the campaign credit (the
|
||||
// frontend sends deposits raw). Both the displayed overflow
|
||||
// and the amount actually charged must therefore account for
|
||||
// the eligible campaign discount on the deposit path.
|
||||
const depositDiscountPence =
|
||||
paymentType === 'deposit' ? campaignDiscountPence(discountPreview) : 0;
|
||||
overflowConfirm = {
|
||||
amountCents,
|
||||
amountPence,
|
||||
paymentType,
|
||||
overflowCents: Math.max(0, amountCents - remainingBalanceCents),
|
||||
overflowPence: Math.max(0, amountPence - remainingBalancePence - depositDiscountPence),
|
||||
chargePence: Math.max(0, amountPence - depositDiscountPence),
|
||||
cardId,
|
||||
newCardToken,
|
||||
verificationToken
|
||||
@@ -567,7 +547,7 @@
|
||||
payment_type: data.payment_type
|
||||
};
|
||||
toast.success('Payment successful');
|
||||
fetchPaymentMethods();
|
||||
savedCardsStore.invalidate();
|
||||
onComplete();
|
||||
releaseLock();
|
||||
} catch (_err) {
|
||||
@@ -605,7 +585,7 @@
|
||||
error = null;
|
||||
await submitBookingPayment(
|
||||
pending.paymentType,
|
||||
pending.amountCents,
|
||||
pending.amountPence,
|
||||
pending.cardId,
|
||||
pending.newCardToken,
|
||||
pending.verificationToken,
|
||||
@@ -623,17 +603,20 @@
|
||||
}
|
||||
|
||||
function handlePayDeposit() {
|
||||
const depositCents = booking.deposit_amount
|
||||
const depositPence = booking.deposit_amount
|
||||
? Math.round(booking.deposit_amount * 100)
|
||||
: Math.round(booking.total_amount * 0.2 * 100);
|
||||
makePayment('deposit', depositCents);
|
||||
makePayment('deposit', depositPence);
|
||||
}
|
||||
|
||||
function handlePayFull() {
|
||||
const fullCents = Math.round(booking.amount_due * 100);
|
||||
const discountedCents = Math.max(0, fullCents - campaignDiscountCents() - loyaltyDiscount);
|
||||
const fullPence = Math.round(booking.amount_due * 100);
|
||||
const discountedPence = Math.max(
|
||||
0,
|
||||
fullPence - campaignDiscountPence(discountPreview) - loyaltyDiscount
|
||||
);
|
||||
const paymentType = booking.amount_paid > 0 ? 'balance' : 'full';
|
||||
makePayment(paymentType, discountedCents);
|
||||
makePayment(paymentType, discountedPence);
|
||||
}
|
||||
|
||||
function handlePayPartial() {
|
||||
@@ -649,10 +632,10 @@
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Fetch payment methods on mount if authenticated
|
||||
// Fetch payment methods + loyalty on mount if authenticated
|
||||
$effect(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
fetchPaymentMethods();
|
||||
loadSavedCards();
|
||||
fetchLoyaltyData();
|
||||
}
|
||||
});
|
||||
@@ -690,7 +673,21 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Root
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
// ESC while the overflow-confirm prompt is showing must dismiss the
|
||||
// prompt (back to the amount-editing form) instead of closing the whole
|
||||
// modal — the payment was rejected by the guard and the user needs to
|
||||
// confirm or adjust, not lose the flow entirely.
|
||||
if (overflowConfirm) {
|
||||
cancelOverflowConfirmation();
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-w-[calc(100%-2rem)] sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
|
||||
@@ -721,9 +718,18 @@
|
||||
<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
|
||||
{formatCurrency(overflowConfirm.overflowCents)} will be recorded as a tip. Confirm
|
||||
to continue?
|
||||
{formatCurrency(overflowConfirm.overflowPence)} will be recorded as a tip. Confirm to
|
||||
continue?
|
||||
</p>
|
||||
{#if overflowConfirm.paymentType === 'deposit' && overflowConfirm.chargePence !== undefined}
|
||||
<p class="mt-2 text-sm font-medium text-amber-800">
|
||||
An eligible campaign discount of
|
||||
{formatCurrency(
|
||||
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
|
||||
)}
|
||||
applies — you'll be charged {formatCurrency(overflowConfirm.chargePence)}.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
@@ -731,6 +737,7 @@
|
||||
class="flex-1"
|
||||
loading={status === 'processing'}
|
||||
disabled={status === 'processing'}
|
||||
autofocus
|
||||
onclick={confirmOverflowPayment}
|
||||
>
|
||||
Confirm
|
||||
@@ -919,7 +926,7 @@
|
||||
{formatCurrency(
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(amountRemaining * 100) - campaignDiscountCents() - loyaltyDiscount
|
||||
remainingBalancePence - campaignDiscountPence(discountPreview) - loyaltyDiscount
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
@@ -937,12 +944,12 @@
|
||||
"Card entry failed". Staying mounted keeps both alive for the
|
||||
full duration of makePayment. -->
|
||||
{#if authStore.isAuthenticated}
|
||||
{#if paymentMethodsLoading}
|
||||
{#if savedCardsStore.loading}
|
||||
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
|
||||
{:else}
|
||||
<CardSelection
|
||||
bind:this={cardSelection}
|
||||
cards={paymentMethods}
|
||||
cards={savedCardsStore.cards}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId
|
||||
bind:saveCard
|
||||
@@ -1008,7 +1015,7 @@
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(booking.amount_due * 100) -
|
||||
campaignDiscountCents() -
|
||||
campaignDiscountPence(discountPreview) -
|
||||
(useLoyalty ? loyaltyDiscount : 0)
|
||||
)
|
||||
)}
|
||||
@@ -1087,7 +1094,7 @@
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(booking.amount_due * 100) -
|
||||
campaignDiscountCents() -
|
||||
campaignDiscountPence(discountPreview) -
|
||||
(useLoyalty ? loyaltyDiscount : 0)
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -2,15 +2,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
NONCE_STALENESS_MS,
|
||||
OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE,
|
||||
PAYMENT_AMBIGUOUS_STATUS,
|
||||
PAYMENT_DEFINITIVE_STATUS,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
campaignDiscountPence,
|
||||
canSaveCardsForRole,
|
||||
isAmbiguousPaymentFailure,
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
sanitizeDecimalInput,
|
||||
submitPaymentWithRetry
|
||||
} from './square';
|
||||
import type * as SquareModule from './square';
|
||||
@@ -121,6 +120,56 @@ describe('isSquareMock / isSquareConfigured / getSquareConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeDecimalInput', () => {
|
||||
it.each([
|
||||
['', ''],
|
||||
['0', '0'],
|
||||
['12.34', '12.34'],
|
||||
['1.2.3', '1.23'],
|
||||
['£50', '50'],
|
||||
['1,234.56', '1234.56'],
|
||||
['abc', ''],
|
||||
['..', '.'],
|
||||
['1.', '1.']
|
||||
])('sanitizes %s → %s', (input, expected) => {
|
||||
expect(sanitizeDecimalInput(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('campaignDiscountPence', () => {
|
||||
const base = {
|
||||
eligible: true,
|
||||
discounts: [
|
||||
{ source: 'campaign', name: '10% Off', percent: 10, amount: 5 },
|
||||
{ source: 'campaign', name: 'Referral', percent: 5, amount: 2.5 }
|
||||
],
|
||||
original_total: 50,
|
||||
discounted_total: 42.5
|
||||
};
|
||||
|
||||
it('sums eligible discount amounts in pence', () => {
|
||||
expect(campaignDiscountPence(base)).toBe(750);
|
||||
});
|
||||
|
||||
it('rounds each discount amount to pence before summing', () => {
|
||||
expect(campaignDiscountPence({ ...base, discounts: [{ ...base.discounts[0], amount: 5.005 }] })).toBe(
|
||||
501
|
||||
);
|
||||
});
|
||||
|
||||
it('is 0 when no preview', () => {
|
||||
expect(campaignDiscountPence(null)).toBe(0);
|
||||
});
|
||||
|
||||
it('is 0 when the preview is not eligible', () => {
|
||||
expect(campaignDiscountPence({ ...base, eligible: false })).toBe(0);
|
||||
});
|
||||
|
||||
it('is 0 for an empty discount list', () => {
|
||||
expect(campaignDiscountPence({ ...base, discounts: [] })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canSaveCardsForRole', () => {
|
||||
it.each([
|
||||
['admin', true],
|
||||
@@ -137,12 +186,12 @@ describe('canSaveCardsForRole', () => {
|
||||
});
|
||||
|
||||
describe('payment failure classification', () => {
|
||||
it('PAYMENT_DEFINITIVE_STATUS is 402', () => {
|
||||
expect(PAYMENT_DEFINITIVE_STATUS).toBe(402);
|
||||
it('a definitive 402 on a saved-card charge is an issuer verification failure', () => {
|
||||
expect(isSavedCardVerificationRequired(402, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('PAYMENT_AMBIGUOUS_STATUS is 503', () => {
|
||||
expect(PAYMENT_AMBIGUOUS_STATUS).toBe(503);
|
||||
it('a 402 on a new-card charge is a plain decline, not a verification failure', () => {
|
||||
expect(isSavedCardVerificationRequired(402, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('isAmbiguousPaymentFailure matches only 503', () => {
|
||||
@@ -172,14 +221,16 @@ describe('isOverflowTipConfirmationRequired', () => {
|
||||
it('matches the backend overflow-guard error body by its code', () => {
|
||||
const body = JSON.stringify({
|
||||
error: 'The extra amount will be recorded as a tip. Confirm to continue.',
|
||||
code: OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE
|
||||
code: 'overflow_tip_confirmation_required'
|
||||
});
|
||||
expect(isOverflowTipConfirmationRequired(body)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a 400 body with a different code', () => {
|
||||
expect(
|
||||
isOverflowTipConfirmationRequired(JSON.stringify({ error: 'Bad amount', code: 'invalid_amount' }))
|
||||
isOverflowTipConfirmationRequired(
|
||||
JSON.stringify({ error: 'Bad amount', code: 'invalid_amount' })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,46 @@ export function canSaveCardsForRole(role: string | undefined): boolean {
|
||||
return role === 'verified_email' || role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a decimal-money text input: strips every non-numeric character
|
||||
* except the decimal point and keeps only the FIRST dot (so "£1.2.3" → "1.23").
|
||||
* The caller still validates the result against /^\d+(\.\d{0,2})?$/ when a
|
||||
* max-two-decimal rule applies — this only normalizes what the user typed.
|
||||
* Shared by every decimal money input (booking partials, admin service
|
||||
* overrides, tips, cash amounts) so the sanitizer can't drift between them.
|
||||
*/
|
||||
export function sanitizeDecimalInput(value: string): string {
|
||||
const cleaned = value.replace(/[^0-9.]/g, '');
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
if (firstDot !== -1) {
|
||||
const integerPart = cleaned.substring(0, firstDot);
|
||||
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
||||
return integerPart + '.' + decimalPart;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/** Shape of the `/api/bookings/{id}/discount-preview` response, as consumed by
|
||||
* the payment modals when computing the eligible campaign credit. */
|
||||
export interface DiscountPreview {
|
||||
eligible: boolean;
|
||||
discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
|
||||
original_total: number;
|
||||
discounted_total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total eligible campaign-discount credit in pence. The backend auto-applies
|
||||
* eligible campaigns at payment/completion, so the modals must charge the
|
||||
* DISCOUNTED amount — sharing the computation keeps the customer and admin
|
||||
* modals from drifting on how the preview is reduced to pence.
|
||||
*/
|
||||
export function campaignDiscountPence(discountPreview: DiscountPreview | null): number {
|
||||
return discountPreview?.eligible
|
||||
? discountPreview.discounts.reduce((sum, d) => sum + Math.round(d.amount * 100), 0)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** True when a cached card nonce can no longer be reused: it was tokenized for a
|
||||
* different amount than `amount`, or it is older than NONCE_STALENESS_MS. */
|
||||
export function isNonceStale(
|
||||
@@ -52,8 +92,8 @@ export function isNonceStale(
|
||||
* - 402 (definitive): card declined, expired, AVS/CVV failure — retrying
|
||||
* with the same inputs can never succeed.
|
||||
*/
|
||||
export const PAYMENT_AMBIGUOUS_STATUS = 503;
|
||||
export const PAYMENT_DEFINITIVE_STATUS = 402;
|
||||
const PAYMENT_AMBIGUOUS_STATUS = 503;
|
||||
const PAYMENT_DEFINITIVE_STATUS = 402;
|
||||
|
||||
/**
|
||||
* True when a definitive (402) charge failure on a SAVED CARD should be
|
||||
@@ -89,9 +129,9 @@ export const SAVED_CARD_VERIFICATION_MESSAGE =
|
||||
* request with `confirm_overflow_tip: true` on confirm. This fires mainly on
|
||||
* stale booking data (multi-tab, admin-changed totals, refunds that reopened
|
||||
* capacity), so the response body carries no amount — the caller computes the
|
||||
* overflow as `req.Amount - remainingCents` from its booking data.
|
||||
* overflow as `req.Amount - remainingPence` from its booking data.
|
||||
*/
|
||||
export const OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE = 'overflow_tip_confirmation_required';
|
||||
const OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE = 'overflow_tip_confirmation_required';
|
||||
|
||||
/**
|
||||
* True when an API error body is the backend's overflow-tip confirmation guard
|
||||
|
||||
@@ -60,6 +60,16 @@ class AuthStore {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
// PSD2 SCA stand-in: 2FA required but not yet enabled blocks saved-card
|
||||
// use (charging a saved card, selecting one as default) and saving new
|
||||
// cards for reuse. The new-card (nonce) path has its own SCA via Square
|
||||
// tokenizeWithVerification, so only the saved-card surfaces are gated.
|
||||
// Single source of truth so the predicate can't drift between the booking,
|
||||
// account, tip and admin payment surfaces.
|
||||
get twoFactorBlocksSavedCards() {
|
||||
return !!this.user?.twoFactorRequired && !this.user?.twoFactorEnabled;
|
||||
}
|
||||
|
||||
get currentToken() {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { canSaveCardsForRole, isNonceStale, isSquareConfigured, submitPaymentWithRetry } from '$lib/square/square';
|
||||
import {
|
||||
canSaveCardsForRole,
|
||||
isNonceStale,
|
||||
isSavedCardVerificationRequired,
|
||||
isSquareConfigured,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||||
@@ -387,120 +394,132 @@
|
||||
if (buySelectedCard) {
|
||||
// saved card — nothing to tokenize
|
||||
} else if (buyCardSelection) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
|
||||
// verification token on retry (tokenization is one-shot; the backend
|
||||
// idempotency key dedups).
|
||||
if (
|
||||
!buyNonce ||
|
||||
buyTokenizedForSaveCard !== buySaveCard ||
|
||||
isNonceStale(buyTokenizedAt, buyTokenAmount, buyAmount * 100)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await buyCardSelection.tokenizeWithVerification(
|
||||
buyAmount * 100,
|
||||
{
|
||||
givenName: userData?.firstName,
|
||||
familyName: userData?.lastName,
|
||||
email: userData?.email
|
||||
},
|
||||
buySaveCard
|
||||
);
|
||||
buyNonce = tokenized.nonce;
|
||||
buyVerificationToken = tokenized.verificationToken ?? '';
|
||||
buyTokenAmount = buyAmount * 100;
|
||||
buyTokenizedAt = Date.now();
|
||||
buyTokenizedForSaveCard = buySaveCard;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
buyingGiftCard = false;
|
||||
return;
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
|
||||
// verification token on retry (tokenization is one-shot; the backend
|
||||
// idempotency key dedups).
|
||||
if (
|
||||
!buyNonce ||
|
||||
buyTokenizedForSaveCard !== buySaveCard ||
|
||||
isNonceStale(buyTokenizedAt, buyTokenAmount, buyAmount * 100)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await buyCardSelection.tokenizeWithVerification(
|
||||
buyAmount * 100,
|
||||
{
|
||||
givenName: userData?.firstName,
|
||||
familyName: userData?.lastName,
|
||||
email: userData?.email
|
||||
},
|
||||
buySaveCard
|
||||
);
|
||||
buyNonce = tokenized.nonce;
|
||||
buyVerificationToken = tokenized.verificationToken ?? '';
|
||||
buyTokenAmount = buyAmount * 100;
|
||||
buyTokenizedAt = Date.now();
|
||||
buyTokenizedForSaveCard = buySaveCard;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
buyingGiftCard = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
newCardToken = buyNonce;
|
||||
verificationToken = buyVerificationToken || undefined;
|
||||
} else {
|
||||
toast.error('Please select a payment method');
|
||||
buyingGiftCard = false;
|
||||
return;
|
||||
}
|
||||
|
||||
buyingGiftCard = true;
|
||||
try {
|
||||
const cardId = buySelectedCard;
|
||||
|
||||
// Cache the idempotency key per amount+card so a lost-response retry
|
||||
// reuses the same key (backend dedups) instead of double-charging.
|
||||
// Regenerate when the amount or card changes.
|
||||
// Cache the idempotency key per amount+card so a lost-response
|
||||
// retry reuses it (backend dedups) instead of double-charging. The
|
||||
// new-card identity is a STABLE sentinel, NOT the cnon: nonce: the
|
||||
// nonce is one-shot and cleared on a failed charge, so keying on it
|
||||
// would regenerate the key on retry and a network-timeout retry
|
||||
// (where the charge actually landed) would double-charge.
|
||||
const cardKey = cardId || 'new-card';
|
||||
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
|
||||
buyIdempotencyKey = generateIdempotencyKey();
|
||||
buyKeyedAmount = buyAmount;
|
||||
buyKeyedCard = cardKey;
|
||||
}
|
||||
|
||||
const res = await submitPaymentWithRetry(() =>
|
||||
apiFetch('/api/user/giftcards/buy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: buyAmount * 100, // cents
|
||||
recipient_type: buyRecipientType,
|
||||
recipient_email: buyRecipientEmail,
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
idempotency_key: buyIdempotencyKey
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
toast.success('Gift card purchased successfully!');
|
||||
purchaseResultCode = data.code;
|
||||
// Track confirmed purchases toward the £500/day cap (the backend
|
||||
// is authoritative; this only feeds the client-side nudge).
|
||||
buyDailyTotal += buyAmount;
|
||||
buyIdempotencyKey = '';
|
||||
buyKeyedAmount = 0;
|
||||
buyKeyedCard = '';
|
||||
buyNonce = '';
|
||||
buyVerificationToken = '';
|
||||
buyTokenAmount = 0;
|
||||
buyTokenizedAt = 0;
|
||||
buyTokenizedForSaveCard = false;
|
||||
await fetchGiftCardBalance();
|
||||
newCardToken = buyNonce;
|
||||
verificationToken = buyVerificationToken || undefined;
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
toast.error(extractErrorMessage(errText) || 'Failed to purchase gift card');
|
||||
// A definitive charge failure (e.g. declined card) consumes the
|
||||
// nonce + SCA verification token — clear the cached pair so the
|
||||
// next retry re-tokenizes fresh. The idempotency key stays for
|
||||
// network-timeout dedup.
|
||||
toast.error('Please select a payment method');
|
||||
buyingGiftCard = false;
|
||||
return;
|
||||
}
|
||||
|
||||
buyingGiftCard = true;
|
||||
try {
|
||||
const cardId = buySelectedCard;
|
||||
|
||||
// Cache the idempotency key per amount+card so a lost-response retry
|
||||
// reuses the same key (backend dedups) instead of double-charging.
|
||||
// Regenerate when the amount or card changes.
|
||||
// Cache the idempotency key per amount+card so a lost-response
|
||||
// retry reuses it (backend dedups) instead of double-charging. The
|
||||
// new-card identity is a STABLE sentinel, NOT the cnon: nonce: the
|
||||
// nonce is one-shot and cleared on a failed charge, so keying on it
|
||||
// would regenerate the key on retry and a network-timeout retry
|
||||
// (where the charge actually landed) would double-charge.
|
||||
const cardKey = cardId || 'new-card';
|
||||
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
|
||||
buyIdempotencyKey = generateIdempotencyKey();
|
||||
buyKeyedAmount = buyAmount;
|
||||
buyKeyedCard = cardKey;
|
||||
}
|
||||
|
||||
const res = await submitPaymentWithRetry(() =>
|
||||
apiFetch('/api/user/giftcards/buy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: buyAmount * 100, // cents
|
||||
recipient_type: buyRecipientType,
|
||||
recipient_email: buyRecipientEmail,
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
idempotency_key: buyIdempotencyKey
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
toast.success('Gift card purchased successfully!');
|
||||
purchaseResultCode = data.code;
|
||||
// Track confirmed purchases toward the £500/day cap (the backend
|
||||
// is authoritative; this only feeds the client-side nudge).
|
||||
buyDailyTotal += buyAmount;
|
||||
buyIdempotencyKey = '';
|
||||
buyKeyedAmount = 0;
|
||||
buyKeyedCard = '';
|
||||
buyNonce = '';
|
||||
buyVerificationToken = '';
|
||||
buyTokenAmount = 0;
|
||||
buyTokenizedAt = 0;
|
||||
buyTokenizedForSaveCard = false;
|
||||
await fetchGiftCardBalance();
|
||||
} else {
|
||||
// Capture the status BEFORE consuming the body — the saved-card
|
||||
// SCA check needs it, and text() can only be read once.
|
||||
const status = res.status;
|
||||
const errText = await res.text();
|
||||
// A saved-card (ccof) charge skips the client-side SCA step, so a
|
||||
// definitive 402 on the saved-card path means the issuer still
|
||||
// requires verification — surface the fix instead of the generic
|
||||
// backend text.
|
||||
const verificationRequired = isSavedCardVerificationRequired(status, !!buySelectedCard);
|
||||
toast.error(
|
||||
verificationRequired
|
||||
? SAVED_CARD_VERIFICATION_MESSAGE
|
||||
: extractErrorMessage(errText) || 'Failed to purchase gift card'
|
||||
);
|
||||
// A definitive charge failure (e.g. declined card) consumes the
|
||||
// nonce + SCA verification token — clear the cached pair so the
|
||||
// next retry re-tokenizes fresh. The idempotency key stays for
|
||||
// network-timeout dedup.
|
||||
buyNonce = '';
|
||||
buyVerificationToken = '';
|
||||
buyTokenAmount = 0;
|
||||
buyTokenizedAt = 0;
|
||||
buyTokenizedForSaveCard = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('buyGiftCard error:', err);
|
||||
toast.error('Network error');
|
||||
// Same for thrown errors (network / malformed response): a retry must
|
||||
// re-tokenize fresh rather than resubmit a consumed nonce.
|
||||
buyNonce = '';
|
||||
buyVerificationToken = '';
|
||||
buyTokenAmount = 0;
|
||||
buyTokenizedAt = 0;
|
||||
buyTokenizedForSaveCard = false;
|
||||
} finally {
|
||||
buyingGiftCard = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('buyGiftCard error:', err);
|
||||
toast.error('Network error');
|
||||
// Same for thrown errors (network / malformed response): a retry must
|
||||
// re-tokenize fresh rather than resubmit a consumed nonce.
|
||||
buyNonce = '';
|
||||
buyVerificationToken = '';
|
||||
buyTokenAmount = 0;
|
||||
buyTokenizedAt = 0;
|
||||
} finally {
|
||||
buyingGiftCard = false;
|
||||
}
|
||||
} finally {
|
||||
isBuyingSync = false;
|
||||
}
|
||||
@@ -2615,9 +2634,9 @@
|
||||
class="flex flex-col gap-2 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<div class="font-mono text-sm font-bold text-gray-900"
|
||||
>{formatCardCode(gc.code)}</div
|
||||
>
|
||||
<div class="font-mono text-sm font-bold text-gray-900">
|
||||
{formatCardCode(gc.code)}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
|
||||
<span>Value: <strong>{formatCurrency(gc.amount)}</strong></span>
|
||||
<span>Purchased: {formatShortDate(gc.purchased_at)}</span>
|
||||
@@ -2834,139 +2853,137 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
{/if}
|
||||
</div>
|
||||
<Separator />
|
||||
{/if}
|
||||
|
||||
<!-- Two-Factor Authentication (visible to all roles; the notification
|
||||
<!-- Two-Factor Authentication (visible to all roles; the notification
|
||||
preferences above are the role-gated part of this area) -->
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold">Two-Factor Authentication</h3>
|
||||
<p class="mb-3 text-sm text-gray-600">
|
||||
Protect online card payments with a one-time verification code
|
||||
</p>
|
||||
|
||||
{#if authStore.currentUser?.twoFactorEnabled}
|
||||
<div class="mb-3 rounded-lg border p-3">
|
||||
<div class="text-sm font-medium">
|
||||
Enabled
|
||||
{#if authStore.currentUser?.twoFactorMethod}
|
||||
({authStore.currentUser.twoFactorMethod === 'email' ? 'Email' : 'SMS'})
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
A verification code is required for online card payments
|
||||
</div>
|
||||
</div>
|
||||
{:else if authStore.currentUser?.twoFactorRequired}
|
||||
<div
|
||||
class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"
|
||||
>
|
||||
You must enable 2FA to use online card payments.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Email</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
Receive your verification code by email
|
||||
</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
checked={selectedTwoFA === 'email'}
|
||||
onchange={() => {
|
||||
selectedTwoFA = selectedTwoFA === 'email' ? 'none' : 'email';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">SMS</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
Receive your verification code by text message
|
||||
</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
checked={selectedTwoFA === 'sms'}
|
||||
onchange={() => {
|
||||
selectedTwoFA = selectedTwoFA === 'sms' ? 'none' : 'sms';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if twoFASetupPending}
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="one-time-code"
|
||||
maxlength={6}
|
||||
placeholder="6-digit code"
|
||||
bind:value={twoFACode}
|
||||
/>
|
||||
<Button disabled={twoFAVerifying} onclick={verifyTwoFASetup}>
|
||||
{twoFAVerifying ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showDisableCodeEntry}
|
||||
<div class="mt-3">
|
||||
<p class="mb-2 text-sm text-gray-600">
|
||||
Enter the verification code to disable two-factor authentication.
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold">Two-Factor Authentication</h3>
|
||||
<p class="mb-3 text-sm text-gray-600">
|
||||
Protect online card payments with a one-time verification code
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="one-time-code"
|
||||
maxlength={6}
|
||||
placeholder="6-digit code"
|
||||
bind:value={twoFADisableCode}
|
||||
disabled={twoFADisableConfirming}
|
||||
/>
|
||||
<Button disabled={twoFADisableConfirming} onclick={confirmDisableWithCode}>
|
||||
{twoFADisableConfirming ? 'Disabling...' : 'Confirm Disable'}
|
||||
</Button>
|
||||
|
||||
{#if authStore.currentUser?.twoFactorEnabled}
|
||||
<div class="mb-3 rounded-lg border p-3">
|
||||
<div class="text-sm font-medium">
|
||||
Enabled
|
||||
{#if authStore.currentUser?.twoFactorMethod}
|
||||
({authStore.currentUser.twoFactorMethod === 'email' ? 'Email' : 'SMS'})
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
A verification code is required for online card payments
|
||||
</div>
|
||||
</div>
|
||||
{:else if authStore.currentUser?.twoFactorRequired}
|
||||
<div
|
||||
class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"
|
||||
>
|
||||
You must enable 2FA to use online card payments.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Email</div>
|
||||
<div class="text-xs text-gray-500">Receive your verification code by email</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
checked={selectedTwoFA === 'email'}
|
||||
onchange={() => {
|
||||
selectedTwoFA = selectedTwoFA === 'email' ? 'none' : 'email';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">SMS</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
Receive your verification code by text message
|
||||
</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
checked={selectedTwoFA === 'sms'}
|
||||
onchange={() => {
|
||||
selectedTwoFA = selectedTwoFA === 'sms' ? 'none' : 'sms';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if twoFASetupPending}
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="one-time-code"
|
||||
maxlength={6}
|
||||
placeholder="6-digit code"
|
||||
bind:value={twoFACode}
|
||||
/>
|
||||
<Button disabled={twoFAVerifying} onclick={verifyTwoFASetup}>
|
||||
{twoFAVerifying ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showDisableCodeEntry}
|
||||
<div class="mt-3">
|
||||
<p class="mb-2 text-sm text-gray-600">
|
||||
Enter the verification code to disable two-factor authentication.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="one-time-code"
|
||||
maxlength={6}
|
||||
placeholder="6-digit code"
|
||||
bind:value={twoFADisableCode}
|
||||
disabled={twoFADisableConfirming}
|
||||
/>
|
||||
<Button disabled={twoFADisableConfirming} onclick={confirmDisableWithCode}>
|
||||
{twoFADisableConfirming ? 'Disabling...' : 'Confirm Disable'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if twoFADirty && !twoFASetupPending}
|
||||
<Button
|
||||
class="mt-3"
|
||||
disabled={twoFASettingUp || twoFADisabling}
|
||||
onclick={applyTwoFA}
|
||||
>
|
||||
{twoFASettingUp ? 'Sending...' : 'Apply'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if twoFADirty && !twoFASetupPending}
|
||||
<Button
|
||||
class="mt-3"
|
||||
disabled={twoFASettingUp || twoFADisabling}
|
||||
onclick={applyTwoFA}
|
||||
>
|
||||
{twoFASettingUp ? 'Sending...' : 'Apply'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold">Policies</h3>
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold">Policies</h3>
|
||||
<p class="mb-3 text-sm text-gray-600">
|
||||
View our cancellation, deposit, and no-show policies
|
||||
</p>
|
||||
@@ -3341,8 +3358,8 @@
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Disable two-factor authentication?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Disabling two-factor authentication means online card payments will be blocked
|
||||
while 2FA is required. You can re-enable it at any time.
|
||||
Disabling two-factor authentication means online card payments will be blocked while 2FA
|
||||
is required. You can re-enable it at any time.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<p class="mt-1">Trading name: Crussell Salon</p>
|
||||
<p>Registered address: Edinburgh, Scotland</p>
|
||||
<!-- TODO pre-launch: replace {{SUPPORT_EMAIL}} with the real support address before go-live. -->
|
||||
<p>Contact email: {"{{SUPPORT_EMAIL}}"}</p>
|
||||
<p>Contact email: {'{{SUPPORT_EMAIL}}'}</p>
|
||||
<p>VAT: Not currently registered (threshold £90,000; will register when reached)</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -97,15 +97,14 @@
|
||||
<p class="mb-3">
|
||||
<strong>If your account has a balance:</strong> your balance becomes dormant and is
|
||||
transferred to our recovery registry. You will receive your
|
||||
<strong>Account ID</strong> by email (once email delivery is available) and can recover your
|
||||
balance at any time by providing it.
|
||||
All other personal data is anonymized.
|
||||
<strong>Account ID</strong> by email (once email delivery is available) and can recover your balance
|
||||
at any time by providing it. All other personal data is anonymized.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
<strong>Warning:</strong> account deletion is permanent. You will lose access to your account,
|
||||
your booking history, loyalty stamps and referral codes, and your account balance (unless you
|
||||
retain your Account ID). Treatment and safety notes are retained after deletion in a form
|
||||
that cannot be traced back to you, and financial records are retained for 7 years (HMRC).
|
||||
retain your Account ID). Treatment and safety notes are retained after deletion in a form that
|
||||
cannot be traced back to you, and financial records are retained for 7 years (HMRC).
|
||||
</p>
|
||||
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.3 Inactive Account Policy</h3>
|
||||
<p class="mb-2">
|
||||
@@ -129,20 +128,46 @@
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Bookings & Appointments</h2>
|
||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||
<li>Bookings are subject to availability.</li>
|
||||
<li>You will receive confirmation on-screen and in your account (via email/SMS once email delivery is available).</li>
|
||||
<li>
|
||||
You will receive confirmation on-screen and in your account (via email/SMS once email
|
||||
delivery is available).
|
||||
</li>
|
||||
<li>Some services require a deposit (typically 20–50% of the service cost).</li>
|
||||
</ul>
|
||||
<p class="mb-2 font-medium text-gray-800">Cancellations & rescheduling</p>
|
||||
<p class="mb-2">
|
||||
Eligibility for a refund before your service depends on the amount of notice provided prior
|
||||
to your scheduled appointment time. These thresholds represent a genuine pre-estimate of the
|
||||
operational costs and loss of business incurred by late cancellations. Refunds apply to <strong
|
||||
>booking payments only</strong
|
||||
>, up to 100% of the total booking value.
|
||||
</p>
|
||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||
<li><strong>Client cancellation:</strong> at least 24 hours before the appointment.</li>
|
||||
<li><strong>Late cancellation (<24 hours):</strong> deposit may be forfeited.</li>
|
||||
<li><strong>No-show:</strong> deposit forfeited; may affect future booking eligibility.</li>
|
||||
<li>
|
||||
<strong>Notice of more than 72 hours:</strong> you are entitled to a full 100% refund of all
|
||||
booking payments made.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Notice between 24 and 72 hours:</strong> any booking payments made up to 50% of the
|
||||
total booking value are treated as a Protected Deposit. This Protected Deposit is retained to
|
||||
cover the short-notice vacancy, while any balance paid above 50% will be fully refunded.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Notice of less than 24 hours:</strong> all booking payments and deposits are entirely
|
||||
non-refundable and will be retained. The cancellation will be logged as a missed appointment
|
||||
history strike.
|
||||
</li>
|
||||
<li>
|
||||
<strong>No-show:</strong> all booking payments and deposits are retained; may affect future
|
||||
booking eligibility.
|
||||
</li>
|
||||
<li><strong>Business cancellation:</strong> full refund or reschedule offered.</li>
|
||||
</ul>
|
||||
<p class="mb-2 font-medium text-gray-800">Deposits</p>
|
||||
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
Deposits are non-refundable if you cancel less than 24 hours before the appointment.
|
||||
Deposits are retained on cancellation in line with the tiered schedule above (up to 50% of
|
||||
the booking value between 24 and 72 hours' notice; all payments retained under 24 hours).
|
||||
</li>
|
||||
<li>Deposits are applied to your final bill.</li>
|
||||
<li>If we cancel, the deposit is fully refunded.</li>
|
||||
@@ -170,7 +195,33 @@
|
||||
<li>Card payments are processed securely via Square.</li>
|
||||
<li>We do not store full card details.</li>
|
||||
<li>
|
||||
Refunds are processed to the original payment method within 5–10 business days.
|
||||
<strong>Refunds are returned to the original payment method where possible:</strong>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
<strong>Card payments</strong> (debit/credit card processed online or in-person): refunded
|
||||
directly back to the original card via Square. Processing times vary by card issuer (typically
|
||||
3–10 working days).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Gift card payments</strong>: refunded back to the original gift card (or, if
|
||||
you paid from your account balance, back to that balance). The gift card’s
|
||||
remaining balance is incremented and is immediately available for use. Expired gift
|
||||
cards are non-refundable.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cash payments</strong>: credited to your account balance, available for
|
||||
immediate use against future bookings or services. Guests (walk-in bookings made
|
||||
without an account) do not hold a rolling balance, so their cash refunds are arranged
|
||||
in person at the salon — please bring your receipt and an admin will process the
|
||||
refund at the till.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
If a card refund cannot be processed (e.g. the card is expired or the Square payment
|
||||
reference is unavailable), we will notify you via your account and arrange collection of
|
||||
the refund in person at the salon — please allow at least a day’s notice so we
|
||||
can have cash on hand. You will never be left out of pocket.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Saving a card for next time</strong> stores a tokenised reference with our payment provider,
|
||||
@@ -219,11 +270,11 @@
|
||||
balance, no additional VAT is charged (it has already been paid).
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
<strong>Right to cancel:</strong> if you buy a gift card online, you can cancel the purchase
|
||||
within 14 days for a refund to the original payment method. If the card has been partly used
|
||||
on salon services, only the unspent balance is refunded and the card is then cancelled. A card
|
||||
that has been redeemed to an account balance or fully spent cannot be cancelled. See our Gift
|
||||
Card Terms for the full position.
|
||||
<strong>Right to cancel:</strong> if you buy a gift card online, you can cancel the purchase within
|
||||
14 days for a refund to the original payment method. If the card has been partly used on salon
|
||||
services, only the unspent balance is refunded and the card is then cancelled. A card that has
|
||||
been redeemed to an account balance or fully spent cannot be cancelled. See our Gift Card Terms
|
||||
for the full position.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -245,14 +296,13 @@
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mb-3">
|
||||
If you have a consumer dispute that you cannot resolve with us, you can get free,
|
||||
impartial advice from
|
||||
If you have a consumer dispute that you cannot resolve with us, you can get free, impartial
|
||||
advice from
|
||||
<a
|
||||
href="https://consumeradvice.scot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-medium text-blue-600 underline hover:text-blue-800"
|
||||
>consumeradvice.scot</a
|
||||
class="font-medium text-blue-600 underline hover:text-blue-800">consumeradvice.scot</a
|
||||
>.
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
|
||||
@@ -374,7 +374,11 @@ CREATE TABLE bookings (
|
||||
-- entries are special-category health data (Art 9(1), Art 4(15)).
|
||||
-- RETAINED at erasure (de-identified) — see RETENTION POLICY in
|
||||
-- anonymize_user.
|
||||
notes TEXT,
|
||||
-- Server-side length cap mirrors the frontend CharCounter default
|
||||
-- (frontend/src/lib/components/ui/CharCounter.svelte: maxChars = 1,000,000),
|
||||
-- giving the de-identified-retention claim a technical guardrail against
|
||||
-- runaway free-text.
|
||||
notes TEXT CHECK (notes IS NULL OR char_length(notes) <= 1000000),
|
||||
deposit_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
-- Computed fields (maintained by trigger on booking_services/booking_custom_services)
|
||||
total_duration_minutes INT NOT NULL DEFAULT 60,
|
||||
|
||||
@@ -420,6 +420,25 @@ When you click on a customer in the Users list, you see their full profile.
|
||||
- Whether they've agreed to data retention
|
||||
- When these consents were last updated
|
||||
|
||||
### Treatment & Safety Notes — IMPORTANT
|
||||
|
||||
The notes field on a booking (and in the customer's profile) is kept as a single
|
||||
health-and-safety record. On account deletion the rest of the customer's record is
|
||||
wiped, but these notes are **retained** so the salon can still make safe adjustments if
|
||||
the customer returns and to defend any future legal claim. That retention is only
|
||||
compliant because the notes are treated as **de-identified** after deletion — so:
|
||||
|
||||
- **Never** enter a customer's name, phone number, address, or email address in the notes.
|
||||
- Refer to the customer by the system's customer record, not by personal identifiers.
|
||||
- Keep notes to treatment facts: colour and preference, allergies and skin sensitivities,
|
||||
lateness, access needs.
|
||||
- If a customer asks you to note down their phone/address "for next time", do not write it
|
||||
in the notes — use the customer's phone field on their profile instead.
|
||||
|
||||
If a note is entered that contains a direct identifier, the de-identified-retention claim
|
||||
for that record no longer holds — the note must be edited to remove the identifier.
|
||||
(There is no automated check for this; it is a procedural requirement.)
|
||||
|
||||
---
|
||||
|
||||
## Booking Details
|
||||
|
||||
@@ -98,7 +98,7 @@ These don't add features but reduce maintenance cost and risk.
|
||||
| T4 | **Create or remove documented `update_data_consent()` function** | S (1h) | DB Schema | Listed in FUNCTION USAGE SUMMARY comment (~line 2401) but no `CREATE FUNCTION` exists. |
|
||||
| T5 | **Resolve 2 route-conflicted lint-ignored handlers** | S (1h) | Backend | `manage.go:27,314` — handlers exist only for tests but routes conflict. |
|
||||
| T6 | **Resolve portfolio lint-ignored handler** | S (1h) | Backend | `images.go:53` — handler referenced from tests only, never routed. |
|
||||
| T7 | **Fix README job count: 22 not 21** | S (5min) | Docs | ✅ **COMPLETED Aug 2026** — README now documents 25 maintenance jobs (the three payment sweeps: `sweep-pending-square-refunds`, `sweep-stale-pending-payments`, `sweep-stale-terminal-checkouts`, plus `sweep-square-webhook-events` and `scan-critical-payment-logs`). |
|
||||
| T7 | **Fix README job count: 22 not 21** | S (5min) | Docs | ✅ **COMPLETED Aug 2026** — README now documents 26 maintenance jobs (the three payment sweeps: `sweep-pending-square-refunds`, `sweep-stale-pending-payments`, `sweep-stale-terminal-checkouts`, plus `sweep-square-webhook-events` and `scan-critical-payment-logs`). |
|
||||
| T8 | **Audit 18 silent catch blocks** | M (1d) | Frontend | 1 `catch (e) {}`, 17 `catch (_err)` — errors swallowed silently. Many should show user-facing toasts. |
|
||||
| T9 | **33 `svelte/no-navigation-without-resolve` suppressions** | M (1d) | Frontend | Create a project-wide `goto` wrapper instead of suppressing per-file. |
|
||||
| T10 | **Replace `as any` in HolidayHours** | S (30min) | Frontend | `HolidayHours.svelte:234` — `(group.hours as any[])?.map(…)`. Hours array has known shape. |
|
||||
|
||||
@@ -220,7 +220,7 @@ npm run dev # Dev server with HMR
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go test -tags "test,dev" ./... # 2,269 tests passed (4 skipped), as of 13 Aug 2026
|
||||
go test -tags "test,dev" ./... # 2,333 tests passed (4 skipped), as of 14 Aug 2026
|
||||
go test -tags "test,dev" -v -run TestName ./... # Single test
|
||||
```
|
||||
|
||||
|
||||
@@ -832,7 +832,7 @@ validTransitions := map[string]map[string]bool{
|
||||
- A mistyped or unset `SQUARE_ENVIRONMENT` can never silently disarm the gate. `REQUIRE_2FA=false` disables enforcement even in a deployed environment, for local testing.
|
||||
- **Residual brute-force exposure (accepted):** a fresh-code delivery (setup, or a disable that mints because no pending code exists) resets the shared 5-attempt counter. An authenticated attacker who already holds the victim's password can therefore loop `disable` with wrong codes to obtain an unlimited series of fresh codes, each granting 5 guesses — the 2FA gate then reduces to a 6-digit guessing game bounded only by the per-IP rate limit (120 req/min on `/api/user`) and the 10-minute code TTL. This is the same reset-on-delivery tradeoff that makes codes deliverable to locked-out users; it is documented rather than fixed because a hard per-user lockout would strand a legitimate user who lost their code, with no email/SMS transport to recover (P6). Revisit when real delivery lands.
|
||||
|
||||
**State:** stored on `users` — `two_factor_enabled BOOLEAN DEFAULT FALSE`, `two_factor_method` (`'email'` / `'sms'`), `two_factor_pending_code_hash` (SHA-256), `two_factor_pending_code_expires` (10-minute TTL). Only the digest is stored in the DB; the plaintext code is delivered via the server log with a `[2FA]` prefix in **all** modes — enforced and unenforced alike — the operator reads it and relays it to the customer. This is the fake delivery channel until real email/SMS infrastructure replaces that log line (P6); there is no email/SMS transport yet. When enforcement is off (dev), the setup endpoint also returns the code in its response and verify accepts any code, so the flow is testable without grepping backend logs.
|
||||
**State:** stored on `users` — `two_factor_enabled BOOLEAN DEFAULT FALSE`, `two_factor_method` (`'email'` / `'sms'`), `two_factor_pending_code_hash`, `two_factor_pending_code_expires` (10-minute TTL). Only a digest of the code is stored in the DB — never the plaintext. The digest is **HMAC-SHA256 keyed by `TWO_FACTOR_PEPPER`** when that env var is set (`hashTwoFACode`, `handlers/user/twofa.go`); an unset pepper falls back to the legacy unsalted SHA-256 digest **only** in dev/test builds and for the legacy-row migration window — production builds can never persist an unsalted digest because code issuance **fails closed** without the pepper (see `handlers/user/twofa_prod.go`). **Code delivery is build-dependent and production fails closed:** dev/test builds always write the plaintext code to the server log with a `[2FA]` prefix (and, when enforcement is off, the setup endpoint also returns the code and verify accepts any code, so the flow is testable without grepping logs). Production builds **NEVER** log the code unless the operator explicitly opts in with `TWO_FACTOR_ALLOW_LOG_DELIVERY=true`; without it, code issuance is refused (503 / `errTwoFADeliveryUnavailable`) so no user can complete setup or disable 2FA, and every enforced saved-card payment 403s with no way forward. This is the fake delivery channel until real email/SMS infrastructure replaces that log line (P6); there is no email/SMS transport yet. Each fresh code is checked under a shared **5-attempt lockout** (`twoFAMaxAttempts = 5` consecutive failed verifies invalidate the pending code); a fresh-code delivery resets that counter (see the residual brute-force note above).
|
||||
|
||||
**Gate:** `requireTwoFactorForCardAccess` (`handlers/payments/twofa.go`) is called on the saved-card online charge paths — booking payments, tips, and saved-card till sales. New-card (nonce) charges are **not** gated; a verification token from Square's own SDK covers the SCA step on new-card entry. Disabling 2FA requires a verification code when enforcement is ON (a password-only attacker must not be able to lift the protection) — the disable flow reuses a still-valid pending code when one exists, otherwise it generates and delivers a fresh one via the same `[2FA]` log channel; the submitted code is checked under the shared 5-attempt lockout (the same per-user counter as verify). The "always generate a fresh code on disable" alternative was deliberately **not** adopted: with an out-of-band log-delivery channel, a code generated by a request could never be submitted within that same request. In dev (unenforced) environments no code is required to disable.
|
||||
|
||||
@@ -1323,7 +1323,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user
|
||||
|
||||
### Test Coverage
|
||||
|
||||
**2,269 tests compiled** across all packages (4 skipped, 0 failures) — as of 13 Aug 2026. Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000.
|
||||
**2,333 tests compiled** across all packages (4 skipped, 0 failures) — as of 14 Aug 2026. Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000.
|
||||
|
||||
| Package | Coverage Area |
|
||||
|---------|--------------|
|
||||
@@ -1348,7 +1348,10 @@ Items that must be closed before a production go-live. This is a living list; ad
|
||||
|
||||
- **Set `SUPPORT_EMAIL`.** Every consumer-facing legal doc ([[Terms & Conditions - Overall App]], [[Privacy Policy]], [[Gift Card Terms & Conditions]], and the `/terms`, `/privacy-policy`, `/cancellation-policy` routes) currently uses the `{{SUPPORT_EMAIL}}` placeholder for the support address. The real address must be substituted in **all** of those places before launch — a placeholder in a live policy is a consumer-law exposure.
|
||||
- **Legal review of the DRAFT-bannered legal docs.** The T&Cs, Privacy Policy, Gift Card Terms, and the policy routes are still drafts for go-live review; have the wording checked by a solicitor before launch.
|
||||
- **Wire real email/SMS or keep the `[2FA]` log relay.** 2FA codes are delivered via the server log until email/SMS lands (see the Two-Factor Authentication section in this manual); confirm the delivery channel before launch.
|
||||
- **Wire real email/SMS or keep the `[2FA]` log relay.** 2FA codes are delivered via the server log until email/SMS lands (see the Two-Factor Authentication section in this manual); confirm the delivery channel before launch. In a production build the relay is **explicitly opt-in**: set `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` to deliver codes via the `[2FA]` log line, otherwise code issuance fails closed (503) and no user can complete 2FA setup or disable — every enforced saved-card online payment will 403. This is the **only** production 2FA delivery channel until email/SMS (P6) is wired, so it must be a deliberate decision at launch (with restricted log access), not a silent default.
|
||||
- **Set `SNAPSHOT_ENC_KEY`.** `square_request_snapshot` rows contain buyer PII (email + `ccof:` card tokens). Without `SNAPSHOT_ENC_KEY` (base64-encoded 32-byte AES-256 key, `openssl rand -base64 32`), non-mock deployments store those rows **PLAINTEXT at rest** with only a one-time CRITICAL startup log (see `checkSnapshotEncKey`, `backend/main.go`). Money-safety first: the process does **not** fail at startup, so the misconfiguration is otherwise silent — set the key before go-live.
|
||||
- **Set `TRUST_PROXY_HEADERS=true`.** The backend is deployed behind nginx and/or Cloudflare, which overwrite `X-Real-IP`/`CF-Connecting-IP` with the real client IP. `TRUST_PROXY_HEADERS` defaults to false; without it every per-IP rate-limit key collapses onto the proxy's IP and any one client can exhaust the shared per-IP budget for everyone (and per-IP limiter protection is effectively bypassed). Keep it false only when the backend is origin-exposed. The var ships via `.env` (`env_file` in `compose.yml`) — `compose.yml` deliberately never sets it, the operator decides per deployment.
|
||||
- **Treatment/safety notes retention — operator assertion (documented residual risk).** The privacy-policy route promises notes are "retained in a form that cannot be traced back to you" (de-identified at account erasure). This is a **business decision, not a technical guarantee**: notes are free-text `TEXT` (no backend PII validation, UI-capped at 1,000,000 chars) and are kept on the anonymised booking row after `anonymize_user` wipes the surrounding record. The operator asserts notes never contain direct identifiers. The residual risk is that a note entered with a name/phone/address could still re-identify the customer after erasure — see the Admin Manual procedure ("never enter direct identifiers in notes") and the `anonymize_user` RETENTION POLICY comment in `init-scripts/init-script.sql`.
|
||||
- **Production storage (S3/R2)** and **SMTP** are unimplemented stubs (see README "Limitations") — required for prod.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Testing Architecture & DB Management
|
||||
|
||||
**Last Updated:** August 2026 (v6 — coverage 50.4%→65.0%, 2,269 tests compiled, 4 skipped)
|
||||
**Last Updated:** August 2026 (v6 — coverage 50.4%→65.0%, 2,333 tests compiled, 4 skipped)
|
||||
|
||||
---
|
||||
|
||||
@@ -502,7 +502,7 @@ This appears in `TestAccount_DeleteGuest` and `TestLoyalty_Get`. The `dav.Servic
|
||||
|--------|-------|
|
||||
| Quick check (`-count=1`) | **~2min** |
|
||||
| Packages | 25 tested, 0 failures |
|
||||
| Tests | 2,269 compiled under test,dev tags (as of 13 Aug 2026) |
|
||||
| Tests | 2,333 compiled under test,dev tags (as of 14 Aug 2026) |
|
||||
|
||||
New test additions in this batch:
|
||||
| Test | Coverage |
|
||||
@@ -521,7 +521,7 @@ New test additions in this batch:
|
||||
| `TestCancelReservation_DoesNotTouchAnonReservations` | Inverse-isolation test — user cancel ignores `RESERVATION:anon:%` (defensive — the WHERE clause only matches `RESERVATION:user:%`) |
|
||||
| `TestCancelReservation_DoesNotTouchAdminReservations` | Inverse-isolation test — user cancel ignores `RESERVATION:admin:%`. Pairs with the admin-side test that verifies admin cancel ignores `RESERVATION:user:%`. Proves the two endpoints are properly partitioned. |
|
||||
|
||||
**Total tests:** 2,269 compiled across all packages (4 skipped) — as of 13 Aug 2026. 0 failures. Growth driven by: coverage improvement pass (new test files for bookings, user, payments, giftcards, till, refunds, DAV, auth, middleware, validators, zxcvbn — 56 new files, coverage 50.4%→65.0%), VAT lifecycle and parallel-deadlock regression tests, savepoint/transaction-context pattern for time-sensitive tests, split-lunch detection tests, removal of 10 dead test functions flagged by staticcheck U1000, and the Square payments test-gap round (terminal CreateCheckout-failure, GetCheckoutStatus reference_id mismatch, deadline wire shape, loyalty lock contention 409, GDPR saved-card scrubbing, ValidateAmount/isTokenLike/lock helpers direct units, buildSplitRecords tip overflow).
|
||||
**Total tests:** 2,333 compiled across all packages (4 skipped) — as of 14 Aug 2026. 0 failures. Growth driven by: coverage improvement pass (new test files for bookings, user, payments, giftcards, till, refunds, DAV, auth, middleware, validators, zxcvbn — 56 new files, coverage 50.4%→65.0%), VAT lifecycle and parallel-deadlock regression tests, savepoint/transaction-context pattern for time-sensitive tests, split-lunch detection tests, removal of 10 dead test functions flagged by staticcheck U1000, and the Square payments test-gap round (terminal CreateCheckout-failure, GetCheckoutStatus reference_id mismatch, deadline wire shape, loyalty lock contention 409, GDPR saved-card scrubbing, ValidateAmount/isTokenLike/lock helpers direct units, buildSplitRecords tip overflow).
|
||||
|
||||
### What Drives Test Time
|
||||
|
||||
@@ -638,7 +638,7 @@ This shouldn't appear anymore — the auth package's TestMain was updated to use
|
||||
|
||||
### Q: What's the total test count?
|
||||
|
||||
2,269 tests compiled across all packages (4 skipped), as of 13 Aug 2026. 0 failures.
|
||||
2,333 tests compiled across all packages (4 skipped), as of 14 Aug 2026. 0 failures.
|
||||
|
||||
**Notable new tests:** Centralised job scheduler tests (3 — RegisterAll count, schedules, handler signatures), scheduled-cleanup handler tests (21 — NotifyUnpaidOneWeek/Month, TransitionDiscountCampaigns, CleanupExpiredVerificationCodes/RefreshTokens), GDPR export cache cleanup (4), stale login entry cleanup (4), rate limiter cleanup tests (6), rate limiter production behavior tests (6). Duplicate completion guard (idempotent second `"completed"` call), daily stamp cap (two completions same day → 1 stamp), invalid status transitions (no-show→completed rejected with 400), sequential edit (two edits in sequence), timezone independence (UTC in, UTC out — no shift), past-booking no-show guard (past confirmed booking cancelled → `client_cancelled`, not `no_show`). New closing_time tests (3), content-type middleware tests (2), clock package tests, expanded admin reserve overlap tests, expanded gift card buy flow tests with VAT, and full admin reservation cancel coverage (12 tests covering walkin + callin + isolation + no-op + idempotency + response format parity).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user