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:
@@ -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(¬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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Generated
+66
-185
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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+$/);
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 — so if you only paid a
|
at {protectedDepositMaxPct}% 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.
|
{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> — they are not
|
>, gift cards and gift-card balances are <strong>non-refundable</strong> — 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">
|
||||||
|
|||||||
@@ -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 & rescheduling</p>
|
<p class="mb-2 font-medium text-gray-800">Cancellations & 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 — in full if unused, or the unspent balance if partly used
|
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
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user