fix: adminnotify observability — money-critical rows sort first, flood-cap suppression surfaced to operator, stale coordination doc fixed

- notifications priority ordering: money-critical reasons (webhooks, sweeps, refunds, gift-card, manual-refund failures) above routine
- admin notifications page exposes the flood-cap suppressed count
- adminnotify.go contract doc: removed stale 2FA reissue-fail site, current insert-site list

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent e9b34d0ad6
commit 049c361e16
16 changed files with 1048 additions and 314 deletions
+81 -12
View File
@@ -3,6 +3,7 @@ package notifications
import ( import (
"context" "context"
"crussell/db" "crussell/db"
"crussell/internal/adminnotify"
"crussell/internal/validators" "crussell/internal/validators"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
@@ -27,8 +28,24 @@ type AdminNotification struct {
UserID *string `json:"user_id,omitempty"` UserID *string `json:"user_id,omitempty"`
UserName *string `json:"user_name,omitempty"` UserName *string `json:"user_name,omitempty"`
BookingStartTime *time.Time `json:"booking_start_time,omitempty"` BookingStartTime *time.Time `json:"booking_start_time,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"` // Money-critical event detail (amount / square id / description): populated
CreatedAt time.Time `json:"created_at"` // 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 { type AdminNotificationListResponse struct {
@@ -37,6 +54,13 @@ type AdminNotificationListResponse struct {
PerPage int `json:"per_page"` PerPage int `json:"per_page"`
Total int `json:"total"` Total int `json:"total"`
NextCursor *string `json:"next_cursor,omitempty"` 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. // parseCursor splits a "createdAt|id" cursor string into its components.
@@ -47,6 +71,13 @@ type AdminNotificationListResponse struct {
// cursor, per_page — pagination (cursor-based) // cursor, per_page — pagination (cursor-based)
// include_acknowledged — if "true", returns all notifications sorted newest-first. // include_acknowledged — if "true", returns all notifications sorted newest-first.
// Default (false/omitted): only unacknowledged, sorted by priority then oldest-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) { func GetNotifications(w http.ResponseWriter, r *http.Request) {
// Parse query params // Parse query params
perPage := 20 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, SELECT an.id, an.reason, an.booking_id, an.user_id,
u.n_first_name || ' ' || u.n_last_name AS user_name, u.n_first_name || ' ' || u.n_last_name AS user_name,
b.start_time AS booking_start_time, b.start_time AS booking_start_time,
an.amount, an.square_id, an.description,
an.acknowledged_at, an.created_at an.acknowledged_at, an.created_at
FROM admin_notifications an FROM admin_notifications an
LEFT JOIN users u ON an.user_id = u.id 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" baseQuery += " ORDER BY an.created_at DESC, an.id DESC"
} else { } else {
baseQuery += ` ORDER BY CASE an.reason baseQuery += ` ORDER BY CASE an.reason
WHEN 'pending_booking' THEN 1 WHEN 'critical_payment_log' THEN 1
WHEN 'cancelled_booking' THEN 2 WHEN 'refund_failed' THEN 2
WHEN 'late_cancellation' THEN 3 WHEN 'refresh_token_reuse' THEN 3
WHEN 'deposit_paid' THEN 4 WHEN 'gift_card_purchased_for_friend' THEN 4
WHEN 'affiliate_claim' THEN 5 WHEN 'pending_booking' THEN 5
WHEN 'edit_requested' THEN 6 WHEN 'cancelled_booking' THEN 6
WHEN 'new_booking' THEN 7 WHEN 'late_cancellation' THEN 7
WHEN '1_month_no_pay' THEN 8 WHEN 'deposit_paid' THEN 8
WHEN '1_week_no_pay' THEN 9 WHEN 'affiliate_claim' THEN 9
ELSE 10 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` END, an.created_at ASC, an.id ASC`
} }
baseQuery += fmt.Sprintf(" LIMIT $%d", param) baseQuery += fmt.Sprintf(" LIMIT $%d", param)
@@ -149,6 +189,9 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
var userID sql.NullString var userID sql.NullString
var userName sql.NullString var userName sql.NullString
var bookingStartTime sql.NullTime var bookingStartTime sql.NullTime
var amount sql.NullFloat64
var squareID sql.NullString
var description sql.NullString
var acknowledgedAt sql.NullTime var acknowledgedAt sql.NullTime
err := rows.Scan( err := rows.Scan(
@@ -158,6 +201,9 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
&userID, &userID,
&userName, &userName,
&bookingStartTime, &bookingStartTime,
&amount,
&squareID,
&description,
&acknowledgedAt, &acknowledgedAt,
&n.CreatedAt, &n.CreatedAt,
) )
@@ -179,6 +225,15 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
if bookingStartTime.Valid { if bookingStartTime.Valid {
n.BookingStartTime = &bookingStartTime.Time 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 { if acknowledgedAt.Valid {
n.AcknowledgedAt = &acknowledgedAt.Time n.AcknowledgedAt = &acknowledgedAt.Time
} }
@@ -206,6 +261,20 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
NextCursor: nextCursor, 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 { if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("Failed to encode response: %v", err) log.Printf("Failed to encode response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -14,6 +14,7 @@ import (
"time" "time"
"crussell/db" "crussell/db"
"crussell/internal/adminnotify"
"crussell/mw" "crussell/mw"
"crussell/testutils" "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()) 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(&notificationID)
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)
}
}
+90 -16
View File
@@ -2,39 +2,47 @@
// notification centre (admin_notifications). That table is the single // notification centre (admin_notifications). That table is the single
// operator's ONLY pager for money events, so every site that inserts an // operator's ONLY pager for money events, so every site that inserts an
// operator-facing alert must bound its unacknowledged queue — a hostile flood // operator-facing alert must bound its unacknowledged queue — a hostile flood
// (attacker-registered accounts triggering refresh_token_reuse / reissue-fail / // (attacker-registered accounts triggering refresh_token_reuse / webhook
// webhook alerts) must not be able to bury the notification centre under rows // alerts) must not be able to bury the notification centre under rows the
// the operator can never work through. // operator can never work through.
// //
// Coordination contract (Round 2 Loop B finding 1): the cap is applied // Coordination contract (Round 2 Loop B finding 1): the cap is applied
// atomically at every insert site in this codebase: // 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. // - 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). // (dispute, booking, unknown-event, orphan-replay).
// - handlers/user/account.go InsertSquareErasureCriticalNotification. // - 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): // main.go has NO admin_notifications insert sites (it only mounts the
// // notification read/ack routes), so nothing to cap there.
// - 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.
// //
// Every site folds the cap INTO its INSERT (a conditional // Every site folds the cap INTO its INSERT (a conditional
// `INSERT ... SELECT ... WHERE (SELECT COUNT(*) ...) < $cap`) so the // `INSERT ... SELECT ... WHERE (SELECT COUNT(*) ...) < $cap`) so the
// count-then-insert is ATOMIC — closing the TOCTOU where two concurrent // count-then-insert is ATOMIC — closing the TOCTOU where two concurrent
// inserts both read a below-cap count and overshoot together (finding 2). // 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 package adminnotify
import ( import (
"context" "context"
"log" "log"
"time"
"crussell/db" "crussell/db"
) )
@@ -58,6 +66,11 @@ const MaxUnacknowledgedCriticalLogs = 100
// a money alert is never dropped because the count query failed (the insert's // 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 // own atomic cap condition below still guards the row in that case — the
// pre-check only decides whether to log a suppression). // 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 { func CriticalLogsCapExceeded(ctx context.Context, q db.Querier, reason string) bool {
var n int var n int
err := q.QueryRow(ctx, ` 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) log.Printf("adminnotify: failed to count unacknowledged %s admin notifications: %v", reason, err)
return false 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()
} }
@@ -35,6 +35,7 @@ func TestCriticalLogsCapExceeded(t *testing.T) {
ctx := context.Background() ctx := context.Background()
t.Cleanup(func() { t.Cleanup(func() {
_, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'") _, _ = 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") { 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") 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))
}
}
+66 -185
View File
@@ -29,7 +29,6 @@
"@tailwindcss/vite": "^4.2.0", "@tailwindcss/vite": "^4.2.0",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"@vitest/coverage-v8": "^4.1.10",
"bits-ui": "^2.16.1", "bits-ui": "^2.16.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"eslint": "^9.39.5", "eslint": "^9.39.5",
@@ -56,66 +55,6 @@
"vitest": "^4.1.10" "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": { "node_modules/@discourse/jxl": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@discourse/jxl/-/jxl-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@discourse/jxl/-/jxl-1.3.0.tgz",
@@ -1234,6 +1173,72 @@
"node": ">=14.0.0" "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": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", "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" "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": { "node_modules/@vitest/expect": {
"version": "4.1.10", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
@@ -1904,18 +1878,6 @@
"node": ">=12" "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": { "node_modules/axobject-query": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -2705,13 +2667,6 @@
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==", "integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
"license": "MIT" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2796,45 +2751,6 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2845,13 +2761,6 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/js-yaml": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
@@ -3270,34 +3179,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/maplibre-gl": {
"version": "5.24.0", "version": "5.24.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
-1
View File
@@ -29,7 +29,6 @@
"@tailwindcss/vite": "^4.2.0", "@tailwindcss/vite": "^4.2.0",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"@vitest/coverage-v8": "^4.1.10",
"bits-ui": "^2.16.1", "bits-ui": "^2.16.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"eslint": "^9.39.5", "eslint": "^9.39.5",
@@ -214,7 +214,11 @@
const resp = await fetch(`/api/check-email?${params}`); const resp = await fetch(`/api/check-email?${params}`);
if (resp.ok) { if (resp.ok) {
const data = await resp.json(); 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 { } catch {
// Network error - silently ignore, don't block booking // Network error - silently ignore, don't block booking
@@ -528,19 +532,18 @@
if (!confirmedBooking) return; if (!confirmedBooking) return;
const bookingId = confirmedBooking.id; const bookingId = confirmedBooking.id;
const response = await submitPaymentWithRetry( const response = await submitPaymentWithRetry(() =>
() => apiFetch(`/api/bookings/${bookingId}/payment`, {
apiFetch(`/api/bookings/${bookingId}/payment`, { method: 'POST',
method: 'POST', headers: {
headers: { 'Content-Type': 'application/json',
'Content-Type': 'application/json', ...getAuthHeaders()
...getAuthHeaders() },
}, body: JSON.stringify({
body: JSON.stringify({ ...body,
...body, ...(confirmOverflowTip ? { confirm_overflow_tip: true } : {})
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {})
})
}) })
})
); );
if (response.ok) { if (response.ok) {
@@ -2372,11 +2375,6 @@
<a href={resolve('/login')} class="underline hover:text-red-800">log in</a> <a href={resolve('/login')} class="underline hover:text-red-800">log in</a>
instead to access your bookings and rewards. instead to access your bookings and rewards.
</p> </p>
{:else if emailSuggestion === 'check'}
<p class="text-xs font-medium text-amber-600">
This email might belong to an existing account. Please double-check or
<a href={resolve('/login')} class="underline hover:text-amber-800">log in</a>.
</p>
{/if} {/if}
{/if} {/if}
@@ -10,8 +10,10 @@
import { apiFetch } from '$lib/utils/api'; import { apiFetch } from '$lib/utils/api';
import { formatCurrency } from '$lib/utils/format'; import { formatCurrency } from '$lib/utils/format';
import { import {
buildCashTillPaymentBody,
CARD_VERIFICATION_RETRY_MESSAGE, CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence, campaignDiscountPence,
cashChargeBasePence,
isOverflowTipConfirmationRequired, isOverflowTipConfirmationRequired,
isVerificationRequiredSignal, isVerificationRequiredSignal,
PAYMENT_METHOD_SAVED_CARD, PAYMENT_METHOD_SAVED_CARD,
@@ -558,7 +560,20 @@
let cashAmount = $state<string>(''); let cashAmount = $state<string>('');
const cashAmountNum = $derived(cashAmount === '' ? 0 : parseFloat(cashAmount)); 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); let extraAsTip = $state(false);
function handleCashInput(e: Event) { function handleCashInput(e: Event) {
@@ -571,7 +586,6 @@
async function handleCashPayment() { async function handleCashPayment() {
if (isProcessingPaymentSync) return; if (isProcessingPaymentSync) return;
const cashDue = totalDue - loyaltyDiscount / 100;
if (cashDue <= 0) { if (cashDue <= 0) {
toast.error('Nothing to charge — the booking is fully covered by discounts'); toast.error('Nothing to charge — the booking is fully covered by discounts');
@@ -591,15 +605,11 @@
try { try {
await applyLoyaltyRedemption(); await applyLoyaltyRedemption();
const body: Record<string, unknown> = { // Cash till-sale body: the tip is FOLDED into the amount (the backend's
amount: Math.round(cashDue * 100), // CreateTerminalPaymentRequest derives the tip from `amount - remaining`
payment_type: 'full', // when tip_enabled; it has no tip_amount field, so sending one would
payment_method: 'cash' // silently drop the tip).
}; const body = buildCashTillPaymentBody(Math.round(cashDue * 100), Math.round(tipAmount * 100));
if (tipAmount > 0) {
body.tip_enabled = true;
body.tip_amount = Math.round(tipAmount * 100);
}
const response = await apiFetch(`/api/admin/bookings/${booking.id}/payment`, { const response = await apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST', method: 'POST',
@@ -890,22 +900,21 @@
status = 'saved-card-processing'; status = 'saved-card-processing';
} }
const response = await submitPaymentWithRetry( const response = await submitPaymentWithRetry(() =>
() => apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
apiFetch(`/api/admin/bookings/${booking.id}/payment`, { method: 'POST',
method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
body: JSON.stringify({ amount: chargeAmount,
amount: chargeAmount, payment_type: 'full',
payment_type: 'full', payment_method: 'saved_card',
payment_method: 'saved_card', saved_card_id: selectedSavedCardId,
saved_card_id: selectedSavedCardId, // C1: the SCA tokenize-result token is the charge SOURCE
// C1: the SCA tokenize-result token is the charge SOURCE // (new_card_token) alongside the saved-card ref — never
// (new_card_token) alongside the saved-card ref — never // the legacy verification_token.
// the legacy verification_token. ...(verificationToken ? { new_card_token: verificationToken } : {}),
...(verificationToken ? { new_card_token: verificationToken } : {}), idempotency_key: savedCardIdempotencyKey
idempotency_key: savedCardIdempotencyKey })
})
}) })
); );
@@ -996,7 +1005,7 @@
$effect(() => { $effect(() => {
if (selectedMethod === 'cash') { if (selectedMethod === 'cash') {
cashAmount = totalDue.toFixed(2); cashAmount = cashDue.toFixed(2);
extraAsTip = false; extraAsTip = false;
} }
if (selectedMethod === 'giftcard') { if (selectedMethod === 'giftcard') {
@@ -1387,7 +1396,7 @@
{#each tipPercentages as tip (tip.pct)} {#each tipPercentages as tip (tip.pct)}
<button <button
type="button" type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none hover:bg-fuchsia-50 {selectedTipPercent === class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {selectedTipPercent ===
tip.pct tip.pct
? 'border-input bg-fuchsia-100 text-foreground' ? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input'}" : 'border-input'}"
@@ -1447,7 +1456,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4"> <div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total Due</span> <span class="text-base font-semibold text-gray-700">Total Due</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span> <span class="text-xl font-bold text-gray-900">{formatCurrency(cashDue)}</span>
</div> </div>
<div> <div>
@@ -1465,7 +1474,7 @@
</div> </div>
</div> </div>
{#if cashAmountNum >= totalDue} {#if cashAmountNum >= cashDue}
<div class="rounded-md border border-green-200 bg-green-50 p-4"> <div class="rounded-md border border-green-200 bg-green-50 p-4">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm font-medium text-green-800">Change Due</span> <span class="text-sm font-medium text-green-800">Change Due</span>
@@ -1487,7 +1496,7 @@
<Button <Button
onclick={handleCashPayment} onclick={handleCashPayment}
class="min-h-11 flex-1" class="min-h-11 flex-1"
disabled={cashAmountNum < totalDue || nothingToCharge} disabled={cashAmountNum < cashDue || nothingToCharge}
> >
Confirm Cash Confirm Cash
</Button> </Button>
+161 -1
View File
@@ -8,8 +8,10 @@ import {
SCA_REFUSAL_MESSAGE_TILL, SCA_REFUSAL_MESSAGE_TILL,
VERIFICATION_REQUIRED_MESSAGE, VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode, adminRequestNewTwoFactorCode,
buildCashTillPaymentBody,
campaignDiscountPence, campaignDiscountPence,
canSaveCardsForRole, canSaveCardsForRole,
cashChargeBasePence,
depositChargePence, depositChargePence,
isAmbiguousPaymentFailure, isAmbiguousPaymentFailure,
isNonceStale, isNonceStale,
@@ -20,11 +22,13 @@ import {
newCardTokenizeResult, newCardTokenizeResult,
parseTokenizeVerificationResult, parseTokenizeVerificationResult,
requestNewTwoFactorCode, requestNewTwoFactorCode,
resendEmailVerification,
sanitizeDecimalInput, sanitizeDecimalInput,
scaFallbackConsentFields, scaFallbackConsentFields,
shouldShowSCARefusal, shouldShowSCARefusal,
submitPaymentWithRetry, submitPaymentWithRetry,
type SavedCardVerificationOutcome type SavedCardVerificationOutcome,
verifyEmailCode
} from './square'; } from './square';
import type * as SquareModule 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', () => { describe('payment failure classification', () => {
it('a definitive 402 on a saved-card charge is an issuer verification failure', () => { it('a definitive 402 on a saved-card charge is an issuer verification failure', () => {
expect(isSavedCardVerificationRequired(402, true)).toBe(true); 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<string, string>)['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<string, string>)['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)', () => { describe('SCA-unavailable fallback consent (C6)', () => {
it('SCA_FALLBACK_CONSENT_VERSION is a non-empty versioned string', () => { it('SCA_FALLBACK_CONSENT_VERSION is a non-empty versioned string', () => {
expect(SCA_FALLBACK_CONSENT_VERSION).toMatch(/^v\d+$/); expect(SCA_FALLBACK_CONSENT_VERSION).toMatch(/^v\d+$/);
+95
View File
@@ -31,6 +31,60 @@ export const NONCE_STALENESS_MS = 240_000;
// 'saved_card' across the booking, tip, account, till and admin surfaces. // 'saved_card' across the booking, tip, account, till and admin surfaces.
export const PAYMENT_METHOD_SAVED_CARD = 'saved_card'; 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 * 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 * 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`); 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<Response> {
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<Response> {
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 /** Minimal `{"error"|"message": "..."}` extractor for the 2FA code-request
* endpoint bodies (429/503), kept inline so square.ts stays import-free for * endpoint bodies (429/503), kept inline so square.ts stays import-free for
* the vitest suite. */ * the vitest suite. */
+102 -23
View File
@@ -5,6 +5,8 @@
import { Toaster } from '$lib/components/ui/sonner/index.js'; import { Toaster } from '$lib/components/ui/sonner/index.js';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { authStore } from '$lib/stores/auth.svelte'; 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 { resetZIndexStack } from '$lib/components/ui/dialog/zindex.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -41,22 +43,77 @@
}; };
}); });
// Verify email address // Re-send the email verification code via POST /api/verify/generate (the
// TODO: `/api/verify-email` doesn't exist in the backend. The correct endpoints are // backend only exposes generate/check — there is no /api/verify-email).
// `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`.
async function verify_email() { async function verify_email() {
const loadingToast = toast.loading('Verifying email address...'); const loadingToast = toast.loading('Sending verification code...');
const response = await fetch('/api/verify-email', { if (!authStore.currentUser?.email) {
method: 'POST' 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) { if (response.ok) {
toast.success('Email verified successfully!', { id: loadingToast }); toast.success('If the email exists, a verification code has been sent', {
window.location.reload(); id: loadingToast
} else { });
toast.error('Failed to verify email address', { 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;
} }
} }
</script> </script>
@@ -78,15 +135,37 @@
<main class="flex-1 pt-16"> <main class="flex-1 pt-16">
{#if authStore.currentUser?.role === 'unverified_email'} {#if authStore.currentUser?.role === 'unverified_email'}
<div class="w-full bg-red-600 py-2 pr-8 pl-8 text-center text-sm font-medium text-white"> <div class="w-full bg-red-600 py-2 pr-8 pl-8 text-center text-sm font-medium text-white">
Please verify your email address to continue. Didn't recieve the email? Check your spam <p>
folder, or <button Please verify your email address to continue. Didn't recieve the email? Check your spam
type="button" folder, or
onclick={verify_email} <button
class="cursor-pointer border-0 bg-transparent p-0 text-white underline" type="button"
> onclick={verify_email}
click here class="cursor-pointer border-0 bg-transparent p-0 text-white underline"
</button> >
to resend it. click here
</button>
to resend it.
</p>
<div class="mx-auto mt-2 flex max-w-md items-center justify-center gap-2">
<input
type="text"
inputmode="numeric"
autocomplete="one-time-code"
placeholder="Enter verification code"
value={verificationCode}
oninput={(e) => (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"
/>
<button
type="button"
onclick={submitVerificationCode}
disabled={isVerifyingCode}
class="cursor-pointer rounded-md bg-white px-4 py-1.5 text-sm font-semibold text-red-700 transition-colors hover:bg-red-50 disabled:opacity-50"
>
Verify
</button>
</div>
</div> </div>
{/if} {/if}
{@render children?.()} {@render children?.()}
@@ -25,10 +25,19 @@
user_id?: string; user_id?: string;
user_name?: string; user_name?: string;
booking_start_time?: string; booking_start_time?: string;
amount?: number;
square_id?: string;
description?: string;
acknowledged_at?: string; acknowledged_at?: string;
created_at: string; created_at: string;
} }
interface Suppression {
reason: string;
suppressed_count: number;
last_suppressed_at: string;
}
interface ServiceItem { interface ServiceItem {
id: string; id: string;
name: string; name: string;
@@ -69,6 +78,8 @@
const perPage = $state(20); const perPage = $state(20);
let total = $state(0); let total = $state(0);
let includeAcknowledged = $state(false); let includeAcknowledged = $state(false);
let suppressed = $state(0);
let suppressedDetails = $state<Suppression[]>([]);
let showApprovalModal = $state(false); let showApprovalModal = $state(false);
let selectedBooking = $state<Booking | null>(null); let selectedBooking = $state<Booking | null>(null);
@@ -111,7 +122,10 @@
affiliate_claim: 'Affiliate Referral Claimed', affiliate_claim: 'Affiliate Referral Claimed',
'1_month_no_pay': 'No Payments in 1 Month', '1_month_no_pay': 'No Payments in 1 Month',
'1_week_no_pay': 'No Payments in 1 Week', '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 { function hasAction(reason: string): string | null {
@@ -150,6 +164,8 @@
const data = await response.json(); const data = await response.json();
notifications = data.notifications; notifications = data.notifications;
total = data.total; total = data.total;
suppressed = data.suppressed ?? 0;
suppressedDetails = data.suppressed_details ?? [];
} catch { } catch {
error = true; error = true;
} finally { } finally {
@@ -408,13 +424,49 @@
</div> </div>
{:else} {:else}
<div class="mx-auto max-w-3xl p-6 pt-24"> <div class="mx-auto max-w-3xl p-6 pt-24">
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900">Notifications</h1> <h1 class="text-2xl font-semibold text-gray-900">Notifications</h1>
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600"> <label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600">
<Checkbox checked={includeAcknowledged} onchange={toggleView} /> <Checkbox checked={includeAcknowledged} onchange={toggleView} />
Show acknowledged Show acknowledged
</label> </label>
</div>
{#if suppressed > 0}
<div class="mb-6 rounded-lg border border-amber-300 bg-amber-50 p-4">
<div class="flex items-start gap-2">
<svg
class="mt-0.5 h-5 w-5 shrink-0 text-amber-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div>
<p class="text-sm font-medium text-amber-800">
{suppressed} money-critical alert{suppressed === 1 ? '' : 's'} suppressed
</p>
<p class="mt-1 text-sm text-amber-700">
The unacknowledged queue hit the flood cap. Acknowledge outstanding notifications to re-arm
money alerts.
</p>
{#if suppressedDetails.length > 0}
<ul class="mt-2 space-y-1 text-xs text-amber-700">
{#each suppressedDetails as d (d.reason)}
<li>{reasonLabels[d.reason] || d.reason}: {d.suppressed_count} suppressed</li>
{/each}
</ul>
{/if}
</div>
</div>
</div> </div>
{/if}
{#if notifications.length === 0} {#if notifications.length === 0}
<div class="rounded-lg border border-gray-200 bg-gray-50 py-16 text-center"> <div class="rounded-lg border border-gray-200 bg-gray-50 py-16 text-center">
@@ -458,6 +510,19 @@
<h3 class="font-medium text-gray-900">{getNotificationTitle(n)}</h3> <h3 class="font-medium text-gray-900">{getNotificationTitle(n)}</h3>
</div> </div>
<p class="mt-1 text-sm text-gray-500">{getNotificationSubtitle(n)}</p> <p class="mt-1 text-sm text-gray-500">{getNotificationSubtitle(n)}</p>
{#if n.amount !== undefined || n.square_id || n.description}
<div class="mt-2 rounded-md bg-gray-100 px-2.5 py-2 text-xs text-gray-600">
{#if n.amount !== undefined}
<span class="font-medium">Amount: £{n.amount.toFixed(2)}</span>
{/if}
{#if n.square_id}
<span class="{n.amount !== undefined ? 'ml-2' : ''}">Square: {n.square_id}</span>
{/if}
{#if n.description}
<p class="mt-0.5">{n.description}</p>
{/if}
</div>
{/if}
</div> </div>
<div class="flex shrink-0 items-center gap-2 sm:flex-col sm:items-end"> <div class="flex shrink-0 items-center gap-2 sm:flex-col sm:items-end">
{#if hasAction(n.reason)} {#if hasAction(n.reason)}
@@ -2,6 +2,19 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { onMount } from 'svelte'; 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'); let format = $state('html');
@@ -58,13 +71,14 @@
<h2 class="mb-3 text-base font-semibold text-gray-900"> <h2 class="mb-3 text-base font-semibold text-gray-900">
1. Deposit Requirements & Booking Rules 1. Deposit Requirements & Booking Rules
</h2> </h2>
<!-- keep in sync with $lib/constants/policy -->
<p class="mb-3"> <p class="mb-3">
Where a deposit is required to secure your appointment, you must pay at least Where a deposit is required to secure your appointment, you must pay at least
<strong>20%</strong> of the total service cost before the 24-hour deadline prior to the <strong>{depositPct}%</strong> 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 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 — what matters is the total when the deadline passes. When deposit restrictions are active
on your account, appointments must be scheduled at least on your account, appointments must be scheduled at least
<strong>36 hours in advance</strong>. <strong>{DEPOSIT_ADVANCE_HOURS} hours in advance</strong>.
</p> </p>
<p class="mb-3"> <p class="mb-3">
Payments toward your booking are capped at 100% of the total booking value based on service Payments toward your booking are capped at 100% of the total booking value based on service
@@ -84,6 +98,7 @@
<h2 class="mb-3 text-base font-semibold text-gray-900"> <h2 class="mb-3 text-base font-semibold text-gray-900">
2. Unpaid Deposits & The "Pending Release" Window 2. Unpaid Deposits & The "Pending Release" Window
</h2> </h2>
<!-- keep in sync with $lib/constants/policy -->
<p class="mb-3"> <p class="mb-3">
If a required deposit is not paid at least 24 hours before the appointment begins, the If a required deposit is not paid at least 24 hours before the appointment begins, the
booking is shifted into a <strong>"Pending Release"</strong> status. The slot becomes vulnerable booking is shifted into a <strong>"Pending Release"</strong> status. The slot becomes vulnerable
@@ -123,7 +138,7 @@
<div class="mt-4 divide-y divide-gray-200 rounded-md border border-gray-200"> <div class="mt-4 divide-y divide-gray-200 rounded-md border border-gray-200">
<div class="bg-gray-50/50 p-4"> <div class="bg-gray-50/50 p-4">
<p class="font-semibold text-gray-900">Notice of more than 72 hours</p> <p class="font-semibold text-gray-900">Notice of more than {FULL_REFUND_THRESHOLD_HOURS} hours</p>
<p class="mt-1 text-xs text-gray-600"> <p class="mt-1 text-xs text-gray-600">
You are entitled to a full 100% refund of all booking payments made. You are entitled to a full 100% refund of all booking payments made.
</p> </p>
@@ -131,19 +146,20 @@
<div class="p-4"> <div class="p-4">
<p class="font-semibold text-gray-900"> <p class="font-semibold text-gray-900">
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)
</p> </p>
<p class="mt-1 text-xs text-gray-600"> <p class="mt-1 text-xs text-gray-600">
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, 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 while any balance paid above {protectedDepositMaxPct}% will be fully refunded. The protected deposit is capped
at 50% of the booking total AND at what you actually paid &mdash; so if you only paid a at {protectedDepositMaxPct}% of the booking total AND at what you actually paid &mdash; so if you only paid a
20% deposit, no more than that is ever retained. {depositPct}% deposit, no more than that is ever retained.
</p> </p>
</div> </div>
<div class="bg-gray-50/50 p-4"> <div class="bg-gray-50/50 p-4">
<p class="font-semibold text-gray-900">Notice of less than 24 hours</p> <p class="font-semibold text-gray-900">Notice of less than {NO_SHOW_THRESHOLD_HOURS} hours</p>
<p class="mt-1 text-xs text-gray-600"> <p class="mt-1 text-xs text-gray-600">
All booking payments and deposits are retained (no refund). The cancellation will be All booking payments and deposits are retained (no refund). The cancellation will be
logged as a missed appointment history strike. logged as a missed appointment history strike.
@@ -220,6 +236,7 @@
<!-- Section 4 --> <!-- Section 4 -->
<section> <section>
<h2 class="mb-3 text-base font-semibold text-gray-900">4. Missed Appointments (No-Shows)</h2> <h2 class="mb-3 text-base font-semibold text-gray-900">4. Missed Appointments (No-Shows)</h2>
<!-- keep in sync with $lib/constants/policy -->
<p class="mb-3"> <p class="mb-3">
Failing to attend a confirmed appointment without notifying us in advance constitutes a 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 "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 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). (for example when the cancellation is due to our own scheduling conflicts).
</p> </p>
<!-- keep in sync with $lib/constants/policy -->
<p class="mb-3"> <p class="mb-3">
Please note that in accordance with UK statutory exclusions for distance contracts, the 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 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. Regulations 2013 does not apply to online bookings scheduled for a specific date or time.
</p> </p>
<!-- keep in sync with $lib/constants/policy -->
<p class="mb-3"> <p class="mb-3">
That exclusion does not apply to gift cards: online gift-card purchases may be cancelled 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 within 14 days for a refund to the original payment method under the Consumer Contracts
@@ -91,6 +91,7 @@
<section> <section>
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Expiry</h2> <h2 class="mb-3 text-base font-semibold text-gray-900">3. Expiry</h2>
<!-- keep in sync with $lib/constants/policy -->
<p class="mb-3"> <p class="mb-3">
Gift cards expire <strong>24 months after their last use</strong> (rolling expiry). Each use resets Gift cards expire <strong>24 months after their last use</strong> (rolling expiry). Each use resets
the 24-month period. the 24-month period.
@@ -147,6 +148,7 @@
<section> <section>
<h2 class="mb-3 text-base font-semibold text-gray-900">6. Refunds and Cancellation</h2> <h2 class="mb-3 text-base font-semibold text-gray-900">6. Refunds and Cancellation</h2>
<!-- keep in sync with $lib/constants/policy -->
<p class="mb-3"> <p class="mb-3">
<strong>Non-refundable except where the law requires:</strong> except for the 14-day right <strong>Non-refundable except where the law requires:</strong> except for the 14-day right
to cancel described below, and except as required by consumer law or by an express refund we 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 <strong>non-refundable</strong> &mdash; they are not >, gift cards and gift-card balances are <strong>non-refundable</strong> &mdash; they are not
redeemable for cash (unless required by law). redeemable for cash (unless required by law).
</p> </p>
<!-- keep in sync with $lib/constants/policy -->
<p class="mb-3"> <p class="mb-3">
<strong>14-day right to cancel online purchases:</strong> if you buy a gift card online, the Consumer <strong>14-day right to cancel online purchases:</strong> if you buy a gift card online, the Consumer
Contracts (Information, Cancellation and Additional Charges) Regulations 2013 give you a 14-day Contracts (Information, Cancellation and Additional Charges) Regulations 2013 give you a 14-day
@@ -407,6 +407,7 @@
<td class="px-3 py-2">6 months after the appointment, then anonymized</td> <td class="px-3 py-2">6 months after the appointment, then anonymized</td>
<td class="px-3 py-2">GDPR storage limitation</td> <td class="px-3 py-2">GDPR storage limitation</td>
</tr> </tr>
<!-- keep in sync with $lib/constants/policy -->
<tr> <tr>
<td class="px-3 py-2">Gift cards</td> <td class="px-3 py-2">Gift cards</td>
<td class="px-3 py-2"> <td class="px-3 py-2">
+27 -9
View File
@@ -3,6 +3,18 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { SUPPORT_EMAIL, BUSINESS_NAME, BUSINESS_ADDRESS, TRADER_LEGAL_NAME } from '$lib/constants/contact'; 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'); let format = $state('html');
@@ -130,9 +142,9 @@
delivery is available). delivery is available).
</li> </li>
<li> <li>
Some services require a deposit (typically 20% of the service cost; a Some services require a deposit (typically {depositPct}% of the service cost; a
protected-deposit cap of 50% may be retained on short-notice cancellation, capped at protected-deposit cap of {protectedDepositMaxPct}% may be retained on short-notice
what you actually paid). cancellation, capped at what you actually paid).
</li> </li>
</ul> </ul>
<p class="mb-2 font-medium text-gray-800">Cancellations &amp; rescheduling</p> <p class="mb-2 font-medium text-gray-800">Cancellations &amp; rescheduling</p>
@@ -145,16 +157,16 @@
</p> </p>
<ul class="mb-3 list-disc space-y-1 pl-5"> <ul class="mb-3 list-disc space-y-1 pl-5">
<li> <li>
<strong>Notice of more than 72 hours:</strong> you are entitled to a full 100% refund of all <strong>Notice of more than {FULL_REFUND_THRESHOLD_HOURS} hours:</strong> you are entitled to a full 100% refund of all
booking payments made. booking payments made.
</li> </li>
<li> <li>
<strong>Notice between 24 and 72 hours:</strong> any booking payments made up to 50% of the <strong>Notice between {PARTIAL_REFUND_THRESHOLD_HOURS} and {FULL_REFUND_THRESHOLD_HOURS} hours:</strong> any booking payments made up to {protectedDepositMaxPct}% of the
total booking value are treated as a Protected Deposit. This Protected Deposit is retained to 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.
</li> </li>
<li> <li>
<strong>Notice of less than 24 hours:</strong> all booking payments and deposits are entirely <strong>Notice of less than {NO_SHOW_THRESHOLD_HOURS} hours:</strong> all booking payments and deposits are entirely
non-refundable and will be retained. The cancellation will be logged as a missed appointment non-refundable and will be retained. The cancellation will be logged as a missed appointment
history strike. history strike.
</li> </li>
@@ -172,8 +184,8 @@
<p class="mb-2 font-medium text-gray-800">Deposits</p> <p class="mb-2 font-medium text-gray-800">Deposits</p>
<ul class="mb-3 list-disc space-y-1 pl-5"> <ul class="mb-3 list-disc space-y-1 pl-5">
<li> <li>
Deposits are retained on cancellation in line with the tiered schedule above (up to 50% of Deposits are retained on cancellation in line with the tiered schedule above (up to {protectedDepositMaxPct}% of
the booking value between 24 and 72 hours' notice; all payments retained under 24 hours). the booking value between {PARTIAL_REFUND_THRESHOLD_HOURS} and {FULL_REFUND_THRESHOLD_HOURS} hours' notice; all payments retained under {NO_SHOW_THRESHOLD_HOURS} hours).
</li> </li>
<li>Deposits are applied to your final bill.</li> <li>Deposits are applied to your final bill.</li>
<li>If we cancel, any deposit paid is refunded under the business-cancellation terms above.</li> <li>If we cancel, any deposit paid is refunded under the business-cancellation terms above.</li>
@@ -263,6 +275,7 @@
</h2> </h2>
<p class="mb-2 font-medium text-gray-800">Gift card expiry</p> <p class="mb-2 font-medium text-gray-800">Gift card expiry</p>
<ul class="mb-3 list-disc space-y-1 pl-5"> <ul class="mb-3 list-disc space-y-1 pl-5">
<!-- keep in sync with $lib/constants/policy -->
<li> <li>
Gift cards expire after the expiry period set in the salon's business settings (24 months 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). 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 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). balance, no additional VAT is charged (it has already been paid).
</p> </p>
<!-- keep in sync with $lib/constants/policy -->
<p class="mb-3"> <p class="mb-3">
<strong>Right to cancel:</strong> if you buy a gift card online, you can cancel the purchase <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 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. for the full position.
</p> </p>
<!-- keep in sync with $lib/constants/policy -->
<p class="mb-3"> <p class="mb-3">
<strong>Non-refundable except where the law requires:</strong> except for the 14-day right <strong>Non-refundable except where the law requires:</strong> except for the 14-day right
to cancel online purchases above, and except as required by consumer law or by an express 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 Purchases made on our Platform (rather than face-to-face in the salon) are
<strong>distance contracts</strong> under the Consumer Contracts (Information, Cancellation <strong>distance contracts</strong> under the Consumer Contracts (Information, Cancellation
and Additional Charges) Regulations 2013. This gives you a and Additional Charges) Regulations 2013. This gives you a
<!-- keep in sync with $lib/constants/policy -->
<strong>14-day right to cancel</strong> most online purchases, running from the day after <strong>14-day right to cancel</strong> most online purchases, running from the day after
purchase. purchase.
</p> </p>
<ul class="mb-3 list-disc space-y-1 pl-5"> <ul class="mb-3 list-disc space-y-1 pl-5">
<!-- keep in sync with $lib/constants/policy -->
<li> <li>
<strong>Gift cards bought online</strong> carry this 14-day right, refunded to the <strong>Gift cards bought online</strong> carry this 14-day right, refunded to the
original payment method &mdash; in full if unused, or the unspent balance if partly used original payment method &mdash; 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 activities), so the 14-day right does not apply to the service itself; our cancellation and
refund policy in section 3 applies instead. refund policy in section 3 applies instead.
</li> </li>
<!-- keep in sync with $lib/constants/policy -->
<li> <li>
<strong>How to exercise it:</strong> for gift cards, cancel in-app from your account <strong>How to exercise it:</strong> for gift cards, cancel in-app from your account
within 14 days (no email needed); for anything else, email {SUPPORT_EMAIL} within 14 days. within 14 days (no email needed); for anything else, email {SUPPORT_EMAIL} within 14 days.