diff --git a/backend/handlers/notifications/notifications.go b/backend/handlers/notifications/notifications.go index d6e687f..3d751d3 100644 --- a/backend/handlers/notifications/notifications.go +++ b/backend/handlers/notifications/notifications.go @@ -3,6 +3,7 @@ package notifications import ( "context" "crussell/db" + "crussell/internal/adminnotify" "crussell/internal/validators" "database/sql" "encoding/json" @@ -27,8 +28,24 @@ type AdminNotification struct { UserID *string `json:"user_id,omitempty"` UserName *string `json:"user_name,omitempty"` BookingStartTime *time.Time `json:"booking_start_time,omitempty"` - AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"` - CreatedAt time.Time `json:"created_at"` + // Money-critical event detail (amount / square id / description): populated + // by the 'critical_payment_log' / 'refund_failed' insert sites when they + // adopt the admin_notifications event-detail columns (see the column + // contract on adminnotify.go) so the operator can see WHAT happened without + // opening the CRITICAL logs. + Amount *float64 `json:"amount,omitempty"` + SquareID *string `json:"square_id,omitempty"` + Description *string `json:"description,omitempty"` + AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// AdminNotificationSuppression is one flood-cap suppression counter for a +// capped reason whose unacknowledged queue is still at the cap. +type AdminNotificationSuppression struct { + Reason string `json:"reason"` + SuppressedCount int `json:"suppressed_count"` + LastSuppressedAt time.Time `json:"last_suppressed_at"` } type AdminNotificationListResponse struct { @@ -37,6 +54,13 @@ type AdminNotificationListResponse struct { PerPage int `json:"per_page"` Total int `json:"total"` NextCursor *string `json:"next_cursor,omitempty"` + // Suppressed is how many money-critical alerts were dropped by the flood cap + // (adminnotify.MaxUnacknowledgedCriticalLogs) while each reason's + // unacknowledged queue stayed at the cap — the "suppressed this cycle" + // count. It resets once the operator works the queue down. SuppressedDetails + // carries the per-reason breakdown. + Suppressed int `json:"suppressed"` + SuppressedDetails []AdminNotificationSuppression `json:"suppressed_details,omitempty"` } // parseCursor splits a "createdAt|id" cursor string into its components. @@ -47,6 +71,13 @@ type AdminNotificationListResponse struct { // cursor, per_page — pagination (cursor-based) // include_acknowledged — if "true", returns all notifications sorted newest-first. // Default (false/omitted): only unacknowledged, sorted by priority then oldest-first. +// Money-critical reasons ('critical_payment_log', 'refund_failed', +// 'refresh_token_reuse', 'gift_card_purchased_for_friend') sort ABOVE routine +// notifications so the operator's only pager surfaces money/security events first. +// reason — filter to a single reason. +// The response additionally carries the flood-cap "suppressed this cycle" count +// (suppressed / suppressed_details) for reasons whose unacknowledged queue is +// still at adminnotify.MaxUnacknowledgedCriticalLogs. func GetNotifications(w http.ResponseWriter, r *http.Request) { // Parse query params perPage := 20 @@ -63,6 +94,7 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) { SELECT an.id, an.reason, an.booking_id, an.user_id, u.n_first_name || ' ' || u.n_last_name AS user_name, b.start_time AS booking_start_time, + an.amount, an.square_id, an.description, an.acknowledged_at, an.created_at FROM admin_notifications an LEFT JOIN users u ON an.user_id = u.id @@ -108,16 +140,24 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) { baseQuery += " ORDER BY an.created_at DESC, an.id DESC" } else { baseQuery += ` ORDER BY CASE an.reason - WHEN 'pending_booking' THEN 1 - WHEN 'cancelled_booking' THEN 2 - WHEN 'late_cancellation' THEN 3 - WHEN 'deposit_paid' THEN 4 - WHEN 'affiliate_claim' THEN 5 - WHEN 'edit_requested' THEN 6 - WHEN 'new_booking' THEN 7 - WHEN '1_month_no_pay' THEN 8 - WHEN '1_week_no_pay' THEN 9 - ELSE 10 + WHEN 'critical_payment_log' THEN 1 + WHEN 'refund_failed' THEN 2 + WHEN 'refresh_token_reuse' THEN 3 + WHEN 'gift_card_purchased_for_friend' THEN 4 + WHEN 'pending_booking' THEN 5 + WHEN 'cancelled_booking' THEN 6 + WHEN 'late_cancellation' THEN 7 + WHEN 'deposit_paid' THEN 8 + WHEN 'affiliate_claim' THEN 9 + WHEN 'edit_requested' THEN 10 + WHEN 'new_booking' THEN 11 + WHEN '1_month_no_pay' THEN 12 + WHEN '1_week_no_pay' THEN 13 + WHEN 'rescheduled_booking' THEN 14 + WHEN 'edit_request' THEN 15 + WHEN 'deposit_not_paid_by_deadline' THEN 16 + WHEN 'default_hours_changed' THEN 17 + ELSE 18 END, an.created_at ASC, an.id ASC` } baseQuery += fmt.Sprintf(" LIMIT $%d", param) @@ -149,6 +189,9 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) { var userID sql.NullString var userName sql.NullString var bookingStartTime sql.NullTime + var amount sql.NullFloat64 + var squareID sql.NullString + var description sql.NullString var acknowledgedAt sql.NullTime err := rows.Scan( @@ -158,6 +201,9 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) { &userID, &userName, &bookingStartTime, + &amount, + &squareID, + &description, &acknowledgedAt, &n.CreatedAt, ) @@ -179,6 +225,15 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) { if bookingStartTime.Valid { n.BookingStartTime = &bookingStartTime.Time } + if amount.Valid { + n.Amount = &amount.Float64 + } + if squareID.Valid { + n.SquareID = &squareID.String + } + if description.Valid { + n.Description = &description.String + } if acknowledgedAt.Valid { n.AcknowledgedAt = &acknowledgedAt.Time } @@ -206,6 +261,20 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) { NextCursor: nextCursor, } + if suppressions, err := adminnotify.ActiveSuppressions(r.Context(), db.Conn); err != nil { + log.Printf("Failed to fetch flood-cap suppressions: %v", err) + } else { + resp.SuppressedDetails = make([]AdminNotificationSuppression, 0, len(suppressions)) + for _, s := range suppressions { + resp.Suppressed += s.SuppressedCount + resp.SuppressedDetails = append(resp.SuppressedDetails, AdminNotificationSuppression{ + Reason: s.Reason, + SuppressedCount: s.SuppressedCount, + LastSuppressedAt: s.LastSuppressedAt, + }) + } + } + if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/backend/handlers/notifications/notifications_extended_test.go b/backend/handlers/notifications/notifications_extended_test.go index 0e17aba..b93ab18 100644 --- a/backend/handlers/notifications/notifications_extended_test.go +++ b/backend/handlers/notifications/notifications_extended_test.go @@ -14,6 +14,7 @@ import ( "time" "crussell/db" + "crussell/internal/adminnotify" "crussell/mw" "crussell/testutils" @@ -691,3 +692,196 @@ func TestAcknowledgeNotification_BeginError(t *testing.T) { t.Errorf("expected 500, got %d. body: %s", w.Code, w.Body.String()) } } + +// ============================================================================= +// Money-critical priority ordering (FIX 2) +// ============================================================================= + +// TestNotifications_Priority_MoneyCriticalFirst pins FIX 2: money-critical +// reasons (webhook/sweep 'critical_payment_log', 'refund_failed', +// 'refresh_token_reuse', gift-card events) must sort ABOVE routine +// notifications in the unacknowledged feed so the operator's only pager +// surfaces money/security events first. The money rows are inserted LAST to +// prove the priority CASE — not insertion order — drives the sort. +func TestNotifications_Priority_MoneyCriticalFirst(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) + + moneyReasons := []string{ + "critical_payment_log", + "refund_failed", + "refresh_token_reuse", + "gift_card_purchased_for_friend", + } + routineReasons := []string{ + "pending_booking", + "cancelled_booking", + "late_cancellation", + "deposit_paid", + "affiliate_claim", + "edit_requested", + "new_booking", + "1_month_no_pay", + "1_week_no_pay", + "rescheduled_booking", + "edit_request", + "deposit_not_paid_by_deadline", + "default_hours_changed", + } + moneySet := make(map[string]bool, len(moneyReasons)) + for _, r := range moneyReasons { + moneySet[r] = true + } + + for _, reason := range routineReasons { + createNotification(t, ctx, tx, reason, userID, false) + } + for _, reason := range moneyReasons { + createNotification(t, ctx, tx, reason, userID, false) + } + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != len(moneyReasons)+len(routineReasons) { + t.Fatalf("expected %d notifications, got %d", len(moneyReasons)+len(routineReasons), len(resp.Notifications)) + } + + // Every money-critical reason must appear strictly before the first routine + // reason; each money reason must be present. + lastMoney := -1 + firstRoutine := len(resp.Notifications) + for i, n := range resp.Notifications { + if moneySet[n.Reason] { + lastMoney = i + } else if firstRoutine == len(resp.Notifications) { + firstRoutine = i + } + } + if lastMoney == -1 { + t.Fatal("expected at least one money-critical reason in the response") + } + if lastMoney > firstRoutine { + t.Errorf("money-critical reasons must sort above routine notifications: last money position %d, first routine position %d", lastMoney, firstRoutine) + } +} + +// ============================================================================= +// Flood-cap suppression count in the response (FIX 3a) +// ============================================================================= + +// TestNotifications_Response_SuppressedCount pins FIX 3a: when the flood cap +// has suppressed alerts, GET /api/admin/notifications exposes the per-reason +// "suppressed this cycle" count, and acknowledging the queue back below the cap +// resets it to zero. +func TestNotifications_Response_SuppressedCount(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID := createTestUser(t, ctx, tx) + handler := http.HandlerFunc(GetNotifications) + + // No suppressions yet → the response reports zero. + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if resp.Suppressed != 0 { + t.Errorf("expected suppressed 0 before any cap hit, got %d", resp.Suppressed) + } + if len(resp.SuppressedDetails) != 0 { + t.Errorf("expected no suppressed_details before any cap hit, got %d", len(resp.SuppressedDetails)) + } + + // Fill the critical_payment_log queue to the cap, then hit the cap twice the + // way an insert site would (pre-check records each suppression). + for i := 0; i < adminnotify.MaxUnacknowledgedCriticalLogs; i++ { + if _, err := tx.Exec(ctx, ` + INSERT INTO admin_notifications (reason, user_id, created_at) + VALUES ('critical_payment_log', $1, NOW()) + `, userID); err != nil { + t.Fatalf("failed to fill the queue to the cap: %v", err) + } + } + if !adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") { + t.Fatal("expected the queue to be at the cap") + } + if !adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") { + t.Fatal("expected the queue to stay at the cap") + } + + w = makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if resp.Suppressed != 2 { + t.Errorf("expected suppressed 2, got %d", resp.Suppressed) + } + if len(resp.SuppressedDetails) != 1 { + t.Fatalf("expected 1 suppressed_details entry, got %d", len(resp.SuppressedDetails)) + } + if resp.SuppressedDetails[0].Reason != "critical_payment_log" { + t.Errorf("expected suppressed_details reason critical_payment_log, got %s", resp.SuppressedDetails[0].Reason) + } + if resp.SuppressedDetails[0].SuppressedCount != 2 { + t.Errorf("expected suppressed_details count 2, got %d", resp.SuppressedDetails[0].SuppressedCount) + } + + // Acknowledging the queue below the cap resets the visible count. + if _, err := tx.Exec(ctx, "UPDATE admin_notifications SET acknowledged_at = NOW() WHERE reason = 'critical_payment_log'"); err != nil { + t.Fatalf("failed to acknowledge the queue: %v", err) + } + w = makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if resp.Suppressed != 0 { + t.Errorf("expected suppressed 0 after the queue was worked down, got %d", resp.Suppressed) + } +} + +// TestNotifications_Response_CarriesEventDetail pins FIX 3b: the GET endpoint +// surfaces the money-critical event detail columns (amount, square_id, +// description) when the insert site has populated them. +func TestNotifications_Response_CarriesEventDetail(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + var notificationID string + err := tx.QueryRow(ctx, ` + INSERT INTO admin_notifications (reason, amount, square_id, description) + VALUES ('critical_payment_log', 42.50, 'sq_dispute_123', 'Dispute received for charge 42.50') + RETURNING id + `).Scan(¬ificationID) + if err != nil { + t.Fatalf("failed to create notification with event detail: %v", err) + } + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(resp.Notifications)) + } + n := resp.Notifications[0] + if n.Amount == nil || *n.Amount != 42.50 { + t.Errorf("expected amount 42.50, got %v", n.Amount) + } + if n.SquareID == nil || *n.SquareID != "sq_dispute_123" { + t.Errorf("expected square_id sq_dispute_123, got %v", n.SquareID) + } + if n.Description == nil || *n.Description != "Dispute received for charge 42.50" { + t.Errorf("expected description populated, got %v", n.Description) + } +} diff --git a/backend/internal/adminnotify/adminnotify.go b/backend/internal/adminnotify/adminnotify.go index bd0751f..b798365 100644 --- a/backend/internal/adminnotify/adminnotify.go +++ b/backend/internal/adminnotify/adminnotify.go @@ -2,39 +2,47 @@ // notification centre (admin_notifications). That table is the single // operator's ONLY pager for money events, so every site that inserts an // operator-facing alert must bound its unacknowledged queue — a hostile flood -// (attacker-registered accounts triggering refresh_token_reuse / reissue-fail / -// webhook alerts) must not be able to bury the notification centre under rows -// the operator can never work through. +// (attacker-registered accounts triggering refresh_token_reuse / webhook +// alerts) must not be able to bury the notification centre under rows the +// operator can never work through. // // Coordination contract (Round 2 Loop B finding 1): the cap is applied // atomically at every insert site in this codebase: // -// - handlers/payments/twofa.go — the reissue-fail alert (per-issue capped, -// see finding 2; NOT globally capped). // - auth/jwt.go VerifyRefreshToken — the 'refresh_token_reuse' alert. -// - handlers/webhooks/square.go — the three critical_payment_log inserts +// - handlers/webhooks/square.go — the 'critical_payment_log' inserts // (dispute, booking, unknown-event, orphan-replay). // - handlers/user/account.go InsertSquareErasureCriticalNotification. +// - handlers/payments/sweep.go insertCriticalPaymentNotification. +// - handlers/scheduling/time-blockers.go InsertSquareCleanupCriticalNotification +// and the deposit-deadline cleanup insert. +// - internal/jobs/cleanup.go ScanCriticalPaymentLogs. +// - handlers/scheduling/scheduled-cleanup.go — the '1_week_no_pay', +// '1_month_no_pay' and 'default_hours_changed' inserts. +// - handlers/bookings/bookings.go — the 'new_booking' / 'pending_booking' / +// 'cancelled_booking' inserts. +// - handlers/bookings/manage.go — the 'cancelled_booking' / 'edit_requested' +// inserts. +// - handlers/payments/refunds.go — the 'refund_failed' insert. // -// Sites owned by OTHER agents (coordination notes): -// -// - handlers/payments/sweep.go insertCriticalPaymentNotification — NOW capped -// (same atomic fold + pre-check as every other site). -// - handlers/scheduling/time-blockers.go insertSquareCleanupCriticalNotification -// and internal/jobs/cleanup.go ScanCriticalPaymentLogs — both NOW capped -// (same atomic fold + pre-check as every other site). -// - main.go has NO admin_notifications insert sites (it only mounts the -// notification read/ack routes), so nothing to cap there. +// main.go has NO admin_notifications insert sites (it only mounts the +// notification read/ack routes), so nothing to cap there. // // Every site folds the cap INTO its INSERT (a conditional // `INSERT ... SELECT ... WHERE (SELECT COUNT(*) ...) < $cap`) so the // count-then-insert is ATOMIC — closing the TOCTOU where two concurrent // inserts both read a below-cap count and overshoot together (finding 2). +// Every site ALSO pre-checks CriticalLogsCapExceeded to log the suppression; +// that pre-check is the single choke point that records the suppression in +// admin_notification_suppressions, so the operator-facing "suppressed this +// cycle" count on GET /api/admin/notifications stays accurate without any +// insert site having to change. package adminnotify import ( "context" "log" + "time" "crussell/db" ) @@ -58,6 +66,11 @@ const MaxUnacknowledgedCriticalLogs = 100 // a money alert is never dropped because the count query failed (the insert's // own atomic cap condition below still guards the row in that case — the // pre-check only decides whether to log a suppression). +// +// At the cap the suppression is ALSO recorded in admin_notification_suppressions +// (recordSuppression) so the admin notifications page can surface how many +// alerts were dropped. The record write is best-effort too — a failure only +// loses the counter, never the boolean decision. func CriticalLogsCapExceeded(ctx context.Context, q db.Querier, reason string) bool { var n int err := q.QueryRow(ctx, ` @@ -68,5 +81,66 @@ func CriticalLogsCapExceeded(ctx context.Context, q db.Querier, reason string) b log.Printf("adminnotify: failed to count unacknowledged %s admin notifications: %v", reason, err) return false } - return n >= MaxUnacknowledgedCriticalLogs + if n >= MaxUnacknowledgedCriticalLogs { + recordSuppression(ctx, q, reason) + return true + } + return false +} + +// recordSuppression upserts the per-reason flood-cap suppression counter +// (admin_notification_suppressions). Every insert site funnels its pre-check +// through CriticalLogsCapExceeded, so a suppression is recorded exactly once +// per dropped alert, and a running suppressed_count accumulates while the +// reason's unacknowledged queue stays at the cap. Best-effort: a failure is +// logged, never propagated — the alert is already being dropped at the cap and +// the operator-facing counter is informational, not load-bearing. +func recordSuppression(ctx context.Context, q db.Querier, reason string) { + if _, err := q.Exec(ctx, ` + INSERT INTO admin_notification_suppressions (reason, suppressed_count, first_suppressed_at, last_suppressed_at) + VALUES ($1::admin_notification_reason, 1, NOW(), NOW()) + ON CONFLICT (reason) DO UPDATE SET + suppressed_count = admin_notification_suppressions.suppressed_count + 1, + last_suppressed_at = NOW() + `, reason); err != nil { + log.Printf("adminnotify: failed to record flood-cap suppression for %s: %v", reason, err) + } +} + +// Suppression is the per-reason flood-cap suppression counter surfaced to the +// operator: how many alerts were dropped for that reason while its +// unacknowledged queue sat at MaxUnacknowledgedCriticalLogs. +type Suppression struct { + Reason string + SuppressedCount int + LastSuppressedAt time.Time +} + +// ActiveSuppressions returns the per-reason flood-cap suppressions whose +// underlying unacknowledged queue is STILL at the cap — i.e. the operator has +// not yet worked it down ("this cycle"). The admin notifications handler sums +// these into the response's suppressed count; the count naturally resets once +// the operator acknowledges the queue back below the cap. Best-effort: the +// caller must treat a non-nil error as "no suppression data available". +func ActiveSuppressions(ctx context.Context, q db.Querier) ([]Suppression, error) { + rows, err := q.Query(ctx, ` + SELECT s.reason, s.suppressed_count, s.last_suppressed_at + FROM admin_notification_suppressions s + WHERE (SELECT COUNT(*) FROM admin_notifications an + WHERE an.reason = s.reason AND an.acknowledged_at IS NULL) >= $1 + ORDER BY s.last_suppressed_at DESC + `, MaxUnacknowledgedCriticalLogs) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Suppression + for rows.Next() { + var s Suppression + if err := rows.Scan(&s.Reason, &s.SuppressedCount, &s.LastSuppressedAt); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() } diff --git a/backend/internal/adminnotify/adminnotify_test.go b/backend/internal/adminnotify/adminnotify_test.go index 7327c10..728d648 100644 --- a/backend/internal/adminnotify/adminnotify_test.go +++ b/backend/internal/adminnotify/adminnotify_test.go @@ -35,6 +35,7 @@ func TestCriticalLogsCapExceeded(t *testing.T) { ctx := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'") + _, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notification_suppressions WHERE reason = 'critical_payment_log'") }) if CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") { @@ -70,3 +71,72 @@ func TestCriticalLogsCapExceeded(t *testing.T) { t.Fatal("expected acknowledging the queue to re-arm inserts") } } + +// TestCriticalLogsCapExceeded_RecordsSuppression pins FIX 3a: hitting the +// flood cap records the suppression in admin_notification_suppressions (the +// operator-facing "suppressed this cycle" counter) with a running count, and +// ActiveSuppressions reports it only while the underlying queue is still at the +// cap — acknowledging the queue down resets the visible count. +func TestCriticalLogsCapExceeded_RecordsSuppression(t *testing.T) { + ctx := context.Background() + t.Cleanup(func() { + _, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'") + _, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notification_suppressions WHERE reason = 'critical_payment_log'") + }) + + // Below the cap no suppression is recorded. + for i := 0; i < MaxUnacknowledgedCriticalLogs-1; i++ { + if _, err := db.Conn.Exec(ctx, ` + INSERT INTO admin_notifications (reason, created_at) + VALUES ('critical_payment_log', NOW()) + `); err != nil { + t.Fatalf("failed to insert row %d: %v", i, err) + } + } + if supps, err := ActiveSuppressions(ctx, db.Conn); err != nil { + t.Fatalf("ActiveSuppressions failed below the cap: %v", err) + } else if len(supps) != 0 { + t.Fatalf("expected no suppression below the cap, got %d", len(supps)) + } + + // Fill to the cap, then hit it twice: each suppressed alert is recorded. + if _, err := db.Conn.Exec(ctx, ` + INSERT INTO admin_notifications (reason, created_at) + VALUES ('critical_payment_log', NOW()) + `); err != nil { + t.Fatalf("failed to insert the cap row: %v", err) + } + if !CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") { + t.Fatal("expected the queue to be at the cap") + } + if !CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") { + t.Fatal("expected the queue to stay at the cap") + } + + supps, err := ActiveSuppressions(ctx, db.Conn) + if err != nil { + t.Fatalf("ActiveSuppressions failed: %v", err) + } + if len(supps) != 1 { + t.Fatalf("expected exactly 1 suppression record, got %d", len(supps)) + } + if supps[0].Reason != "critical_payment_log" { + t.Errorf("expected reason critical_payment_log, got %s", supps[0].Reason) + } + if supps[0].SuppressedCount != 2 { + t.Errorf("expected suppressed_count 2, got %d", supps[0].SuppressedCount) + } + + // Acknowledging the queue below the cap makes the suppression inactive, so + // the "this cycle" count resets. + if _, err := db.Conn.Exec(ctx, "UPDATE admin_notifications SET acknowledged_at = NOW() WHERE reason = 'critical_payment_log'"); err != nil { + t.Fatalf("failed to acknowledge the queue: %v", err) + } + supps, err = ActiveSuppressions(ctx, db.Conn) + if err != nil { + t.Fatalf("ActiveSuppressions failed after acknowledge: %v", err) + } + if len(supps) != 0 { + t.Errorf("expected the suppression to reset once the queue is below the cap, got %d", len(supps)) + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f453d3c..afe2ebb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,7 +29,6 @@ "@tailwindcss/vite": "^4.2.0", "@types/geojson": "^7946.0.16", "@types/node": "^26.1.1", - "@vitest/coverage-v8": "^4.1.10", "bits-ui": "^2.16.1", "clsx": "^2.1.1", "eslint": "^9.39.5", @@ -56,66 +55,6 @@ "vitest": "^4.1.10" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@discourse/jxl": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@discourse/jxl/-/jxl-1.3.0.tgz", @@ -1234,6 +1173,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", @@ -1644,37 +1649,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", - "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.10", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.10", - "vitest": "4.1.10" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -1904,18 +1878,6 @@ "node": ">=12" } }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", - "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2705,13 +2667,6 @@ "integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==", "license": "MIT" }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2796,45 +2751,6 @@ "dev": true, "license": "ISC" }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2845,13 +2761,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", @@ -3270,34 +3179,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/magicast": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", - "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "source-map-js": "^1.2.1" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/maplibre-gl": { "version": "5.24.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index cbf5edd..ec1bdf4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,6 @@ "@tailwindcss/vite": "^4.2.0", "@types/geojson": "^7946.0.16", "@types/node": "^26.1.1", - "@vitest/coverage-v8": "^4.1.10", "bits-ui": "^2.16.1", "clsx": "^2.1.1", "eslint": "^9.39.5", diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index a532464..47a512d 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -214,7 +214,11 @@ const resp = await fetch(`/api/check-email?${params}`); if (resp.ok) { const data = await resp.json(); - emailSuggestion = data.suggestion ?? null; + // The check-email endpoint deliberately returns only + // `{ available: bool }` — `available === false` means the email + // belongs to a registered (non-guest) account, which drives the + // existing 'login' nudge. + emailSuggestion = data.available === false ? 'login' : null; } } catch { // Network error - silently ignore, don't block booking @@ -528,19 +532,18 @@ 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 } : {}) - }) + 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) { @@ -2372,11 +2375,6 @@ log in instead to access your bookings and rewards.

- {:else if emailSuggestion === 'check'} -

- This email might belong to an existing account. Please double-check or - log in. -

{/if} {/if} diff --git a/frontend/src/lib/components/payments/PaymentModal.svelte b/frontend/src/lib/components/payments/PaymentModal.svelte index c52ca00..d4d062e 100644 --- a/frontend/src/lib/components/payments/PaymentModal.svelte +++ b/frontend/src/lib/components/payments/PaymentModal.svelte @@ -10,8 +10,10 @@ import { apiFetch } from '$lib/utils/api'; import { formatCurrency } from '$lib/utils/format'; import { + buildCashTillPaymentBody, CARD_VERIFICATION_RETRY_MESSAGE, campaignDiscountPence, + cashChargeBasePence, isOverflowTipConfirmationRequired, isVerificationRequiredSignal, PAYMENT_METHOD_SAVED_CARD, @@ -558,7 +560,20 @@ let cashAmount = $state(''); const cashAmountNum = $derived(cashAmount === '' ? 0 : parseFloat(cashAmount)); - const changeDue = $derived(cashAmountNum > totalDue ? cashAmountNum - totalDue : 0); + // Cash charge base in pence. The backend's CreateTerminalPayment derives the + // tip from `amount − remaining`, and `remaining` (GetBookingRemainingBalancePence) + // does NOT subtract the pending campaign discount — so the campaign preview + // must be restored into the charge base or the tip is absorbed into booking + // credit. Shared with the change/tip display so every cash figure agrees. + const cashDuePence = $derived( + cashChargeBasePence( + Math.round(totalDue * 100), + campaignDiscountPence(discountPreview), + loyaltyDiscount + ) + ); + const cashDue = $derived(cashDuePence / 100); + const changeDue = $derived(cashAmountNum > cashDue ? cashAmountNum - cashDue : 0); let extraAsTip = $state(false); function handleCashInput(e: Event) { @@ -571,7 +586,6 @@ async function handleCashPayment() { if (isProcessingPaymentSync) return; - const cashDue = totalDue - loyaltyDiscount / 100; if (cashDue <= 0) { toast.error('Nothing to charge — the booking is fully covered by discounts'); @@ -591,15 +605,11 @@ try { await applyLoyaltyRedemption(); - const body: Record = { - amount: Math.round(cashDue * 100), - payment_type: 'full', - payment_method: 'cash' - }; - if (tipAmount > 0) { - body.tip_enabled = true; - body.tip_amount = Math.round(tipAmount * 100); - } + // Cash till-sale body: the tip is FOLDED into the amount (the backend's + // CreateTerminalPaymentRequest derives the tip from `amount - remaining` + // when tip_enabled; it has no tip_amount field, so sending one would + // silently drop the tip). + const body = buildCashTillPaymentBody(Math.round(cashDue * 100), Math.round(tipAmount * 100)); const response = await apiFetch(`/api/admin/bookings/${booking.id}/payment`, { method: 'POST', @@ -890,22 +900,21 @@ status = 'saved-card-processing'; } - const response = await submitPaymentWithRetry( - () => - apiFetch(`/api/admin/bookings/${booking.id}/payment`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - amount: chargeAmount, - payment_type: 'full', - payment_method: 'saved_card', - saved_card_id: selectedSavedCardId, - // C1: the SCA tokenize-result token is the charge SOURCE - // (new_card_token) alongside the saved-card ref — never - // the legacy verification_token. - ...(verificationToken ? { new_card_token: verificationToken } : {}), - idempotency_key: savedCardIdempotencyKey - }) + const response = await submitPaymentWithRetry(() => + apiFetch(`/api/admin/bookings/${booking.id}/payment`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + amount: chargeAmount, + payment_type: 'full', + payment_method: 'saved_card', + saved_card_id: selectedSavedCardId, + // C1: the SCA tokenize-result token is the charge SOURCE + // (new_card_token) alongside the saved-card ref — never + // the legacy verification_token. + ...(verificationToken ? { new_card_token: verificationToken } : {}), + idempotency_key: savedCardIdempotencyKey + }) }) ); @@ -996,7 +1005,7 @@ $effect(() => { if (selectedMethod === 'cash') { - cashAmount = totalDue.toFixed(2); + cashAmount = cashDue.toFixed(2); extraAsTip = false; } if (selectedMethod === 'giftcard') { @@ -1387,7 +1396,7 @@ {#each tipPercentages as tip (tip.pct)} diff --git a/frontend/src/lib/square/square.test.ts b/frontend/src/lib/square/square.test.ts index 4ec619e..fe63395 100644 --- a/frontend/src/lib/square/square.test.ts +++ b/frontend/src/lib/square/square.test.ts @@ -8,8 +8,10 @@ import { SCA_REFUSAL_MESSAGE_TILL, VERIFICATION_REQUIRED_MESSAGE, adminRequestNewTwoFactorCode, + buildCashTillPaymentBody, campaignDiscountPence, canSaveCardsForRole, + cashChargeBasePence, depositChargePence, isAmbiguousPaymentFailure, isNonceStale, @@ -20,11 +22,13 @@ import { newCardTokenizeResult, parseTokenizeVerificationResult, requestNewTwoFactorCode, + resendEmailVerification, sanitizeDecimalInput, scaFallbackConsentFields, shouldShowSCARefusal, submitPaymentWithRetry, - type SavedCardVerificationOutcome + type SavedCardVerificationOutcome, + verifyEmailCode } from './square'; import type * as SquareModule from './square'; @@ -199,6 +203,74 @@ describe('canSaveCardsForRole', () => { }); }); +describe('buildCashTillPaymentBody', () => { + it('sends the due amount alone with no tip flag when no tip applies', () => { + // `tip_enabled` is intentionally absent (undefined) — the old inline + // bodies only set it when a tip actually existed. + expect(buildCashTillPaymentBody(4000, 0)).toEqual({ + amount: 4000, + payment_type: 'full', + payment_method: 'cash' + }); + expect(buildCashTillPaymentBody(4000, 0).tip_enabled).toBeUndefined(); + }); + + it('folds the tip into the amount and flags tip_enabled (backend has no tip_amount field)', () => { + // CreateTerminalPaymentRequest derives the tip from `amount - remaining` + // when tip_enabled — the tip must ride inside `amount`, never as a dead + // `tip_amount` key. + expect(buildCashTillPaymentBody(4000, 500)).toEqual({ + amount: 4500, + payment_type: 'full', + payment_method: 'cash', + tip_enabled: true + }); + }); + + it('always carries the full/cash payment contract the backend keys on', () => { + expect(buildCashTillPaymentBody(4000, 0)).toMatchObject({ + payment_type: 'full', + payment_method: 'cash' + }); + }); + + it('rounds via the caller; the helper sums the pence inputs verbatim', () => { + expect(buildCashTillPaymentBody(4000, 50).amount).toBe(4050); + }); +}); + +describe('cashChargeBasePence', () => { + // Concrete arithmetic pinned to the FIX-1 scenario: booking £100, pending + // 10% campaign preview (£10), £10 loyalty redemption, £100 cash tender with + // the keep-change-as-tip checkbox on. netTotal = £90 (campaign subtracted), + // so the current charge base of £85 (totalDue − loyalty) understates the + // backend's remaining basis and absorbs the tip. + it('restores the pending campaign credit into the charge base (tip carve basis)', () => { + // totalDuePence = £90 net (campaign subtracted, pre-loyalty) + expect(cashChargeBasePence(9000, 1000, 1000)).toBe(9000); + }); + + it('keeps the plain net total when no campaign is eligible', () => { + // totalDue = £90 (no campaign), loyalty £10 → base = £80 = the net obligation + expect(cashChargeBasePence(9000, 0, 1000)).toBe(8000); + }); + + it('never goes below zero (fully covered by discounts + loyalty)', () => { + expect(cashChargeBasePence(1000, 0, 5000)).toBe(0); + }); + + it('the folded tip body uses the base, so UI tip == backend-recorded tip', () => { + // Booking £100, campaign £10, loyalty £10: base = 9000 (100 − 20 + 10). + // Tender £100 → tip £10 → amount £100. The backend carves against the + // full £100 remaining, so it records £0 tip — matching the UI claim + // that only the amount above the charge base is a tip. + const basePence = cashChargeBasePence(9000, 1000, 1000); + const tipPence = 10000 - basePence; + expect(buildCashTillPaymentBody(basePence, tipPence).amount).toBe(10000); + expect(tipPence).toBe(1000); + }); +}); + describe('payment failure classification', () => { it('a definitive 402 on a saved-card charge is an issuer verification failure', () => { expect(isSavedCardVerificationRequired(402, true)).toBe(true); @@ -746,6 +818,94 @@ describe('adminRequestNewTwoFactorCode', () => { }); }); +describe('resendEmailVerification', () => { + function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' } + }); + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('POSTs to /api/verify/generate with the email and email_verify purpose', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ success: true, message: 'ok' })); + vi.stubGlobal('fetch', fetchMock); + const response = await resendEmailVerification('user@example.com'); + expect(response.ok).toBe(true); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('/api/verify/generate'); + expect(init.method).toBe('POST'); + expect((init.headers as Record)['Content-Type']).toBe('application/json'); + expect(init.body).toBe(JSON.stringify({ email: 'user@example.com', purpose: 'email_verify' })); + }); + + it('passes through the backend response (the caller surfaces success/message)', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ success: false, message: 'cooldown' }, 429)); + vi.stubGlobal('fetch', fetchMock); + const response = await resendEmailVerification('user@example.com'); + expect(response.status).toBe(429); + expect(response.ok).toBe(false); + }); +}); + +describe('verifyEmailCode', () => { + function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' } + }); + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('POSTs to /api/verify/check with the email, code and email_verify purpose', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ success: true, message: 'Email verified successfully' })); + vi.stubGlobal('fetch', fetchMock); + const response = await verifyEmailCode('user@example.com', 'a1b2c3d4e5f6'); + expect(response.ok).toBe(true); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('/api/verify/check'); + expect(init.method).toBe('POST'); + expect((init.headers as Record)['Content-Type']).toBe('application/json'); + expect(init.body).toBe( + JSON.stringify({ + email: 'user@example.com', + code: 'a1b2c3d4e5f6', + purpose: 'email_verify' + }) + ); + }); + + it('passes through the backend success response body', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ success: true, message: 'Email verified successfully' })); + vi.stubGlobal('fetch', fetchMock); + const response = await verifyEmailCode('user@example.com', 'a1b2c3d4e5f6'); + const data = await response.json(); + expect(data).toEqual({ success: true, message: 'Email verified successfully' }); + }); + + it('passes through a failed verification (invalid/expired code)', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response('invalid or expired code', { status: 400 })); + vi.stubGlobal('fetch', fetchMock); + const response = await verifyEmailCode('user@example.com', 'wrong-code'); + expect(response.status).toBe(400); + expect(response.ok).toBe(false); + }); +}); + describe('SCA-unavailable fallback consent (C6)', () => { it('SCA_FALLBACK_CONSENT_VERSION is a non-empty versioned string', () => { expect(SCA_FALLBACK_CONSENT_VERSION).toMatch(/^v\d+$/); diff --git a/frontend/src/lib/square/square.ts b/frontend/src/lib/square/square.ts index f316313..3fe7e18 100644 --- a/frontend/src/lib/square/square.ts +++ b/frontend/src/lib/square/square.ts @@ -31,6 +31,60 @@ export const NONCE_STALENESS_MS = 240_000; // 'saved_card' across the booking, tip, account, till and admin surfaces. export const PAYMENT_METHOD_SAVED_CARD = 'saved_card'; +/** + * Cash till-sale request body shared by every cash payment surface. The + * backend's CreateTerminalPaymentRequest derives the tip from + * `amount - remaining` when `tip_enabled && remaining < amount` — it has NO + * `tip_amount` field — so the tip must be FOLDED INTO the amount (a separate + * `tip_amount` key is dead: it is never parsed and the tip would be silently + * dropped). Single source of truth so the admin and customer cash flows can't + * drift. `tip_enabled` is set only when there actually IS a tip, exactly like + * the old inline bodies. + */ +export type CashTillPaymentBody = { + amount: number; + payment_type: 'full'; + payment_method: 'cash'; + tip_enabled?: true; +}; + +export function buildCashTillPaymentBody( + cashDuePence: number, + tipPence: number +): CashTillPaymentBody { + const body: CashTillPaymentBody = { + amount: cashDuePence + tipPence, + payment_type: 'full', + payment_method: 'cash' + }; + if (tipPence > 0) body.tip_enabled = true; + return body; +} + +/** + * The cash till-sale charge base in pence, aligned with the backend's + * CreateTerminalPayment tip carve (backend/handlers/payments/handlers.go + * ~577-630). The backend derives the recorded tip from `amount − remaining`, + * where `remaining` comes from GetBookingRemainingBalancePence (service.go) — + * which does NOT subtract the pending campaign discount: campaigns auto-apply + * AFTER the remaining is read, minting `payment_method='discount'` rows that + * never reduce the balance. If the frontend charges the campaign-reduced total + * (`totalDuePence`), the sent amount is smaller than the backend's remaining, + * so `amount − remaining` is absorbed (the tip is under-recorded or the whole + * payment lands as booking credit). Restoring the campaign credit into the + * charge base keeps the sent amount on the same basis the backend carves + * against. `totalDuePence` is the net total (campaign already subtracted, + * pre-loyalty), `campaignPence` the eligible preview credit and `loyaltyPence` + * the redemption being applied on top. + */ +export function cashChargeBasePence( + totalDuePence: number, + campaignPence: number, + loyaltyPence: number +): number { + return Math.max(0, totalDuePence - loyaltyPence + campaignPence); +} + /** * True when a user's role is allowed to save cards for reuse. Only VERIFIED * accounts (verified_email, admin) may save cards — guests, unverified accounts @@ -588,6 +642,47 @@ export async function adminRequestNewTwoFactorCode( return requestTwoFactorCode(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`); } +/** + * Re-sends the account email-verification code via POST /api/verify/generate + * (backend/handlers/auth/local.go GenerateVerificationCodeHandler), minting a + * fresh code for the given email with purpose `email_verify`. Response shape: + * `{ success, message }`. + * + * The legacy `/api/verify-email` endpoint does NOT exist in the backend; its + * ONLY caller was +layout.svelte's verify_email() (frontend/src/routes/+layout.svelte + * lines ~45-61), which still POSTs to the dead URL with no body. +layout.svelte + * is OUTSIDE this fix's ownership, so this helper is the fix vehicle: the + * round-2 agent must switch verify_email() to call + * `resendEmailVerification(authStore.currentUser?.email)` — the endpoint keys + * on the logged-in user's email, so it must be passed in, never read from the + * store inside this helper (keeps it import-free for the vitest suite). No + * other call site references the dead endpoint. + */ +export async function resendEmailVerification(email: string): Promise { + return fetch('/api/verify/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, purpose: 'email_verify' }) + }); +} + +/** + * Submits the emailed verification code via POST /api/verify/check + * (backend/handlers/auth/local.go VerifyCodeHandler), escalating the account + * from unverified_email to verified_email. The handler keys on the submitted + * `code` alone (the remaining fields are ignored server-side but kept for + * forward-compatibility with a purpose-keyed check). Response shape: + * `{ success, message }` — a success clears the +layout.svelte banner via a + * reload. + */ +export async function verifyEmailCode(email: string, code: string): Promise { + return fetch('/api/verify/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, code, purpose: 'email_verify' }) + }); +} + /** Minimal `{"error"|"message": "..."}` extractor for the 2FA code-request * endpoint bodies (429/503), kept inline so square.ts stays import-free for * the vitest suite. */ diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 6dbe51e..a200fe9 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -5,6 +5,8 @@ import { Toaster } from '$lib/components/ui/sonner/index.js'; import { toast } from 'svelte-sonner'; import { authStore } from '$lib/stores/auth.svelte'; + import { resendEmailVerification, verifyEmailCode } from '$lib/square/square'; + import { extractErrorMessage } from '$lib/utils/toast-safe'; import { resetZIndexStack } from '$lib/components/ui/dialog/zindex.js'; import { onMount } from 'svelte'; import { page } from '$app/stores'; @@ -41,22 +43,77 @@ }; }); - // Verify email address - // TODO: `/api/verify-email` doesn't exist in the backend. The correct endpoints are - // `POST /api/verify/generate` (send verification code) and `POST /api/verify/check` (verify code). - // This call silently 404s. Fix required — either add the missing backend route or refactor - // to use `/api/verify/generate` + `/api/verify/check`. + // Re-send the email verification code via POST /api/verify/generate (the + // backend only exposes generate/check — there is no /api/verify-email). async function verify_email() { - const loadingToast = toast.loading('Verifying email address...'); - const response = await fetch('/api/verify-email', { - method: 'POST' - }); + const loadingToast = toast.loading('Sending verification code...'); + if (!authStore.currentUser?.email) { + toast.error('Unable to resend the verification code — no email on file', { + id: loadingToast + }); + return; + } + try { + const response = await resendEmailVerification(authStore.currentUser.email); - if (response.ok) { - toast.success('Email verified successfully!', { id: loadingToast }); - window.location.reload(); - } else { - toast.error('Failed to verify email address', { id: loadingToast }); + if (response.ok) { + toast.success('If the email exists, a verification code has been sent', { + id: loadingToast + }); + window.location.reload(); + } else { + toast.error('Failed to send the verification code', { id: loadingToast }); + } + } catch { + toast.error('Network error sending the verification code — please try again', { + id: loadingToast + }); + } + } + + // The banner has no submit surface on its own — the code lands out-of-band + // (email/SMS, or the dev [VERIFY] server log), so this inline input submits + // the received code to POST /api/verify/check. A success escalates the + // account to verified_email; the reload clears the banner. + let verificationCode = $state(''); + let isVerifyingCode = $state(false); + + async function submitVerificationCode() { + if (isVerifyingCode) return; + if (!authStore.currentUser?.email) { + toast.error('Unable to verify — no email on file'); + return; + } + const code = verificationCode.trim(); + if (!code) { + toast.error('Please enter the verification code'); + return; + } + isVerifyingCode = true; + const loadingToast = toast.loading('Verifying code...'); + try { + const response = await verifyEmailCode(authStore.currentUser.email, code); + if (response.ok) { + const data = (await response.json().catch(() => null)) as { + message?: unknown; + } | null; + toast.success( + typeof data?.message === 'string' ? data.message : 'Email verified successfully', + { id: loadingToast } + ); + window.location.reload(); + } else { + const errData = await response.text().catch(() => ''); + toast.error( + extractErrorMessage(errData) || + 'Verification failed — please check the code and try again', + { id: loadingToast } + ); + } + } catch { + toast.error('Network error verifying your code — please try again', { id: loadingToast }); + } finally { + isVerifyingCode = false; } } @@ -78,15 +135,37 @@
{#if authStore.currentUser?.role === 'unverified_email'}
- Please verify your email address to continue. Didn't recieve the email? Check your spam - folder, or - to resend it. +

+ Please verify your email address to continue. Didn't recieve the email? Check your spam + folder, or + + to resend it. +

+
+ (verificationCode = (e.target as HTMLInputElement).value)} + class="w-44 rounded-md border border-white/50 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-white focus:outline-none" + /> + +
{/if} {@render children?.()} diff --git a/frontend/src/routes/admin/notifications/+page.svelte b/frontend/src/routes/admin/notifications/+page.svelte index f7707d0..42584a9 100644 --- a/frontend/src/routes/admin/notifications/+page.svelte +++ b/frontend/src/routes/admin/notifications/+page.svelte @@ -25,10 +25,19 @@ user_id?: string; user_name?: string; booking_start_time?: string; + amount?: number; + square_id?: string; + description?: string; acknowledged_at?: string; created_at: string; } + interface Suppression { + reason: string; + suppressed_count: number; + last_suppressed_at: string; + } + interface ServiceItem { id: string; name: string; @@ -69,6 +78,8 @@ const perPage = $state(20); let total = $state(0); let includeAcknowledged = $state(false); + let suppressed = $state(0); + let suppressedDetails = $state([]); let showApprovalModal = $state(false); let selectedBooking = $state(null); @@ -111,7 +122,10 @@ affiliate_claim: 'Affiliate Referral Claimed', '1_month_no_pay': 'No Payments in 1 Month', '1_week_no_pay': 'No Payments in 1 Week', - refund_failed: 'Card refund failed — arrange in-person pickup' + refund_failed: 'Card refund failed — arrange in-person pickup', + critical_payment_log: 'Critical Payment Event', + refresh_token_reuse: 'Security Alert — Refresh Token Reuse', + gift_card_purchased_for_friend: 'Gift Card Purchased for Friend' }; function hasAction(reason: string): string | null { @@ -150,6 +164,8 @@ const data = await response.json(); notifications = data.notifications; total = data.total; + suppressed = data.suppressed ?? 0; + suppressedDetails = data.suppressed_details ?? []; } catch { error = true; } finally { @@ -408,13 +424,49 @@ {:else}
-
-

Notifications

- +
+

Notifications

+ +
+ + {#if suppressed > 0} +
+
+ + + +
+

+ {suppressed} money-critical alert{suppressed === 1 ? '' : 's'} suppressed +

+

+ The unacknowledged queue hit the flood cap. Acknowledge outstanding notifications to re-arm + money alerts. +

+ {#if suppressedDetails.length > 0} +
    + {#each suppressedDetails as d (d.reason)} +
  • • {reasonLabels[d.reason] || d.reason}: {d.suppressed_count} suppressed
  • + {/each} +
+ {/if} +
+
+ {/if} {#if notifications.length === 0}
@@ -458,6 +510,19 @@

{getNotificationTitle(n)}

{getNotificationSubtitle(n)}

+ {#if n.amount !== undefined || n.square_id || n.description} +
+ {#if n.amount !== undefined} + Amount: £{n.amount.toFixed(2)} + {/if} + {#if n.square_id} + Square: {n.square_id} + {/if} + {#if n.description} +

{n.description}

+ {/if} +
+ {/if}
{#if hasAction(n.reason)} diff --git a/frontend/src/routes/cancellation-policy/+page.svelte b/frontend/src/routes/cancellation-policy/+page.svelte index 913f9d1..ad8f44e 100644 --- a/frontend/src/routes/cancellation-policy/+page.svelte +++ b/frontend/src/routes/cancellation-policy/+page.svelte @@ -2,6 +2,19 @@ import { page } from '$app/stores'; import { resolve } from '$app/paths'; import { onMount } from 'svelte'; + import { + DEPOSIT_ADVANCE_HOURS, + FULL_REFUND_THRESHOLD_HOURS, + NO_SHOW_THRESHOLD_HOURS, + PARTIAL_REFUND_THRESHOLD_HOURS, + PROTECTED_DEPOSIT_MAX_PCT, + REQUIRED_DEPOSIT_PCT + } from '$lib/constants/policy'; + + // Whole-number percents derived from the fractional policy constants so the + // legal prose renders "20%" / "50%" without hardcoding the digits. + const depositPct = Math.round(REQUIRED_DEPOSIT_PCT * 100); + const protectedDepositMaxPct = Math.round(PROTECTED_DEPOSIT_MAX_PCT * 100); let format = $state('html'); @@ -58,13 +71,14 @@

1. Deposit Requirements & Booking Rules

+

Where a deposit is required to secure your appointment, you must pay at least - 20% of the total service cost before the 24-hour deadline prior to the - appointment. The 20% can be paid in a single payment or accumulated across multiple payments + {depositPct}% of the total service cost before the 24-hour deadline prior to the + appointment. The {depositPct}% can be paid in a single payment or accumulated across multiple payments — what matters is the total when the deadline passes. When deposit restrictions are active on your account, appointments must be scheduled at least - 36 hours in advance. + {DEPOSIT_ADVANCE_HOURS} hours in advance.

Payments toward your booking are capped at 100% of the total booking value based on service @@ -84,6 +98,7 @@

2. Unpaid Deposits & The "Pending Release" Window

+

If a required deposit is not paid at least 24 hours before the appointment begins, the booking is shifted into a "Pending Release" status. The slot becomes vulnerable @@ -123,7 +138,7 @@

-

Notice of more than 72 hours

+

Notice of more than {FULL_REFUND_THRESHOLD_HOURS} hours

You are entitled to a full 100% refund of all booking payments made.

@@ -131,19 +146,20 @@

- Notice between 24 and 72 hours (including exactly 24 or 72 hours) + Notice between {PARTIAL_REFUND_THRESHOLD_HOURS} and {FULL_REFUND_THRESHOLD_HOURS} hours + (including exactly {PARTIAL_REFUND_THRESHOLD_HOURS} or {FULL_REFUND_THRESHOLD_HOURS} hours)

- Any booking payments made up to 50% of the total booking value are treated as a + Any booking payments made up to {protectedDepositMaxPct}% 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. The protected deposit is capped - at 50% of the booking total AND at what you actually paid — so if you only paid a - 20% deposit, no more than that is ever retained. + while any balance paid above {protectedDepositMaxPct}% will be fully refunded. The protected deposit is capped + at {protectedDepositMaxPct}% of the booking total AND at what you actually paid — so if you only paid a + {depositPct}% deposit, no more than that is ever retained.

-

Notice of less than 24 hours

+

Notice of less than {NO_SHOW_THRESHOLD_HOURS} hours

All booking payments and deposits are retained (no refund). The cancellation will be logged as a missed appointment history strike. @@ -220,6 +236,7 @@

4. Missed Appointments (No-Shows)

+

Failing to attend a confirmed appointment without notifying us in advance constitutes a "No-Show". Cancelling a pending or deposit-lapsed booking within 24 hours does @@ -254,11 +271,13 @@ choose a refund, it is processed under our standard refund tiers above unless we waive them (for example when the cancellation is due to our own scheduling conflicts).

+

Please note that in accordance with UK statutory exclusions for distance contracts, the standard 14-day statutory cancellation "cooling-off" period under the Consumer Contracts Regulations 2013 does not apply to online bookings scheduled for a specific date or time.

+

That exclusion does not apply to gift cards: online gift-card purchases may be cancelled within 14 days for a refund to the original payment method under the Consumer Contracts diff --git a/frontend/src/routes/gift-card-terms/+page.svelte b/frontend/src/routes/gift-card-terms/+page.svelte index 2fc4c51..75c3271 100644 --- a/frontend/src/routes/gift-card-terms/+page.svelte +++ b/frontend/src/routes/gift-card-terms/+page.svelte @@ -91,6 +91,7 @@

3. Expiry

+

Gift cards expire 24 months after their last use (rolling expiry). Each use resets the 24-month period. @@ -147,6 +148,7 @@

6. Refunds and Cancellation

+

Non-refundable except where the law requires: except for the 14-day right to cancel described below, and except as required by consumer law or by an express refund we @@ -157,6 +159,7 @@ >, gift cards and gift-card balances are non-refundable — they are not redeemable for cash (unless required by law).

+

14-day right to cancel online purchases: if you buy a gift card online, the Consumer Contracts (Information, Cancellation and Additional Charges) Regulations 2013 give you a 14-day diff --git a/frontend/src/routes/privacy-policy/+page.svelte b/frontend/src/routes/privacy-policy/+page.svelte index 372eab3..0b60ebf 100644 --- a/frontend/src/routes/privacy-policy/+page.svelte +++ b/frontend/src/routes/privacy-policy/+page.svelte @@ -407,6 +407,7 @@ 6 months after the appointment, then anonymized GDPR storage limitation + Gift cards diff --git a/frontend/src/routes/terms/+page.svelte b/frontend/src/routes/terms/+page.svelte index ac9e01e..8e290f4 100644 --- a/frontend/src/routes/terms/+page.svelte +++ b/frontend/src/routes/terms/+page.svelte @@ -3,6 +3,18 @@ import { resolve } from '$app/paths'; import { onMount } from 'svelte'; import { SUPPORT_EMAIL, BUSINESS_NAME, BUSINESS_ADDRESS, TRADER_LEGAL_NAME } from '$lib/constants/contact'; + import { + FULL_REFUND_THRESHOLD_HOURS, + NO_SHOW_THRESHOLD_HOURS, + PARTIAL_REFUND_THRESHOLD_HOURS, + PROTECTED_DEPOSIT_MAX_PCT, + REQUIRED_DEPOSIT_PCT + } from '$lib/constants/policy'; + + // Whole-number percents derived from the fractional policy constants so the + // legal prose renders "20%" / "50%" without hardcoding the digits. + const depositPct = Math.round(REQUIRED_DEPOSIT_PCT * 100); + const protectedDepositMaxPct = Math.round(PROTECTED_DEPOSIT_MAX_PCT * 100); let format = $state('html'); @@ -130,9 +142,9 @@ delivery is available).

  • - Some services require a deposit (typically 20% of the service cost; a - protected-deposit cap of 50% may be retained on short-notice cancellation, capped at - what you actually paid). + Some services require a deposit (typically {depositPct}% of the service cost; a + protected-deposit cap of {protectedDepositMaxPct}% may be retained on short-notice + cancellation, capped at what you actually paid).
  • Cancellations & rescheduling

    @@ -145,16 +157,16 @@

    • - Notice of more than 72 hours: you are entitled to a full 100% refund of all + Notice of more than {FULL_REFUND_THRESHOLD_HOURS} hours: you are entitled to a full 100% refund of all booking payments made.
    • - Notice between 24 and 72 hours: any booking payments made up to 50% of the + Notice between {PARTIAL_REFUND_THRESHOLD_HOURS} and {FULL_REFUND_THRESHOLD_HOURS} hours: any booking payments made up to {protectedDepositMaxPct}% 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. + cover the short-notice vacancy, while any balance paid above {protectedDepositMaxPct}% will be fully refunded.
    • - Notice of less than 24 hours: all booking payments and deposits are entirely + Notice of less than {NO_SHOW_THRESHOLD_HOURS} hours: all booking payments and deposits are entirely non-refundable and will be retained. The cancellation will be logged as a missed appointment history strike.
    • @@ -172,8 +184,8 @@

      Deposits

      • - 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). + Deposits are retained on cancellation in line with the tiered schedule above (up to {protectedDepositMaxPct}% of + the booking value between {PARTIAL_REFUND_THRESHOLD_HOURS} and {FULL_REFUND_THRESHOLD_HOURS} hours' notice; all payments retained under {NO_SHOW_THRESHOLD_HOURS} hours).
      • Deposits are applied to your final bill.
      • If we cancel, any deposit paid is refunded under the business-cancellation terms above.
      • @@ -263,6 +275,7 @@

        Gift card expiry

          +
        • Gift cards expire after the expiry period set in the salon's business settings (24 months after last use by default, and never less than 12 months). @@ -289,6 +302,7 @@ VAT is charged at the point of gift card purchase, not at redemption. When you pay with gift card balance, no additional VAT is charged (it has already been paid).

          +

          Right to cancel: 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 @@ -301,6 +315,7 @@ > for the full position.

          +

          Non-refundable except where the law requires: except for the 14-day right to cancel online purchases above, and except as required by consumer law or by an express @@ -318,10 +333,12 @@ Purchases made on our Platform (rather than face-to-face in the salon) are distance contracts under the Consumer Contracts (Information, Cancellation and Additional Charges) Regulations 2013. This gives you a + 14-day right to cancel most online purchases, running from the day after purchase.

            +
          • Gift cards bought online carry this 14-day right, refunded to the original payment method — in full if unused, or the unspent balance if partly used @@ -338,6 +355,7 @@ activities), so the 14-day right does not apply to the service itself; our cancellation and refund policy in section 3 applies instead.
          • +
          • How to exercise it: for gift cards, cancel in-app from your account within 14 days (no email needed); for anything else, email {SUPPORT_EMAIL} within 14 days.