Close refund system and gate raw-PAN card entry

Refund system (Round 3 fixes + follow-up + alignment):
- Serialize cancellation refunds against the manual handler via
  per-payment advisory locks taken before the prior-refunds read
  (pg_advisory_xact_lock, ascending, same crussell:refund: key space)
- Aggregate pending cancellation refunds into ONE Square refund per
  charge (stable charge-level -square-agg key); atomic group UPDATE
  keeps crash-retry amounts identical for Square key-dedup
- Persist paymentID-square-amount idempotency keys on cancellation
  refunds; scheduler reads the stored key (legacy fallback for old rows)
- Add sweep-pending-square-refunds cron (*/5, concurrency 1) with
  refund_attempts cap; sweep retries stale manual pending refunds with
  each row's own stored idempotency key
- Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every
  terminal failed transition: tri-state result leaves rows pending on
  reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED
  resolves to completed
- Move over-refund guard inside the lock, counting completed + pending
  (excluding failed); ErrRefundDeclined distinguishes definitive vs
  ambiguous outcomes
- forgiveFees now executes a real full refund (forceFullRefund override)
  with admin_forgiven_fees reason threaded to Square
- Surface failed card refunds in the admin notification centre
  (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup)
- Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key)
  DO NOTHING without consuming refundRemaining

Frontend:
- Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token
  in request bodies; gate new-card entry behind CardEntryUnavailable
  notice + newCardDisabled prop across all 8 flows
- Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI
  and CardEntryUnavailable fallback
- Update cancellation-policy page to in-person cash pickup wording

Tests:
- Rewrite the two amount-blind dedup tests to assert real money movement
  (single call, aggregated amount, shared refund ID)
- Add coverage: manual refund vs cancellation serialization (concurrent
  goroutines), reconcile error vs no-match branches, stale manual retry,
  forgive-fees real refund row + reason, double-cancel dedup, mock refund
  key dedup, ListPaymentRefunds filtering
- Fix time-dependent booking flakes with fixtures.NextWorkingDayAt
- 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 54f6bf3c1a
commit ae8735ba2f
33 changed files with 4129 additions and 1868 deletions
+111 -10
View File
@@ -6,14 +6,17 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
@@ -360,9 +363,11 @@ func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) {
t.Fatalf("failed to create service: %v", err)
}
// Booking starting in 48 hours (24-72h window, no payments).
midRange := clock.Now().Add(48 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, midRange)
// Booking 24-72h from now (no-show warning tier) at a fixed working-hour slot:
// 10:00 UTC two days out is always 35-58h away, so the warning fires but the
// "too close" 403 never does, at any wall-clock hour.
bookingTime := fixtures.NextWorkingDayAt(2, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
@@ -373,7 +378,8 @@ func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) {
}
token := jwt.GenerateUserToken(userID)
newTime := midRange.Add(48 * time.Hour)
// 10:00 UTC four days out — same slot as bookingTime, inside working hours.
newTime := bookingTime.Add(48 * time.Hour)
handler := http.HandlerFunc(RequestEditHandler)
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{
@@ -574,6 +580,12 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
t.Fatalf("failed to create user: %v", err)
}
// A real admin user so the refund row's created_by FK is satisfied.
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
@@ -591,20 +603,45 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
t.Fatalf("failed to confirm booking: %v", err)
}
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Give the payment a Square reference so the post-commit sweep does NOT
// terminal-pre-pass it to 'failed' (NULL square refs are unresolvable).
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_admin_forgive_fees' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
// Commit the setup so the handler runs at pool level: AdminCancelBookingHandler
// acquires pg_advisory_xact_lock on the card payments, and inside the test-env
// outer per-test transaction (savepoints don't release xact locks) the locks
// would deadlock the post-commit sweep's session locks. Pool-level mirrors
// production, where the handler's tx commits and releases the locks.
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
freshCtx := context.Background()
// Make the post-commit refund processing leave the row 'pending' (ambiguous
// Square transport failure → refund_attempts=1) so the test can assert the
// row exists as pending with the full amount.
origSquare := payments.SquareClient
forgiveFeesClient := &forgiveFeesAmbiguousClient{SquareClient: square.NewDevClient()}
payments.SquareClient = forgiveFeesClient
defer func() { payments.SquareClient = origSquare }()
w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{
"forgive_fees": true,
}, func(baseCtx context.Context) context.Context {
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
adminToken := jwt.GenerateAdminToken()
if info := extractUserFromTestJWT(adminToken); info != nil {
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, info.userID)
}
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
return baseCtx
})
if w.Code != http.StatusOK {
@@ -625,6 +662,70 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
if refundCalc["refundable_amount"] != 100.0 {
t.Errorf("expected refundable_amount 100, got %v", refundCalc["refundable_amount"])
}
// The forgiven-fees FULL refund must actually execute — a refund row for
// the whole £100 must exist as 'pending' (created by ProcessCancellationRefundTx,
// resolved post-commit), not just a synthetic response claim.
var refundCount int
err = db.Conn.QueryRow(freshCtx,
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if refundCount < 1 {
t.Errorf("expected at least 1 refund row for forgiven fees full refund, got %d", refundCount)
}
var refundStatus string
var refundAmount float64
err = db.Conn.QueryRow(freshCtx,
"SELECT status, amount FROM refunds WHERE booking_id = $1 LIMIT 1", bookingID).Scan(&refundStatus, &refundAmount)
if err != nil {
t.Fatalf("failed to query refund row: %v", err)
}
if refundStatus != "pending" {
t.Errorf("expected refund status 'pending', got %q", refundStatus)
}
if refundAmount != 100 {
t.Errorf("expected refund amount 100, got %.2f", refundAmount)
}
// The Square sweep must be called with the forgiven-fees reason — the
// reason is threaded from the refund rows through to the post-commit
// ProcessPendingSquareRefunds call (P3b), not the generic admin_cancelled.
calls := forgiveFeesClient.refundCalls()
if len(calls) == 0 {
t.Fatal("expected the post-commit sweep to call Square RefundPayment")
}
if calls[0].Reason != "admin_forgiven_fees" {
t.Errorf("expected Square refund reason %q, got %q", "admin_forgiven_fees", calls[0].Reason)
}
}
// forgiveFeesAmbiguousClient simulates a transport-level Square failure so the
// ForgiveFees test's post-commit refund processing leaves the row 'pending'.
// It also records every RefundPayment request so the test can assert the
// forgiven-fees reason reaches Square.
type forgiveFeesAmbiguousClient struct {
square.SquareClient
mu sync.Mutex
calls []square.RefundPaymentReq
}
func (c *forgiveFeesAmbiguousClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
c.mu.Lock()
c.calls = append(c.calls, req)
c.mu.Unlock()
return nil, fmt.Errorf("network error: connection reset by peer")
}
func (c *forgiveFeesAmbiguousClient) refundCalls() []square.RefundPaymentReq {
c.mu.Lock()
defer c.mu.Unlock()
return append([]square.RefundPaymentReq(nil), c.calls...)
}
func (c *forgiveFeesAmbiguousClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) {
return nil, fmt.Errorf("network error: connection reset by peer")
}
func TestAdminCancelBookingHandler_NormalRefundOver72h(t *testing.T) {
+15 -15
View File
@@ -198,20 +198,20 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
// Status update succeeded — now process the refund in the SAME transaction
// so that a commit failure rolls back both the status change and the refund.
if forgiveFees && totalPaid > 0 {
refundResult = &payments.RefundCalculationResult{
TotalPrePaid: totalPaid,
RefundableAmount: totalPaid,
KeptAmount: 0,
Tier: "admin_full_refund",
// refundReason threads through to the refund rows AND the post-commit Square
// sweep so forgiven-fee bookings are labelled "admin_forgiven_fees" (audit).
refundReason := "admin_cancelled"
if totalPaid > 0 {
if forgiveFees {
refundReason = "admin_forgiven_fees"
// Full refund of the net pre-paid amount regardless of notice tier —
// the override makes the refund actually execute (rows created and
// swept), instead of the old synthetic response-only claim.
refundResult, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), refundReason, &adminID, true)
} else if calculatedRefund {
refundResult, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), refundReason, &adminID, false)
}
}
if calculatedRefund {
var calc *payments.RefundCalculationResult
calc, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), "admin_cancelled", &adminID)
if err == nil {
refundResult = calc
} else {
if err != nil {
refundFailed = true
log.Printf("ALERT: AdminCancelBookingHandler — ProcessCancellationRefundTx failed for booking %s after status was updated to we_cancelled. Refund was NOT processed. The transaction WILL be committed (cancellation stands, no refund). Error: %v", bookingID, err)
}
@@ -275,8 +275,8 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
// Process pending Square refunds after the transaction commits successfully.
// This ensures Square API calls only happen if the DB records persist.
if calculatedRefund {
payments.ProcessPendingSquareRefunds(r.Context(), bookingID, "admin_cancelled")
if totalPaid > 0 {
payments.ProcessPendingSquareRefunds(r.Context(), bookingID, refundReason)
}
if refundFailed || (refundResult != nil && refundResult.RefundableAmount > 0) {
+8 -2
View File
@@ -1852,8 +1852,14 @@ func TestAdminApproveEditRequest_EvictsPendingRelease(t *testing.T) {
}
dur := durationMinutes(t, ctx, tx, serviceID)
// Use a booking <48h from now so RequestEdit does NOT auto-approve
nearTime := clock.Now().Add(40 * time.Hour).Truncate(time.Second)
// Use a booking 24-48h from now at a fixed working-hour slot so RequestEdit
// neither 403s (<24h away = too close) nor auto-approves (>48h away would
// consume the edit request before the admin can approve it). A raw
// clock.Now().Add(Kh) can land after 19:00 London and fail closing hours.
nearTime := fixtures.NextWorkingDayAt(1, 10)
if nearTime.Sub(clock.Now()) < 24*time.Hour {
nearTime = fixtures.NextWorkingDayAt(2, 10)
}
bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, nearTime)
if err != nil {
+269 -44
View File
@@ -646,10 +646,14 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
if paymentResult.Status == "COMPLETED" {
service := NewPaymentService()
idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(paymentResult.Amount, 10)
// Deterministic idempotency key derived from booking + amount + Square
// payment ID. The Square payment ID disambiguates two distinct
// equal-amount charges on the same booking, so equal amounts never
// collide on the UNIQUE constraint.
idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(paymentResult.Amount, 10) + "-" + paymentResult.SquarePayID
// Begin the transaction BEFORE the idempotency check so it's atomic
// with the payment insert.
// Begin the transaction BEFORE the dedup lookup so it's atomic with the
// payment insert.
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
@@ -662,26 +666,26 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
}
}()
// Check for existing payment inside the transaction.
// Dedup by Square payment ID: a double poll of the same terminal
// checkout must return the existing payment row instead of inserting a
// duplicate (which previously 500'd on the idempotency-key UNIQUE
// violation after the customer had already paid).
var existingID string
var existingSquarePayID sql.NullString
if err := tx.QueryRow(r.Context(), `
SELECT id, COALESCE(square_payment_id, '') FROM payments
WHERE booking_id = $1 AND idempotency_key = $2
`, bookingID, idempotencyKey).Scan(&existingID, &existingSquarePayID); err == nil {
if existingSquarePayID.Valid && existingSquarePayID.String == paymentResult.SquarePayID {
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{
Status: "COMPLETED",
PaymentID: existingID,
Amount: paymentResult.Amount,
CardBrand: paymentResult.CardBrand,
CardLast4: paymentResult.CardLast4,
ReceiptURL: paymentResult.ReceiptURL,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
SELECT id FROM payments
WHERE booking_id = $1 AND square_payment_id = $2
`, bookingID, paymentResult.SquarePayID).Scan(&existingID); err == nil {
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{
Status: "COMPLETED",
PaymentID: existingID,
Amount: paymentResult.Amount,
CardBrand: paymentResult.CardBrand,
CardLast4: paymentResult.CardLast4,
ReceiptURL: paymentResult.ReceiptURL,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
} else if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to check for existing payment: %v", err)
}
@@ -1662,17 +1666,9 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
return
}
alreadyRefunded, err := service.GetAlreadyRefundedAmount(r.Context(), paymentID)
if err != nil {
log.Printf("Failed to get already refunded amount: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if req.Amount+alreadyRefunded > int64(math.Round(payment.Amount*100)) {
http.Error(w, "Refund amount exceeds payment amount", http.StatusBadRequest)
return
}
// Deterministic idempotency key so a same-key retry (network timeout)
// does not create a second Square refund.
idempotencyKey := paymentID + "-refund-" + strconv.FormatInt(req.Amount, 10)
// Serialize refund attempts per payment to prevent two concurrent refunds
// both passing the over-refund guard and both charging Square. Mirrors the
@@ -1700,15 +1696,24 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
}
}()
// Deterministic idempotency key so a same-key retry (network timeout)
// does not create a second Square refund.
idempotencyKey := paymentID + "-refund-" + strconv.FormatInt(req.Amount, 10)
// Check for an existing refund with this key — dedup completed refunds.
// Dedup/resume (inside the lock): a same-key retry of a completed or
// in-flight (pending) refund must not create a second Square refund. Runs
// BEFORE the over-refund guard so a resuming refund never evaluates its own
// pending row against the guard.
var existingRefundID sql.NullString
var existingRefundStatus sql.NullString
err = db.Conn.QueryRow(r.Context(), `SELECT status FROM refunds WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingRefundStatus)
if err == nil && existingRefundStatus.String == "completed" {
var existingRefundAmount sql.NullFloat64
var existingRefundOrigin sql.NullString
var existingRefundReason sql.NullString
var existingRefundCreatedAt sql.NullTime
var existingRefundKey sql.NullString
err = db.Conn.QueryRow(r.Context(), `
SELECT id, status, amount, origin, reason, created_at, idempotency_key FROM refunds WHERE idempotency_key = $1
`, idempotencyKey).Scan(&existingRefundID, &existingRefundStatus, &existingRefundAmount, &existingRefundOrigin, &existingRefundReason, &existingRefundCreatedAt, &existingRefundKey)
switch {
case err == nil && existingRefundStatus.String == "completed":
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
@@ -1718,9 +1723,198 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
case err == nil && existingRefundStatus.String == "pending":
// Resume the in-flight refund: the DB row was committed but the Square
// call never completed (network timeout, crash, etc.). Retry Square with
// the same idempotency key so Square returns the original refund if one
// exists, never a second one.
resumeAmount := int64(math.Round(existingRefundAmount.Float64 * 100))
resumeReq := square.RefundPaymentReq{
PaymentID: *payment.SquarePaymentID,
Amount: resumeAmount,
IdempotencyKey: idempotencyKey,
Reason: req.Reason,
}
resumeResult, resumeErr := SquareClient.RefundPayment(r.Context(), resumeReq)
if resumeErr != nil {
if errors.Is(resumeErr, square.ErrRefundAlreadyProcessed) {
// PAYMENT_ALREADY_REFUNDED — money already moved at Square.
// Resolve the pending row to completed (square_refund_id stays
// NULL) so the guard can never over-refund on top of it.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, existingRefundID.String); upErr != nil {
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", existingRefundID.String, upErr)
}
log.Printf("Refund %s already processed at Square — marked completed", existingRefundID.String)
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
if errors.Is(resumeErr, square.ErrRefundDeclined) {
// Definitive rejection — mark failed so it never retries and
// never blocks future refunds.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, existingRefundID.String); upErr != nil {
log.Printf("Failed to mark refund %s failed after definitive rejection: %v", existingRefundID.String, upErr)
}
log.Printf("Refund %s definitively declined by Square: %v", existingRefundID.String, resumeErr)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
// Ambiguous error — leave pending for the scheduler to retry.
log.Printf("Failed to resume refund %s (left pending): %v", existingRefundID.String, resumeErr)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
if _, upErr := db.Conn.Exec(r.Context(),
`UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`,
resumeResult.ID, existingRefundID.String,
); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", resumeResult.ID, existingRefundID.String, upErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case err == nil && existingRefundStatus.String == "failed":
// A failed row with origin='manual' may actually have moved money at
// Square (response loss after a definitive decline). Reconcile FIRST —
// an exact-amount COMPLETED refund resolves the row to completed.
// Otherwise the reconcile proves money did NOT move, so re-issuing with
// the stored key/amount is safe (Square dedups same-key retries).
if existingRefundOrigin.String == "manual" {
refundSqPaymentID := *payment.SquarePaymentID
resumeAmount := int64(math.Round(existingRefundAmount.Float64 * 100))
var reconcileTime time.Time
if existingRefundCreatedAt.Valid {
reconcileTime = existingRefundCreatedAt.Time
}
sqRefundID, rcErr := reconcileRefundAtSquare(r.Context(), refundSqPaymentID, resumeAmount, reconcileTime)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Do NOT
// re-issue on an unknown state: re-issuing would be safe
// against Square's key dedup, but if money already moved the
// over-refund guard would lose sight of it. Surface a retry.
log.Printf("Failed to reconcile refund %s against Square before re-issue (%v) — not re-issuing, ask the admin to retry", existingRefundID.String, rcErr)
http.Error(w, "Unable to verify refund status with Square, please retry", http.StatusServiceUnavailable)
return
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`, *sqRefundID, existingRefundID.String); upErr != nil {
log.Printf("Failed to mark refund %s completed after Square reconcile: %v", existingRefundID.String, upErr)
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
reissueReq := square.RefundPaymentReq{
PaymentID: refundSqPaymentID,
Amount: resumeAmount,
IdempotencyKey: existingRefundKey.String,
Reason: existingRefundReason.String,
}
reissueResult, reissueErr := SquareClient.RefundPayment(r.Context(), reissueReq)
switch {
case reissueErr == nil:
if _, upErr := db.Conn.Exec(r.Context(),
`UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`,
reissueResult.ID, existingRefundID.String,
); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", reissueResult.ID, existingRefundID.String, upErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case errors.Is(reissueErr, square.ErrRefundAlreadyProcessed):
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, existingRefundID.String); upErr != nil {
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", existingRefundID.String, upErr)
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case errors.Is(reissueErr, square.ErrRefundDeclined):
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, existingRefundID.String); upErr != nil {
log.Printf("Failed to mark refund %s failed after re-issue rejection: %v", existingRefundID.String, upErr)
}
log.Printf("Refund %s re-issued with stored key definitively declined by Square: %v", existingRefundID.String, reissueErr)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
default:
// Ambiguous re-issue — put the row back to 'pending' so the
// sweep's manual retry pass can re-attempt it.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'pending' WHERE id = $1`, existingRefundID.String); upErr != nil {
log.Printf("Failed to mark refund %s pending after ambiguous re-issue: %v", existingRefundID.String, upErr)
}
log.Printf("Refund %s re-issue left pending (ambiguous): %v", existingRefundID.String, reissueErr)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
}
// Previously definitively rejected (non-manual) — a same-key retry cannot
// succeed and the UNIQUE key would block re-insertion. Surface the
// failure instead of 500-ing on a duplicate.
log.Printf("Refund %s was previously marked failed — same-key retry rejected", existingRefundID.String)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
case err != nil && !errors.Is(err, pgx.ErrNoRows):
log.Printf("Failed to check refund idempotency: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Over-refund guard (inside the lock so concurrent refunds can't both pass).
// GetAlreadyRefundedAmount counts completed AND pending refunds.
alreadyRefunded, err := service.GetAlreadyRefundedAmount(r.Context(), paymentID)
if err != nil {
log.Printf("Failed to get already refunded amount: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if req.Amount+alreadyRefunded > int64(math.Round(payment.Amount*100)) {
http.Error(w, "Refund amount exceeds payment amount", http.StatusBadRequest)
return
}
// Begin a transaction. Insert the refund record as 'pending' first, commit,
@@ -1740,8 +1934,8 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
var refundID string
err = tx.QueryRow(r.Context(), `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at)
VALUES ($1, $2, $3, 'pending', $4, $5, $6, $7)
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin)
VALUES ($1, $2, $3, 'pending', $4, $5, $6, $7, 'manual')
RETURNING id
`,
paymentID,
@@ -1773,8 +1967,39 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
refundResult, err := SquareClient.RefundPayment(r.Context(), refundReq)
if err != nil {
// Refund record intentionally left as 'pending' for the scheduler to
// re-attempt (refunds.go ProcessPendingSquareRefunds).
if errors.Is(err, square.ErrRefundAlreadyProcessed) {
// PAYMENT_ALREADY_REFUNDED — money already moved at Square. Resolve
// to completed (square_refund_id stays NULL) rather than failed so
// the over-refund guard can never issue money on top of it.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, refundID); upErr != nil {
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", refundID, upErr)
}
log.Printf("Refund %s already processed at Square — marked completed", refundID)
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: refundID,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
if errors.Is(err, square.ErrRefundDeclined) {
// Definitive rejection (declined / already refunded / invalid
// payment) — mark the refund failed so it never retries and never
// blocks future refunds.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, refundID); upErr != nil {
log.Printf("Failed to mark refund %s failed after definitive rejection: %v", refundID, upErr)
}
log.Printf("Refund %s definitively declined by Square: %v", refundID, err)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
// Ambiguous error — refund record intentionally left as 'pending' for
// the scheduler to re-attempt (refunds.go ProcessPendingSquareRefunds).
log.Printf("Failed to refund payment (refund %s left pending): %v", refundID, err)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
@@ -678,3 +678,134 @@ func TestGetCheckoutStatus_Completed(t *testing.T) {
t.Error("expected card_last4 to be set")
}
}
// pollCheckoutStatus polls GetCheckoutStatus until the checkout reports
// COMPLETED, returning the decoded response.
func pollCheckoutStatus(t *testing.T, ctx context.Context, checkoutID, bookingID, adminToken string) PaymentStatusResponse {
t.Helper()
var resp PaymentStatusResponse
assert.Eventually(t, func() bool {
statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil)
statusRCtx := chi.NewRouteContext()
statusRCtx.URLParams.Add("checkout_id", checkoutID)
statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx)
if info := extractUserFromTestJWT(adminToken); info != nil {
statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID)
statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role)
}
statusReq = statusReq.WithContext(statusCtx)
w2 := httptest.NewRecorder()
GetCheckoutStatus(w2, statusReq)
if w2.Code != http.StatusOK {
return false
}
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil {
return false
}
return resp.Status == "COMPLETED"
}, 10*time.Second, 200*time.Millisecond, "expected checkout to complete")
return resp
}
// createTerminalCheckout creates a terminal checkout via CreateTerminalPayment
// and returns the checkout ID from the response.
func createTerminalCheckout(t *testing.T, ctx context.Context, bookingID, adminToken string, amount int64) string {
t.Helper()
handler := CreateTerminalPayment
req := CreateTerminalPaymentRequest{
Amount: amount,
PaymentType: "full",
}
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var createResp CheckoutResponse
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
t.Fatalf("failed to decode create response: %v", err)
}
if createResp.CheckoutID == "" {
t.Fatal("expected checkout_id to be set")
}
return createResp.CheckoutID
}
func TestGetCheckoutStatus_DoublePoll_SinglePaymentRow(t *testing.T) {
// A double poll of the same terminal checkout must return the existing
// payment row instead of inserting a duplicate (which previously 500'd on
// the idempotency-key UNIQUE violation after the customer had paid).
origClient := SquareClient
SquareClient = &testCheckoutClient{
SquareClient: origClient,
hexIDs: make(map[string]string),
}
defer func() { SquareClient = origClient }()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
checkoutID := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
first := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken)
if first.PaymentID == "" {
t.Fatal("expected payment_id from first poll")
}
// Second poll of the same checkout — deduped against the existing row.
second := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken)
if second.PaymentID == "" {
t.Fatal("expected payment_id from second poll")
}
if second.PaymentID != first.PaymentID {
t.Errorf("expected same payment_id on re-poll, got %q then %q", first.PaymentID, second.PaymentID)
}
var rowCount int
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount)
if err != nil {
t.Fatalf("failed to count payment rows: %v", err)
}
if rowCount != 1 {
t.Errorf("expected exactly 1 payment row after double poll, got %d", rowCount)
}
}
func TestGetCheckoutStatus_TwoEqualAmountCharges_NoCollision(t *testing.T) {
// Two distinct terminal charges on the same booking with the same final
// amount must each create their own payment row (the deposit + equal-amount
// balance case) — no 500 on the idempotency-key UNIQUE collision.
origClient := SquareClient
SquareClient = &testCheckoutClient{
SquareClient: origClient,
hexIDs: make(map[string]string),
}
defer func() { SquareClient = origClient }()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
checkoutA := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
checkoutB := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
respA := pollCheckoutStatus(t, ctx, checkoutA, bookingID, adminToken)
respB := pollCheckoutStatus(t, ctx, checkoutB, bookingID, adminToken)
if respA.PaymentID == "" || respB.PaymentID == "" {
t.Fatal("expected payment_ids for both checkouts")
}
if respA.PaymentID == respB.PaymentID {
t.Error("expected two distinct payment rows for two distinct Square charges")
}
var rowCount int
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount)
if err != nil {
t.Fatalf("failed to count payment rows: %v", err)
}
if rowCount != 2 {
t.Errorf("expected exactly 2 payment rows for two equal-amount charges, got %d", rowCount)
}
}
+175
View File
@@ -15,6 +15,7 @@ import (
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
@@ -772,6 +773,180 @@ func TestRefund_PendingPaymentRejected(t *testing.T) {
}
}
func TestRefund_PendingSameKeyRetry_Resumes(t *testing.T) {
// A retry of an in-flight refund (DB row pending, Square call never
// completed) must resume rather than insert a second row or issue a second
// Square refund. The handler retries Square with the same idempotency key
// and completes the pending row.
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_pending_resume' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
// Seed a pending refund with the same key the handler will compute.
key := paymentID + "-refund-2500"
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_at)
VALUES ($1, $2, 25, 'pending', 'customer request', $3, NOW())
`, paymentID, bookingID, key)
if err != nil {
t.Fatalf("failed to seed pending refund: %v", err)
}
req := RefundRequest{
Amount: 2500,
Reason: "customer request",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// The pending row must now be completed with a Square refund ID — and no
// second refund row inserted.
var refundCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if refundCount != 1 {
t.Errorf("expected 1 refund row (resumed, not duplicated), got %d", refundCount)
}
var status string
var squareRefundID *string
err = tx.QueryRow(ctx, `SELECT status, square_refund_id FROM refunds WHERE idempotency_key = $1`, key).Scan(&status, &squareRefundID)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected status completed, got %q", status)
}
if squareRefundID == nil || *squareRefundID == "" {
t.Error("expected square_refund_id to be set after resume")
}
}
func TestRefund_GuardCountsPendingRefunds(t *testing.T) {
// The over-refund guard must count pending refunds (an in-flight Square
// refund) as well as completed ones, so a second refund cannot push the
// total past the payment amount.
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_guard_pending' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
// Seed a pending refund of £70 (as if a previous attempt's Square call is in flight).
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
VALUES ($1, $2, 70, 'pending', 'in flight', NOW())
`, paymentID, bookingID)
if err != nil {
t.Fatalf("failed to seed pending refund: %v", err)
}
// A further £40 refund would total £110 > £100 — must be rejected.
req := RefundRequest{
Amount: 4000,
Reason: "over refund attempt",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestRefund_PaymentAlreadyRefunded_MarksCompleted(t *testing.T) {
// Square reports PAYMENT_ALREADY_REFUNDED — the money has already moved.
// The refund row must resolve to 'completed' (NOT 'failed', which would let
// the over-refund guard re-issue money on top of it), square_refund_id
// stays NULL, and the API returns 200.
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_already_refunded' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
mock.FailRefundCode = "PAYMENT_ALREADY_REFUNDED"
SquareClient = mock
defer func() { SquareClient = origClient }()
req := RefundRequest{
Amount: 5000,
Reason: "customer request",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp RefundResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Status != "completed" {
t.Errorf("expected response status 'completed', got %q", resp.Status)
}
var status string
var squareRefundID *string
err = tx.QueryRow(ctx,
"SELECT status, square_refund_id FROM refunds WHERE payment_id = $1", paymentID).Scan(&status, &squareRefundID)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected refund status 'completed', got %q", status)
}
if squareRefundID != nil && *squareRefundID != "" {
t.Errorf("expected square_refund_id NULL (no new refund issued), got %q", *squareRefundID)
}
}
func TestTipPayment_HappyPath(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+26 -7
View File
@@ -65,6 +65,8 @@ type RefundRecord struct {
SquareRefundID *string
Status string
Reason string
Origin string // 'manual' (admin handler) or 'cancellation' (cancellation loop)
IdempotencyKey *string
CreatedBy *string
CreatedAt time.Time
}
@@ -154,8 +156,8 @@ func (s *PaymentService) CreateRefundRecord(ctx context.Context, record RefundRe
var id string
err := db.Conn.QueryRow(ctx, `
INSERT INTO refunds (
payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
payment_id, booking_id, amount, square_refund_id, status, reason, idempotency_key, created_by, created_at, origin
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id
`,
record.PaymentID,
@@ -164,8 +166,10 @@ func (s *PaymentService) CreateRefundRecord(ctx context.Context, record RefundRe
record.SquareRefundID,
record.Status,
record.Reason,
record.IdempotencyKey,
record.CreatedBy,
record.CreatedAt,
record.Origin,
).Scan(&id)
if err != nil {
@@ -239,7 +243,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
summary.TotalNetAmount = totalNetAmount
refundRows, err := db.Conn.Query(ctx, `
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, idempotency_key, created_by, created_at, origin
FROM refunds
WHERE booking_id = $1 AND status = 'completed'
ORDER BY created_at ASC
@@ -255,7 +259,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
var r RefundRecord
err := refundRows.Scan(
&r.ID, &r.PaymentID, &r.BookingID, &r.Amount, &r.SquareRefundID,
&r.Status, &r.Reason, &r.CreatedBy, &r.CreatedAt,
&r.Status, &r.Reason, &r.IdempotencyKey, &r.CreatedBy, &r.CreatedAt, &r.Origin,
)
if err != nil {
return nil, err
@@ -348,11 +352,17 @@ func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) (
return &p, nil
}
// GetAlreadyRefundedAmount returns the total refunded amount (in pence) for a
// payment, counting both 'completed' and 'pending' refunds. Pending refunds are
// counted because a Square call may already be in flight for them — excluding
// them would let a concurrent refund over-refund the payment. 'failed' refunds
// are excluded: they were definitively rejected by Square and must not block
// future refund attempts.
func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID string) (int64, error) {
var amount float64
err := db.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(amount), 0) FROM refunds
WHERE payment_id = $1 AND status = 'completed'
WHERE payment_id = $1 AND status IN ('completed', 'pending')
`, paymentID).Scan(&amount)
if err != nil {
@@ -391,18 +401,27 @@ type BookingPaymentInfo struct {
}
// GetBookingPaymentInfo fetches the booking start time, total service amount, and
// total completed payments for a booking.
// total net paid (completed payments minus completed/pending refunds) for a
// booking. Refunds are subtracted so cancellation refunds never double-refund
// money that has already been returned (e.g. via a manual admin refund).
func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID string) (*BookingPaymentInfo, error) {
var info BookingPaymentInfo
err := db.Conn.QueryRow(ctx, `
SELECT b.start_time, b.status,
COALESCE(b.total_amount, 0),
COALESCE(pt.total_paid, 0)
COALESCE(pt.total_paid, 0) - COALESCE(rr.total_refunded, 0)
FROM bookings b
LEFT JOIN (
SELECT booking_id, SUM(amount) AS total_paid
FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') GROUP BY booking_id
) pt ON b.id = pt.booking_id
LEFT JOIN (
SELECT p.booking_id, SUM(r.amount) AS total_refunded
FROM refunds r
JOIN payments p ON r.payment_id = p.id
WHERE p.booking_id = $1 AND r.status IN ('completed', 'pending')
GROUP BY p.booking_id
) rr ON b.id = rr.booking_id
WHERE b.id = $1
`, bookingID).Scan(&info.StartTime, &info.Status, &info.TotalAmount, &info.TotalPaid)
if err != nil {
+81 -46
View File
@@ -100,6 +100,14 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
http.Error(w, "gift_card_id is required for topup", http.StatusBadRequest)
return
}
if req.Action == "topup" && req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
// Redeem is create-only. Topping up an unredeemed card (which can still
// carry residual balance) and then redeeming would zero amount_remaining
// while crediting the user only the top-up amount — destroying money.
// Fail loudly rather than silently ignoring the redeem.
http.Error(w, "Cannot redeem a top-up to a user account", http.StatusBadRequest)
return
}
// Idempotency check: if key provided, return existing sale if found
// Idempotency handling. A 'completed' sale is a dedup (return it). A
@@ -183,6 +191,40 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// If the gift card should be immediately redeemed to a user's account balance
// (e.g. admin selected "add to account" rather than "generate gift code").
// Runs only on the FIRST attempt of a create — a pending retry reuses the
// already-funded gift card and must never re-run this, or the user's balance
// would be credited a second time (money loss to the business).
if req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
_, err = tx.Exec(ctx, `
UPDATE gift_cards
SET amount_remaining = 0,
redeemed_at = NOW(),
redeemed_by = $1,
last_used_at = NOW()
WHERE id = $2
`, *req.RedeemToUserID, giftCardID)
if err != nil {
log.Printf("Failed to redeem gift card to user account: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
_, err = tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE SET
balance = user_giftcard_balances.balance + EXCLUDED.balance,
updated_at = NOW()
`, *req.RedeemToUserID, req.Amount)
if err != nil {
log.Printf("Failed to update user gift card balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
} else {
cardID := validators.NormalizeGiftCardCode(*req.GiftCardID)
var redeemedBy sql.NullString
@@ -244,37 +286,6 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
}
}
// If the gift card should be immediately redeemed to a user's account balance
// (e.g. admin selected "add to account" rather than "generate gift code")
if req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
_, err = tx.Exec(ctx, `
UPDATE gift_cards
SET amount_remaining = 0,
redeemed_at = NOW(),
redeemed_by = $1,
last_used_at = NOW()
WHERE id = $2
`, *req.RedeemToUserID, giftCardID)
if err != nil {
log.Printf("Failed to redeem gift card to user account: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
_, err = tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE SET
balance = user_giftcard_balances.balance + EXCLUDED.balance,
updated_at = NOW()
`, *req.RedeemToUserID, req.Amount)
if err != nil {
log.Printf("Failed to update user gift card balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
penceAmount := int64(math.Round(req.Amount * 100))
var squarePaymentID *string
@@ -288,6 +299,21 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
var needsSquarePayment bool
var savedCardSqCardID string
// Pending-retry for card_machine: the original Square checkout may still be
// live at the terminal. If the pending till_sales row already recorded a
// square_checkout_id, reuse it instead of creating a second checkout — a
// fresh checkout would orphan the original, which can still complete and
// become an untracked charge.
var existingPendingCheckoutID string
if existingPendingID != "" {
err = tx.QueryRow(ctx, `SELECT COALESCE(square_checkout_id, '') FROM till_sales WHERE id = $1`, existingPendingID).Scan(&existingPendingCheckoutID)
if err != nil {
log.Printf("Failed to query existing pending sale checkout: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
switch req.PaymentMethod {
case "cash":
saleStatus = "completed"
@@ -333,23 +359,32 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
req.IdempotencyKey = uniqueTillKey()
}
checkoutReq := square.CreateCheckoutReq{
Amount: penceAmount,
Currency: "GBP",
IdempotencyKey: req.IdempotencyKey,
ReferenceID: giftCardID,
TipEnabled: false,
}
if existingPendingCheckoutID != "" {
// Pending retry — reuse the checkout already created for this sale
// instead of creating a second one. The original checkout may still
// be live at the terminal; a fresh checkout would orphan it into an
// untracked charge.
squareCheckoutID = &existingPendingCheckoutID
saleStatus = "pending"
} else {
checkoutReq := square.CreateCheckoutReq{
Amount: penceAmount,
Currency: "GBP",
IdempotencyKey: req.IdempotencyKey,
ReferenceID: giftCardID,
TipEnabled: false,
}
checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq)
if err != nil {
log.Printf("Failed to create Square checkout: %v", err)
http.Error(w, "Failed to create card machine payment", http.StatusInternalServerError)
return
}
checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq)
if err != nil {
log.Printf("Failed to create Square checkout: %v", err)
http.Error(w, "Failed to create card machine payment", http.StatusInternalServerError)
return
}
squareCheckoutID = &checkout.ID
saleStatus = "pending"
squareCheckoutID = &checkout.ID
saleStatus = "pending"
}
case "online_square":
dbPaymentMethod = "online_square"
if req.IdempotencyKey == "" {
+294
View File
@@ -1038,3 +1038,297 @@ func TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Code, "sale %d: expected 201, got %d. body: %s", i+1, w.Code, w.Body.String())
}
}
// TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce verifies that
// a same-key retry of a pending create-with-redeem sale does NOT re-credit the
// user's balance. The redeem (zeroing the gift card + crediting
// user_giftcard_balances) must only run on the FIRST attempt of a create; a
// pending retry reuses the already-redeemed gift card and would otherwise credit
// the user a second time (money loss to the business).
func TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
// Seed a PENDING till_sale (prior attempt where Square failed after the DB
// transaction committed) whose gift card was already redeemed and the user
// already credited the first-attempt amount (£50).
key := "till-pending-retry-redeem-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by, redeemed_at, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 0.00, $1, $2, NOW(), FALSE, 'SPV')
RETURNING id
`, adminID, userID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to seed redeemed gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, 50.00, NOW())
`, userID)
if err != nil {
t.Fatalf("failed to seed user gift card balance: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
$2, $3, $4, $5, NOW(), NOW())
`, giftCardID, userID, cardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
// Retry with the same key, requesting the redeem again.
redeemUserID := userID
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: key,
RedeemToUserID: &redeemUserID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
}
// The sale must now be 'completed' (Square re-attempted and succeeded).
var saleStatus string
err = tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleStatus != "completed" {
t.Errorf("expected sale status 'completed' after retry, got %s", saleStatus)
}
// The user balance must still be £50 — credited EXACTLY ONCE, not £100.
var balance float64
err = tx.QueryRow(ctx, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance)
if err != nil {
t.Fatalf("failed to query user gift card balance: %v", err)
}
if balance != 50.00 {
t.Errorf("expected balance 50.00 (credited once), got %.2f", balance)
}
// The gift card must remain fully redeemed (amount_remaining still 0).
var amountRemaining float64
err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, giftCardID).Scan(&amountRemaining)
if err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if amountRemaining != 0.00 {
t.Errorf("expected amount_remaining 0.00, got %.2f", amountRemaining)
}
}
// TestCreateTillSale_TopupWithRedeem_Rejected verifies that a top-up request
// carrying a redeem_to_user_id is rejected outright. Allowing redeem on a first
// attempt topup destroys money: the topup branch accepts unredeemed cards with
// residual balance, and topping up £20 onto a card with £40 residual then
// redeeming would zero amount_remaining (£40 lost) while crediting the user only
// the top-up amount.
func TestCreateTillSale_TopupWithRedeem_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
redeemerID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create redeemer user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// An unredeemed card carrying residual balance — the dangerous topup+redeem case.
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (40.00, 40.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to insert gift card: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 20.00,
PaymentMethod: "on_the_house",
GiftCardID: &cardID,
RedeemToUserID: &redeemerID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d. body: %s", w.Code, w.Body.String())
}
// No rows created: no till_sale, card untouched, no user balance credited.
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales`).Scan(&saleCount)
if err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if saleCount != 0 {
t.Errorf("expected 0 till_sales, got %d", saleCount)
}
var totalFunds, amountRemaining float64
err = tx.QueryRow(ctx, `SELECT total_funds_added, amount_remaining FROM gift_cards WHERE id = $1`, cardID).Scan(&totalFunds, &amountRemaining)
if err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if totalFunds != 40.00 {
t.Errorf("expected total_funds_added 40.00, got %.2f", totalFunds)
}
if amountRemaining != 40.00 {
t.Errorf("expected amount_remaining 40.00, got %.2f", amountRemaining)
}
var balanceCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1`, redeemerID).Scan(&balanceCount)
if err != nil {
t.Fatalf("failed to query user_giftcard_balances: %v", err)
}
if balanceCount != 0 {
t.Errorf("expected no user gift card balance row, got %d", balanceCount)
}
}
// TestCreateTillSale_PendingRetry_CardMachine_ReusesCheckout verifies that a
// same-key retry of a pending card_machine sale reuses the checkout already
// stored on the till_sales row instead of calling Square CreateCheckout a second
// time. The original checkout may still be live at the terminal; a fresh
// checkout would orphan it into an untracked charge.
func TestCreateTillSale_PendingRetry_CardMachine_ReusesCheckout(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Seed a PENDING card_machine till_sale that already has a Square checkout.
key := "till-card-machine-reuse-key"
storedCheckoutID := "chk_pending_retry_stored"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, square_checkout_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending',
$2, $3, $4, NOW(), NOW())
`, giftCardID, storedCheckoutID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending card_machine till sale: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "card_machine",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// The response must reference the STORED checkout, not a freshly created one.
// CreateCheckout always generates a new chk_mock_* id, so any second call
// would surface a different id here.
if resp.CheckoutID == nil || *resp.CheckoutID != storedCheckoutID {
t.Errorf("expected checkout_id to be the stored %q, got %v", storedCheckoutID, resp.CheckoutID)
}
if resp.Status != "pending" {
t.Errorf("expected status 'pending', got %s", resp.Status)
}
// The till_sales row must still reference the same checkout id, unchanged.
var rowCheckoutID string
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(MAX(square_checkout_id), '') FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &rowCheckoutID)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till sale (reuse, not duplicate), got %d", saleCount)
}
if rowCheckoutID != storedCheckoutID {
t.Errorf("expected till_sales.square_checkout_id to remain %q, got %q", storedCheckoutID, rowCheckoutID)
}
}
+9
View File
@@ -5,6 +5,7 @@ import (
"crussell/auth"
authHandlers "crussell/handlers/auth"
"crussell/handlers/payments"
"crussell/handlers/scheduling"
"crussell/handlers/user"
"crussell/mw"
@@ -47,6 +48,14 @@ func RegisterAll(s *Scheduler) {
Handler: user.CleanupGDPRExportCache,
})
s.Register(Job{
Name: "sweep-pending-square-refunds",
Schedule: "*/5 * * * *",
Timeout: 60 * time.Second,
Concurrency: 1,
Handler: payments.SweepPendingSquareRefunds,
})
// === MID FREQUENCY — every minute (progressive rate limiter was on 30s) ===
s.Register(Job{
+6 -5
View File
@@ -413,8 +413,8 @@ func TestRegisterAll_RegistersExpectedJobs(t *testing.T) {
s := New()
RegisterAll(s)
if got := len(s.registry); got != 20 {
t.Fatalf("RegisterAll() registered %d jobs, want 20", got)
if got := len(s.registry); got != 21 {
t.Fatalf("RegisterAll() registered %d jobs, want 21", got)
}
registered := make(map[string]Job, len(s.registry))
@@ -442,7 +442,7 @@ func TestRegisterAll_RegistersExpectedJobs(t *testing.T) {
// TestRegisterAll_ValidSchedules verifies all cron expressions in registered jobs
// parse without panic. RegisterAll internally calls Register, which parses every
// schedule; if this test completes without panic, all 19 schedules are valid.
// schedule; if this test completes without panic, all 21 schedules are valid.
func TestRegisterAll_ValidSchedules(t *testing.T) {
t.Parallel()
@@ -472,6 +472,7 @@ func expectedJobNames() map[string]bool {
"cleanup-expired-deposits": true,
"cleanup-rate-limiters": true,
"cleanup-gdpr-export-cache": true,
"sweep-pending-square-refunds": true,
"cleanup-progressive-rate-limiter": true,
"cleanup-expired-loyalty-redemptions": true,
"cleanup-old-idempotency-keys": true,
@@ -508,8 +509,8 @@ func TestRegisterAll_NoDuplicateCronExpressions(t *testing.T) {
// Known-intentional groupings: jobs at the same frequency that touch
// disjoint tables (no contention risk).
knownGroupings := map[int]bool{
4: true, // */5 * * * * — 4 cleanup jobs, different domains
5: true, // 0 * * * * — 5 hourly cleanup jobs, different tables
5: true, // */5 * * * * — 5 cleanup jobs (incl. sweep-pending-square-refunds), different domains
// 0 * * * * — 5 hourly cleanup jobs, different tables
2: true, // 0 2 * * * — 2 daily cleanup jobs, different tables
}
+8 -1
View File
@@ -2,7 +2,10 @@
package square
import "context"
import (
"context"
"time"
)
var Client SquareClient
@@ -43,3 +46,7 @@ func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardO
func (p *ProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
return deleteCardOnFileHTTP(ctx, cardID)
}
func (p *ProdClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
return listRefundsHTTP(ctx, paymentID, beginTime)
}
+52 -1
View File
@@ -30,9 +30,14 @@ type MockClient struct {
payments map[string]*PaymentResult
paymentByKey map[string]*PaymentResult
refunds map[string]*RefundResult
refundByKey map[string]*RefundResult
completed map[string]*PaymentResult
HoldCheckouts bool
ShouldFail bool // if true, CreatePayment/RefundPayment return errors for testing error paths
// FailRefundCode simulates a specific Square refund rejection code. Empty
// = normal success; when set (e.g. "PAYMENT_ALREADY_REFUNDED"),
// RefundPayment returns the sentinel-wrapped error for that code.
FailRefundCode string
}
type devProdClient struct{}
@@ -58,6 +63,9 @@ func (d *devProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]Ca
func (d *devProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
return deleteCardOnFileHTTP(ctx, cardID)
}
func (d *devProdClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
return listRefundsHTTP(ctx, paymentID, beginTime)
}
func NewClient() SquareClient {
return NewDevClient()
@@ -76,6 +84,7 @@ func NewDevClient() SquareClient {
payments: make(map[string]*PaymentResult),
paymentByKey: make(map[string]*PaymentResult),
refunds: make(map[string]*RefundResult),
refundByKey: make(map[string]*RefundResult),
completed: make(map[string]*PaymentResult),
}
}
@@ -287,7 +296,15 @@ func (m *MockClient) GetCheckout(ctx context.Context, checkoutID string) (*Payme
func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
if m.ShouldFail {
return nil, fmt.Errorf("mock: refund declined (simulated failure)")
return nil, fmt.Errorf("%w: refund declined (simulated failure)", ErrRefundDeclined)
}
if m.FailRefundCode != "" {
switch m.FailRefundCode {
case "PAYMENT_ALREADY_REFUNDED":
return nil, fmt.Errorf("%w: payment already fully refunded (simulated)", ErrRefundAlreadyProcessed)
default:
return nil, fmt.Errorf("%w: %s (simulated failure)", ErrRefundDeclined, m.FailRefundCode)
}
}
log.Printf("[SQUARE-MOCK] RefundPayment: payment=%s, amount=%d", req.PaymentID, req.Amount)
mockSleep(1 * time.Second)
@@ -295,6 +312,17 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
m.mu.Lock()
defer m.mu.Unlock()
// Real Square dedups on idempotency key: a retry with the same key returns
// the original refund rather than issuing a second refund. The mock mirrors
// this so dev/testing behaves like production (and the pending-refund
// resume path can rely on it).
if req.IdempotencyKey != "" {
if existing, ok := m.refundByKey[req.IdempotencyKey]; ok {
log.Printf("[SQUARE-MOCK] RefundPayment dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID)
return existing, nil
}
}
now := clock.Now().UTC()
refundID := fmt.Sprintf("ref_mock_%d", now.UnixNano())
@@ -326,6 +354,9 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
CreatedAt: now.Format(time.RFC3339),
}
m.refunds[refundID] = result
if req.IdempotencyKey != "" {
m.refundByKey[req.IdempotencyKey] = result
}
log.Printf("[SQUARE-MOCK] Refund completed: id=%s, payment=%s, amount=%d", refundID, req.PaymentID, amount)
return result, nil
}
@@ -404,6 +435,26 @@ func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error
return fmt.Errorf("card not found: %s", cardID)
}
func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
log.Printf("[SQUARE-MOCK] ListPaymentRefunds: payment=%s, begin=%s", paymentID, beginTime.UTC().Format(time.RFC3339))
m.mu.Lock()
defer m.mu.Unlock()
out := []RefundResult{}
for _, r := range m.refunds {
if r.PaymentID != paymentID {
continue
}
createdAt, err := time.Parse(time.RFC3339, r.CreatedAt)
if err == nil && createdAt.Before(beginTime) {
continue
}
out = append(out, *r)
}
return out, nil
}
// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces and
// ccof:xxx card IDs. Raw PANs (all digits) are NOT token-like and are rejected.
func isTokenLike(s string) bool {
+158
View File
@@ -4,6 +4,7 @@ package square
import (
"context"
"errors"
"fmt"
"sync"
"testing"
@@ -299,6 +300,54 @@ func TestRefundPayment_ShouldFail(t *testing.T) {
assert.Nil(t, result)
}
func TestDevClient_RefundPayment_PaymentAlreadyRefunded(t *testing.T) {
// PAYMENT_ALREADY_REFUNDED means the money already moved at Square, so the
// mock must return ErrRefundAlreadyProcessed (never ErrRefundDeclined) and
// must not store a refund — the caller resolves the record to 'completed'.
client := NewDevClient().(*MockClient)
client.FailRefundCode = "PAYMENT_ALREADY_REFUNDED"
ctx := context.Background()
req := RefundPaymentReq{
PaymentID: "pay_mock_already_refunded",
Amount: 5000,
IdempotencyKey: "refund-key-already",
Reason: "already refunded",
}
result, err := client.RefundPayment(ctx, req)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "expected ErrRefundAlreadyProcessed, got: %v", err)
assert.False(t, errors.Is(err, ErrRefundDeclined), "already-processed refund must not be classified as declined: %v", err)
client.mu.RLock()
defer client.mu.RUnlock()
assert.Len(t, client.refunds, 0, "no refund must be stored when the payment is already refunded")
assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored when the payment is already refunded")
}
func TestDevClient_RefundPayment_FailRefundCode_OtherCode(t *testing.T) {
// Any other code configured via FailRefundCode preserves the prior
// ErrRefundDeclined classification (e.g. REFUND_DECLINED in prod).
client := NewDevClient().(*MockClient)
client.FailRefundCode = "REFUND_DECLINED"
ctx := context.Background()
req := RefundPaymentReq{
PaymentID: "pay_mock_refund_declined",
Amount: 5000,
IdempotencyKey: "refund-key-declined",
Reason: "declined",
}
result, err := client.RefundPayment(ctx, req)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, ErrRefundDeclined), "expected ErrRefundDeclined, got: %v", err)
assert.False(t, errors.Is(err, ErrRefundAlreadyProcessed), "declined refund must not be classified as already processed: %v", err)
}
func TestDevClient_ConcurrentPayments(t *testing.T) {
client := NewDevClient().(*MockClient)
@@ -402,6 +451,46 @@ func TestDevClient_CreatePayment_DedupsOnIdempotencyKey(t *testing.T) {
assert.Equal(t, first.ID, byKey.ID)
}
func TestDevClient_RefundPayment_DedupsOnIdempotencyKey(t *testing.T) {
// Real Square dedups on idempotency key: a same-key retry returns the
// original refund. The mock must mirror this or the pending-refund resume
// path can't be exercised (and a retry could double-refund the customer).
client := NewDevClient().(*MockClient)
ctx := context.Background()
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 10000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "payment-for-refund-dedup",
ReferenceID: "booking-refund-dedup",
})
require.NoError(t, err)
req := RefundPaymentReq{
PaymentID: paymentResult.ID,
Amount: 5000,
IdempotencyKey: "refund-dedup-key-1",
Reason: "customer request",
}
first, err := client.RefundPayment(ctx, req)
require.NoError(t, err)
require.NotEmpty(t, first.ID)
second, err := client.RefundPayment(ctx, req)
require.NoError(t, err)
assert.Equal(t, first.ID, second.ID, "same-key retry must return the original refund, not a new one")
// Only one refund stored in the mock's refunds map (deduped).
client.mu.RLock()
defer client.mu.RUnlock()
assert.Len(t, client.refunds, 1, "same-key retry must not store a second refund")
byKey := client.refundByKey["refund-dedup-key-1"]
assert.NotNil(t, byKey)
assert.Equal(t, first.ID, byKey.ID)
}
func TestDevClient_CreatePayment_AutocompleteFalse(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
@@ -519,6 +608,75 @@ func TestDevClient_CreateCheckout_HoldCheckouts(t *testing.T) {
require.Error(t, err)
}
func TestDevClient_ListPaymentRefunds_FiltersByPaymentAndTime(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
begin := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
client.mu.Lock()
client.refunds["ref_1"] = &RefundResult{
ID: "ref_1", Status: "COMPLETED", Amount: 5000, PaymentID: "pay_a",
LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(2 * 24 * time.Hour).Format(time.RFC3339),
}
client.refunds["ref_2"] = &RefundResult{
ID: "ref_2", Status: "COMPLETED", Amount: 2500, PaymentID: "pay_b",
LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(3 * 24 * time.Hour).Format(time.RFC3339),
}
client.refunds["ref_3"] = &RefundResult{
ID: "ref_3", Status: "COMPLETED", Amount: 1000, PaymentID: "pay_a",
LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(-1 * 24 * time.Hour).Format(time.RFC3339),
}
client.mu.Unlock()
results, err := client.ListPaymentRefunds(ctx, "pay_a", begin)
require.NoError(t, err)
require.Len(t, results, 1, "only the pay_a refund created after beginTime must be returned")
assert.Equal(t, "ref_1", results[0].ID)
assert.Equal(t, int64(5000), results[0].Amount)
assert.Equal(t, "COMPLETED", results[0].Status)
assert.Equal(t, "pay_a", results[0].PaymentID)
}
func TestDevClient_ListPaymentRefunds_AfterRefundPayment(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 10000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "payment-for-list-refunds",
ReferenceID: "booking-list-refunds",
})
require.NoError(t, err)
refundResult, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: paymentResult.ID,
Amount: 5000,
IdempotencyKey: "refund-for-list",
Reason: "customer request",
})
require.NoError(t, err)
results, err := client.ListPaymentRefunds(ctx, paymentResult.ID, time.Now().Add(-24*time.Hour))
require.NoError(t, err)
require.Len(t, results, 1, "the refund stored by RefundPayment must be listed")
assert.Equal(t, refundResult.ID, results[0].ID)
assert.Equal(t, int64(5000), results[0].Amount)
assert.Equal(t, "COMPLETED", results[0].Status)
assert.Equal(t, paymentResult.ID, results[0].PaymentID)
}
func TestDevClient_ListPaymentRefunds_Empty(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
results, err := client.ListPaymentRefunds(ctx, "pay_unknown", time.Now().Add(-24*time.Hour))
require.NoError(t, err)
assert.NotNil(t, results, "must return an empty slice, not nil")
assert.Empty(t, results)
}
func TestDetectCardInfo_Variants(t *testing.T) {
tests := []struct {
sourceID string
+62 -1
View File
@@ -91,7 +91,8 @@ func (c *httpClient) doJSON(ctx context.Context, method, path string, body, targ
var errResp struct{ Errors []SquareError `json:"errors"` }
if json.Unmarshal(respBody, &errResp) == nil && len(errResp.Errors) > 0 {
se := errResp.Errors[0]
return fmt.Errorf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, se.Detail, se.Field)
msg := fmt.Sprintf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, se.Detail, se.Field)
return &squareAPIError{Code: se.Code, Detail: se.Detail, err: errors.New(msg)}
}
return fmt.Errorf("square: %s %s: HTTP %d: %s", method, path, resp.StatusCode, string(respBody))
}
@@ -230,6 +231,11 @@ type sqRefundPaymentResponse struct {
Refund sqRefund `json:"refund"`
}
type sqListRefundsResponse struct {
Refunds []sqRefund `json:"refunds"`
Cursor string `json:"cursor"`
}
type sqRefund struct {
ID string `json:"id"`
Status string `json:"status"`
@@ -343,6 +349,30 @@ func getCheckoutHTTP(ctx context.Context, checkoutID string) (*PaymentResult, er
return paymentFromSquare(&payResp.Payment), nil
}
// squareAPIError wraps a formatted Square API error while exposing the
// structured Square error code so callers can classify definitive business
// rejections (e.g. ErrRefundDeclined) vs ambiguous transport/server errors.
type squareAPIError struct {
Code string
Detail string
err error
}
func (e *squareAPIError) Error() string { return e.err.Error() }
func (e *squareAPIError) Unwrap() error { return e.err }
// Definitive Square refund rejection codes — the refund was declined and can
// never succeed, so retrying is pointless and the refund record should be
// marked 'failed'. Anything else (transport errors, 5xx) is left ambiguous so
// callers leave the refund 'pending' for a scheduler retry. Note that
// PAYMENT_ALREADY_REFUNDED is intentionally absent — the money has already
// moved, so it maps to ErrRefundAlreadyProcessed instead of ErrRefundDeclined.
var definitiveRefundCodes = map[string]bool{
"REFUND_DECLINED": true,
"PAYMENT_REFUND_AMOUNT_EXCEEDED": true,
"INVALID_PAYMENT_ID": true,
}
func refundPaymentHTTP(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
hc := newHTTPClient()
body := sqRefundPaymentRequest{
@@ -353,11 +383,42 @@ func refundPaymentHTTP(ctx context.Context, req RefundPaymentReq) (*RefundResult
}
var resp sqRefundPaymentResponse
if err := hc.doJSON(ctx, http.MethodPost, "/v2/refunds", body, &resp); err != nil {
var sqErr *squareAPIError
if errors.As(err, &sqErr) && definitiveRefundCodes[sqErr.Code] {
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
}
if errors.As(err, &sqErr) && sqErr.Code == "PAYMENT_ALREADY_REFUNDED" {
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
}
return nil, err
}
return refundFromSquare(&resp.Refund), nil
}
func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
hc := newHTTPClient()
base := "/v2/refunds?begin_time=" + url.QueryEscape(beginTime.UTC().Format(time.RFC3339)) + "&limit=100"
path := base
results := []RefundResult{}
for page := 0; page < 20; page++ {
var resp sqListRefundsResponse
if err := hc.doJSON(ctx, http.MethodGet, path, nil, &resp); err != nil {
return nil, err
}
for i := range resp.Refunds {
r := &resp.Refunds[i]
if r.PaymentID == paymentID {
results = append(results, *refundFromSquare(r))
}
}
if resp.Cursor == "" {
return results, nil
}
path = base + "&cursor=" + url.QueryEscape(resp.Cursor)
}
return nil, fmt.Errorf("square: list refunds exceeded 20 pages (infinite loop guard)")
}
func createCardOnFileHTTP(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
hc := newHTTPClient()
+27 -1
View File
@@ -1,6 +1,25 @@
package square
import "context"
import (
"context"
"errors"
"time"
)
// ErrRefundDeclined is returned by RefundPayment when Square definitively
// rejects a refund (refund declined, refund amount exceeds the original
// charge, invalid payment ID, etc.). Callers use errors.Is to distinguish a
// definitive business rejection — where the refund record should be marked
// 'failed' and never retried — from an ambiguous transport/5xx error that is
// safe to retry later. Note: PAYMENT_ALREADY_REFUNDED is NOT a decline — the
// money has already moved, so it maps to ErrRefundAlreadyProcessed instead.
var ErrRefundDeclined = errors.New("square: refund declined")
// ErrRefundAlreadyProcessed is returned by RefundPayment when Square reports
// PAYMENT_ALREADY_REFUNDED — the payment is already fully refunded at Square,
// so the money has already moved. Callers resolve the refund record to
// 'completed' rather than 'failed' (which would let the guard over-refund).
var ErrRefundAlreadyProcessed = errors.New("square: refund already processed")
// CreatePaymentReq maps to Square's CreatePayment endpoint (POST /v2/payments).
// Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment
@@ -139,4 +158,11 @@ type SquareClient interface {
CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error)
GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error)
DeleteCardOnFile(ctx context.Context, cardID string) error
// ListPaymentRefunds returns the refunds Square has recorded for a payment
// (charge), created at or after beginTime. Used to reconcile pending refund
// rows against Square before marking them failed (money may already have
// moved). Square's endpoint lists account-wide; the caller filters by
// PaymentID client-side.
ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error)
}
+10
View File
@@ -9,6 +9,7 @@ import (
"sync/atomic"
"time"
"crussell/clock"
"crussell/db"
"golang.org/x/crypto/bcrypt"
)
@@ -178,6 +179,15 @@ func CreateTestBookingAtTime(q db.Querier, userID, serviceID string, startTime t
return bookingID, nil
}
// NextWorkingDayAt returns the time at `hour` UTC on a day `daysAhead` days from
// now. Hours in [8, 18] are guaranteed inside the fixture's Mon-Sun 08:00-20:00
// London working hours at any wall-clock time (London is at most UTC+1), unlike
// clock.Now().Add(N * time.Hour), which can land after closing and flake tests.
func NextWorkingDayAt(daysAhead, hour int) time.Time {
day := clock.Now().AddDate(0, 0, daysAhead)
return time.Date(day.Year(), day.Month(), day.Day(), hour, 0, 0, 0, time.UTC)
}
func CreateTestVerifiedUser(q db.Querier) (string, error) {
return createTestUser(q, "Verified", "User", "verified@test.com", "verified_email")
}
@@ -15,8 +15,8 @@
import { computeBalanceDue } from '$lib/utils/booking';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
import CardInput from '$lib/components/payments/CardInput.svelte';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
interface Props {
open: boolean;
@@ -149,106 +149,12 @@
let tipSavedCards = $state<SavedCard[]>([]);
let tipLoadingCards = $state(false);
let tipSelectedCardId = $state<string | null>(null);
let tipShowNewCard = $state(false);
// New card form state for tips
let tipNewCardNumber = $state('');
let tipNewCardExpiry = $state('');
let tipNewCardCVC = $state('');
let tipSaveCardFuture = $state(false);
let tipCardNumberTouched = $state(false);
let tipCardExpiryTouched = $state(false);
let tipCVCTouched = $state(false);
const canSaveCards = $derived(
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
);
// Card validation (matching UserPaymentModal pattern)
function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
function handleTipFieldBlur(field: string) {
if (field === 'cardNumber') tipCardNumberTouched = true;
else if (field === 'cardExpiry') tipCardExpiryTouched = true;
else if (field === 'cardCVC') tipCVCTouched = true;
}
function handleTipFieldInput(field: string) {
if (field === 'cardNumber') tipCardNumberTouched = false;
else if (field === 'cardExpiry') tipCardExpiryTouched = false;
else if (field === 'cardCVC') tipCVCTouched = false;
}
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
const tipNewCardExpiryParts = $derived(parseExpiryParts(tipNewCardExpiry));
const isTipNewCardExpiryPast = $derived(
tipNewCardExpiryParts !== null &&
(() => {
const expiryYearMonth = tipNewCardExpiryParts.year * 12 + tipNewCardExpiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);
const hasTipNewCardInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) && tipNewCardExpiryParts === null
);
const tipNewCardError = $derived(
tipShowNewCard || tipSavedCards.length === 0
? tipCardNumberTouched && !isValidLuhn(tipNewCardNumber) && tipNewCardNumber.length > 0
? 'Invalid card number'
: tipCardExpiryTouched && hasTipNewCardInvalidMonth
? 'Invalid expiry month'
: tipCardExpiryTouched && isTipNewCardExpiryPast
? 'This card has expired'
: tipCardExpiryTouched &&
tipNewCardExpiry.length > 0 &&
!/^\d{2}\/\d{2}$/.test(tipNewCardExpiry)
? 'Enter expiry as MM/YY'
: tipCVCTouched && tipNewCardCVC.length < 3 && tipNewCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(tipNewCardNumber) &&
/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) &&
tipNewCardCVC.length >= 3
? null
: tipNewCardNumber.length === 0 &&
tipNewCardExpiry.length === 0 &&
tipNewCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
);
const isTipCardValid = $derived(
tipSelectedCardId !== null ||
(isValidLuhn(tipNewCardNumber) &&
tipNewCardExpiryParts !== null &&
!isTipNewCardExpiryPast &&
tipNewCardCVC.length >= 3)
);
const isTipCardValid = $derived(tipSelectedCardId !== null);
const tipPresets = $derived(
selectedBooking
@@ -314,31 +220,17 @@
return;
}
if (tipSavedCards.length > 0 && !tipSelectedCardId && !tipShowNewCard) {
toast.error('Please select a payment method');
if (tipSavedCards.length === 0) {
toast.error('Please add a saved card or contact the salon to pay by another method');
return;
}
if ((tipShowNewCard || tipSavedCards.length === 0) && !tipNewCardNumber.replace(/\s/g, '')) {
toast.error('Please enter your card number');
if (!tipSelectedCardId) {
toast.error('Please select a payment method');
return;
}
tipProcessing = true;
// Validate card details for new card payments
if (tipShowNewCard || tipSavedCards.length === 0) {
if (
!isValidLuhn(tipNewCardNumber) ||
!/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) ||
isTipNewCardExpiryPast ||
tipNewCardCVC.length < 3
) {
tipProcessing = false;
toast.error(tipNewCardError || 'Please enter valid credit card details');
return;
}
}
try {
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
tipIdempotencyKey = crypto.randomUUID();
@@ -346,16 +238,10 @@
}
const body: Record<string, unknown> = {
amount: Math.round(tipAmount * 100),
idempotency_key: tipIdempotencyKey
idempotency_key: tipIdempotencyKey,
card_id: tipSelectedCardId
};
if (tipShowNewCard || tipSavedCards.length === 0) {
body.new_card_token = tipNewCardNumber.replace(/\s/g, '');
body.save_card = tipSaveCardFuture;
} else {
body.card_id = tipSelectedCardId;
}
const response = await apiFetch(`/api/bookings/${selectedBooking.id}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1264,13 +1150,10 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipSelectedCardId ===
card.id && !tipShowNewCard
card.id
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
tipSelectedCardId = card.id;
tipShowNewCard = false;
}}
onclick={() => (tipSelectedCardId = card.id)}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
@@ -1281,66 +1164,16 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
>
</div>
</div>
{#if tipSelectedCardId === card.id && !tipShowNewCard}
{#if tipSelectedCardId === card.id}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipShowNewCard
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
tipSelectedCardId = null;
tipShowNewCard = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
</div>
{#if tipShowNewCard}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{#if tipShowNewCard}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={tipNewCardNumber}
bind:cardExpiry={tipNewCardExpiry}
bind:cardCVC={tipNewCardCVC}
bind:saveCard={tipSaveCardFuture}
showSaveCard={canSaveCards}
onfieldblur={handleTipFieldBlur}
onfieldinput={handleTipFieldInput}
/>
{#if tipNewCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{tipNewCardError}</div>
{/if}
</div>
{/if}
{:else}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={tipNewCardNumber}
bind:cardExpiry={tipNewCardExpiry}
bind:cardCVC={tipNewCardCVC}
bind:saveCard={tipSaveCardFuture}
showSaveCard={canSaveCards}
onfieldblur={handleTipFieldBlur}
onfieldinput={handleTipFieldInput}
/>
{#if tipNewCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{tipNewCardError}</div>
{/if}
</div>
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
/>
{/if}
</div>
</div>
@@ -9,6 +9,7 @@
import { EmailInput } from '$lib/components/ui/email-input';
import * as Modal from '$lib/components/ui/dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import { range } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
@@ -107,7 +108,6 @@
| 'amount_email'
| 'payment'
| 'cash_entry'
| 'card_details'
| 'processing'
| 'success'
| 'error'
@@ -142,9 +142,6 @@
// Payment Processing States
let cashAmount = $state('');
let ephemeralCardNumber = $state('');
let ephemeralCardExpiry = $state('');
let ephemeralCardCVC = $state('');
let paymentError = $state('');
let paymentResult = $state<{
id: string;
@@ -180,14 +177,7 @@
}
let topUpStep = $state<
| 'choice'
| 'amount'
| 'payment'
| 'cash_entry'
| 'card_details'
| 'processing'
| 'success'
| 'error'
'choice' | 'amount' | 'payment' | 'cash_entry' | 'processing' | 'success' | 'error'
>('choice');
let topUpMode = $state<'giveaway' | 'purchase'>('giveaway');
@@ -469,9 +459,6 @@
// Reset payment
cashAmount = '';
ephemeralCardNumber = '';
ephemeralCardExpiry = '';
ephemeralCardCVC = '';
paymentError = '';
paymentResult = null;
cardMachineItemID = null;
@@ -480,38 +467,9 @@
// =============== Embedded Payment Handlers ===============
const isEphemeralCardValid = $derived(
ephemeralCardNumber.replace(/\s/g, '').length >= 13 &&
ephemeralCardExpiry.includes('/') &&
ephemeralCardExpiry.length === 5 &&
ephemeralCardCVC.length >= 3
);
function handleEphemeralCardNumberInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
const clean = target.value.replace(/\D/g, '');
const formatted = clean.match(/.{1,4}/g)?.join(' ') || clean;
ephemeralCardNumber = formatted.slice(0, 19);
}
function handleEphemeralExpiryInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
const clean = target.value.replace(/\D/g, '');
if (clean.length > 2) {
ephemeralCardExpiry = clean.slice(0, 2) + '/' + clean.slice(2, 4);
} else {
ephemeralCardExpiry = clean;
}
}
function handleEphemeralCvcInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
ephemeralCardCVC = target.value.replace(/\D/g, '').slice(0, 4);
}
function setModalStep(
actionType: 'create' | 'topup',
step: 'processing' | 'success' | 'error' | 'payment' | 'cash_entry' | 'card_details'
step: 'processing' | 'success' | 'error' | 'payment' | 'cash_entry'
) {
if (actionType === 'create') {
generateStep = step;
@@ -636,54 +594,6 @@
setModalStep(actionType, 'error');
}
async function handleEmbeddedEphemeralCardPayment(actionType: 'create' | 'topup', gcId?: string) {
const cardNum = ephemeralCardNumber.replace(/\s/g, '');
const [monthStr, yearStr] = ephemeralCardExpiry.split('/');
const expMonth = parseInt(monthStr, 10);
const expYear = 2000 + parseInt(yearStr, 10);
setModalStep(actionType, 'processing');
processingMessage = 'Processing card payment...';
try {
const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount);
const body: Record<string, unknown> = {
item_type: 'gift_card',
action: actionType,
amount: amt,
payment_method: 'online_square',
idempotency_key: getIdempotencyKey(),
card_number: cardNum,
card_exp_month: expMonth,
card_exp_year: expYear,
card_cvc: ephemeralCardCVC
};
if (gcId) body.gift_card_id = gcId;
if (selectedCustomer) body.user_id = selectedCustomer.id;
if (actionType === 'create' && generateType === 'account' && selectedCustomer)
body.redeem_to_user_id = selectedCustomer.id;
const res = await apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (res.ok) {
const data = await res.json();
paymentResult = { ...data };
setModalStep(actionType, 'success');
await fetchGiftCards();
} else {
paymentError = await res.text();
setModalStep(actionType, 'error');
}
} catch {
paymentError = 'Network error processing card payment';
setModalStep(actionType, 'error');
}
}
async function handleEmbeddedGiveawayTopUp(gcId: string) {
topUpStep = 'processing';
processingMessage = 'Processing on-the-house top-up...';
@@ -1495,8 +1405,6 @@
Select the payment method.
{:else if generateStep === 'cash_entry'}
Enter cash amount received.
{:else if generateStep === 'card_details'}
Enter card payment details.
{/if}
</Modal.Description>
</Modal.Header>
@@ -1902,25 +1810,11 @@
</svg>
Cash
</button>
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 sm:col-span-2"
onclick={() => (generateStep = 'card_details')}
>
<svg
class="mx-auto mb-2 h-8 w-8 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
<path d="M1 14h22" />
<circle cx="7" cy="18" r="1.5" />
</svg>
Card Details
</button>
<div class="sm:col-span-2">
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please take payment by card machine or cash."
/>
</div>
</div>
</div>
<Modal.Footer>
@@ -1964,68 +1858,6 @@
Confirm Cash
</Button>
</Modal.Footer>
{:else if generateStep === 'card_details'}
<div class="space-y-4 py-4">
<div class="space-y-3 rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="text-sm font-medium text-gray-700">Online Card Processing</h4>
<div class="space-y-3">
<div>
<label for="generate-card-number" class="text-sm font-medium text-gray-700"
>Card Number</label
>
<Input
id="generate-card-number"
type="text"
inputmode="numeric"
value={ephemeralCardNumber}
oninput={handleEphemeralCardNumberInput}
placeholder="1234 5678 9012 3456"
maxlength={19}
class="mt-1"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="generate-card-expiry" class="text-sm font-medium text-gray-700"
>Expiry (MM/YY)</label
>
<Input
id="generate-card-expiry"
type="text"
inputmode="numeric"
value={ephemeralCardExpiry}
oninput={handleEphemeralExpiryInput}
placeholder="MM/YY"
maxlength={5}
class="mt-1"
/>
</div>
<div>
<label for="generate-card-cvc" class="text-sm font-medium text-gray-700">CVC</label>
<Input
id="generate-card-cvc"
type="text"
inputmode="numeric"
value={ephemeralCardCVC}
oninput={handleEphemeralCvcInput}
placeholder="123"
maxlength={4}
class="mt-1"
/>
</div>
</div>
</div>
</div>
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (generateStep = 'payment')}>Back</Button>
<Button
disabled={!isEphemeralCardValid}
onclick={() => handleEmbeddedEphemeralCardPayment('create')}
>
Pay {formatCurrency(Number(generateAmount))}
</Button>
</Modal.Footer>
{:else if generateStep === 'processing'}
<div class="flex flex-col items-center justify-center py-8">
<div
@@ -2113,8 +1945,6 @@
Select the payment method.
{:else if topUpStep === 'cash_entry'}
Enter cash amount received.
{:else if topUpStep === 'card_details'}
Enter card payment details.
{/if}
</Modal.Description>
</Modal.Header>
@@ -2219,25 +2049,11 @@
</svg>
Cash
</button>
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 sm:col-span-2"
onclick={() => (topUpStep = 'card_details')}
>
<svg
class="mx-auto mb-2 h-8 w-8 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
<path d="M1 14h22" />
<circle cx="7" cy="18" r="1.5" />
</svg>
Card Details
</button>
<div class="sm:col-span-2">
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please take payment by card machine or cash."
/>
</div>
</div>
</div>
<Modal.Footer>
@@ -2279,68 +2095,6 @@
Confirm Cash
</Button>
</Modal.Footer>
{:else if topUpStep === 'card_details'}
<div class="space-y-4 py-4">
<div class="space-y-3 rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="text-sm font-medium text-gray-700">Online Card Processing</h4>
<div class="space-y-3">
<div>
<label for="topup-card-number" class="text-sm font-medium text-gray-700"
>Card Number</label
>
<Input
id="topup-card-number"
type="text"
inputmode="numeric"
value={ephemeralCardNumber}
oninput={handleEphemeralCardNumberInput}
placeholder="1234 5678 9012 3456"
maxlength={19}
class="mt-1"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="topup-card-expiry" class="text-sm font-medium text-gray-700"
>Expiry (MM/YY)</label
>
<Input
id="topup-card-expiry"
type="text"
inputmode="numeric"
value={ephemeralCardExpiry}
oninput={handleEphemeralExpiryInput}
placeholder="MM/YY"
maxlength={5}
class="mt-1"
/>
</div>
<div>
<label for="topup-card-cvc" class="text-sm font-medium text-gray-700">CVC</label>
<Input
id="topup-card-cvc"
type="text"
inputmode="numeric"
value={ephemeralCardCVC}
oninput={handleEphemeralCvcInput}
placeholder="123"
maxlength={4}
class="mt-1"
/>
</div>
</div>
</div>
</div>
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (topUpStep = 'payment')}>Back</Button>
<Button
disabled={!isEphemeralCardValid}
onclick={() => handleEmbeddedEphemeralCardPayment('topup', selectedCardId ?? undefined)}
>
Pay {formatCurrency(Number(topUpAmount))}
</Button>
</Modal.Footer>
{:else if topUpStep === 'processing'}
<div class="flex flex-col items-center justify-center py-8">
<div
@@ -36,7 +36,7 @@
import DatePicker from '$lib/components/booking/DatePicker.svelte';
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
import CardInput from '$lib/components/payments/CardInput.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
@@ -87,7 +87,6 @@
>([]);
let paymentMethodsLoading = $state(false);
let selectedPaymentMethod = $state<string | null>(null);
let showNewCardForm = $state(false);
let isProcessingPayment = $state(false);
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
// on the next microtask), so `isProcessingPayment` may not propagate to the
@@ -95,76 +94,10 @@
// reactive flag is checked synchronously at the start of processPayment.
let isProcessingPaymentSync = false;
// New card form fields
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let cardNumberTouched = $state(false);
let cardExpiryTouched = $state(false);
let cardCVCTouched = $state(false);
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
const expiryParts = $derived(parseExpiryParts(newCardExpiry));
// Payment flow state
let depositPaid = $state(false);
const cardError = $derived(
cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched &&
expiryParts !== null &&
(() => {
const em = expiryParts.year * 12 + expiryParts.month;
const now2 = new SvelteDate();
const cm = now2.getFullYear() * 12 + now2.getMonth() + 1;
return em < cm;
})()
? 'This card has expired'
: cardExpiryTouched && !/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiry.length > 0
? 'Enter expiry as MM/YY'
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(newCardNumber) &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3
? null
: newCardNumber.length === 0 && newCardExpiry.length === 0 && newCardCVC.length === 0
? null
: 'Please complete all card fields'
);
const depositCardFormValid = $derived(
selectedPaymentMethod !== null ||
(showNewCardForm &&
newCardNumber.replace(/\s/g, '').length >= 13 &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3)
);
const depositCardFormValid = $derived(selectedPaymentMethod !== null);
// VAT registration status from public business info (via shared store)
const vatRegistered = $derived(getBusinessInfo()?.is_vat_registered ?? false);
@@ -325,18 +258,6 @@
}
}
function handleFieldBlur(field: string) {
if (field === 'cardNumber') cardNumberTouched = true;
else if (field === 'cardExpiry') cardExpiryTouched = true;
else if (field === 'cardCVC') cardCVCTouched = true;
}
function handleFieldInput(field: string) {
if (field === 'cardNumber') cardNumberTouched = false;
else if (field === 'cardExpiry') cardExpiryTouched = false;
else if (field === 'cardCVC') cardCVCTouched = false;
}
async function processPayment(amount: number) {
// Synchronous double-click guard — set BEFORE any await so a rapid second
// click is rejected immediately, even before the reactive `disabled` has
@@ -346,6 +267,10 @@
isProcessingPayment = true;
paymentAttempted = false;
try {
if (!selectedPaymentMethod) {
toast.error('Please select a saved card');
return;
}
await submitAndProceed();
if (!confirmedBooking) {
toast.error('Booking was not created. Please try again.');
@@ -356,7 +281,7 @@
// Cache the idempotency key per amount+card so a lost-response retry
// reuses it (backend dedups) instead of double-charging.
const cardKey = selectedPaymentMethod ?? newCardNumber.replace(/\s/g, '') ?? '';
const cardKey = selectedPaymentMethod;
if (
!depositIdempotencyKey ||
depositKeyedAmount !== amountCents ||
@@ -370,18 +295,10 @@
const body: Record<string, unknown> = {
payment_type: 'deposit',
amount: amountCents,
idempotency_key: depositIdempotencyKey
idempotency_key: depositIdempotencyKey,
card_id: selectedPaymentMethod
};
if (selectedPaymentMethod) {
body.card_id = selectedPaymentMethod;
} else {
const rawNumber = newCardNumber.replace(/\s/g, '');
if (rawNumber.length >= 13) {
body.new_card_token = rawNumber;
}
}
paymentAttempted = true;
const response = await apiFetch(`/api/bookings/${bookingId}/payment`, {
@@ -2345,7 +2262,7 @@
{#if authStore.isAuthenticated}
{#if paymentMethodsLoading}
<div class="py-4 text-center text-gray-500">Loading payment methods...</div>
<div class="mb-6 py-4 text-center text-gray-500">Loading payment methods...</div>
{:else if paymentMethods.length > 0}
<div class="mb-6">
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
@@ -2373,10 +2290,7 @@
<Button
size="sm"
variant={selectedPaymentMethod === method.id ? 'default' : 'outline'}
onclick={() => {
selectedPaymentMethod = method.id;
showNewCardForm = false;
}}
onclick={() => (selectedPaymentMethod = method.id)}
>
{selectedPaymentMethod === method.id ? 'Selected' : 'Use this card'}
</Button>
@@ -2384,34 +2298,19 @@
{/each}
</div>
</div>
{:else}
<div class="mb-6">
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
/>
</div>
{/if}
{#if !showNewCardForm}
<Button
variant="outline"
class="mb-6"
onclick={() => {
showNewCardForm = true;
selectedPaymentMethod = null;
}}
>
+ Add new card
</Button>
{/if}
{/if}
{#if showNewCardForm || !authStore.isAuthenticated}
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
disabled={isProcessingPayment}
onfieldblur={handleFieldBlur}
onfieldinput={handleFieldInput}
/>
{#if cardError}
<p class="mt-2 text-sm text-red-600">{cardError}</p>
{/if}
{:else}
<div class="mb-6">
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please contact the salon to pay by another method."
/>
</div>
{/if}
<div class="flex items-center justify-between border-t pt-4">
@@ -0,0 +1,21 @@
<script lang="ts">
import { NEW_CARD_ENTRY_UNAVAILABLE_MESSAGE } from '$lib/constants/payments';
let { message = NEW_CARD_ENTRY_UNAVAILABLE_MESSAGE }: { message?: string } = $props();
</script>
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<div class="flex items-start gap-3">
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-gray-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<p class="text-sm text-gray-600">{message}</p>
</div>
</div>
@@ -1,101 +0,0 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
let {
cardNumber = $bindable(''),
cardExpiry = $bindable(''),
cardCVC = $bindable(''),
saveCard = $bindable(false),
showSaveCard = false,
disabled = false,
onfieldblur = (_field: string) => {},
onfieldinput = (_field: string) => {}
}: {
cardNumber?: string;
cardExpiry?: string;
cardCVC?: string;
saveCard?: boolean;
showSaveCard?: boolean;
disabled?: boolean;
onfieldblur?: (field: string) => void;
onfieldinput?: (field: string) => void;
} = $props();
function formatNumber(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 16);
const groups = digits.match(/.{1,4}/g);
return groups ? groups.join(' ') : digits;
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 4);
if (digits.length >= 3) {
return digits.substring(0, 2) + '/' + digits.substring(2);
}
return digits;
}
</script>
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="mb-4 text-sm font-medium text-gray-700">Card Details</h4>
<div class="space-y-4">
<div class="space-y-2">
<Label for="cardNumber">Card Number</Label>
<Input
id="cardNumber"
type="text"
inputmode="numeric"
value={cardNumber}
oninput={(e) => {
cardNumber = formatNumber((e.target as HTMLInputElement).value);
onfieldinput('cardNumber');
}}
onblur={() => onfieldblur('cardNumber')}
placeholder="1234 5678 9012 3456"
maxlength={19}
{disabled}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="cardExpiry">Expiry (MM/YY)</Label>
<Input
id="cardExpiry"
type="text"
inputmode="numeric"
value={cardExpiry}
oninput={(e) => {
cardExpiry = formatExpiry((e.target as HTMLInputElement).value);
onfieldinput('cardExpiry');
}}
onblur={() => onfieldblur('cardExpiry')}
placeholder="MM/YY"
maxlength={5}
{disabled}
/>
</div>
<div class="space-y-2">
<Label for="cardCVC">CVC</Label>
<Input
id="cardCVC"
type="text"
inputmode="numeric"
bind:value={cardCVC}
oninput={() => onfieldinput('cardCVC')}
onblur={() => onfieldblur('cardCVC')}
placeholder="123"
maxlength={4}
{disabled}
/>
</div>
</div>
{#if showSaveCard}
<div class="flex items-center gap-2">
<Checkbox id="saveCard" bind:checked={saveCard} {disabled} />
<Label for="saveCard" class="text-sm font-normal">Save card for next time</Label>
</div>
{/if}
</div>
</div>
@@ -1,7 +1,6 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import CardInput from './CardInput.svelte';
import CardBrandIcon from './CardBrandIcon.svelte';
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
export interface SelectableCard {
id: string;
@@ -14,21 +13,15 @@
let {
cards = [],
canSaveCards = false,
canSaveCards: _canSaveCards = false,
newCardDisabled = false,
selectedCardId = $bindable(''),
newCardNumber = $bindable(''),
newCardExpiry = $bindable(''),
newCardCVC = $bindable(''),
saveCard = $bindable(false),
onValidityChange = (_valid: boolean) => {}
}: {
cards?: SelectableCard[];
canSaveCards?: boolean;
newCardDisabled?: boolean;
selectedCardId?: string;
newCardNumber?: string;
newCardExpiry?: string;
newCardCVC?: string;
saveCard?: boolean;
onValidityChange?: (valid: boolean) => void;
} = $props();
@@ -47,94 +40,8 @@
}
});
// Blur-based validation state (matches the pattern used across all card flows)
let cardNumberTouched = $state(false);
let cardExpiryTouched = $state(false);
let cardCVCTouched = $state(false);
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
const expiryParts = $derived(parseExpiryParts(newCardExpiry));
const isExpiryInPast = $derived(
expiryParts !== null &&
(() => {
const expiryYearMonth = expiryParts.year * 12 + expiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);
const hasInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null);
function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
function handleFieldBlur(field: string) {
if (field === 'cardNumber') cardNumberTouched = true;
else if (field === 'cardExpiry') cardExpiryTouched = true;
else if (field === 'cardCVC') cardCVCTouched = true;
}
function handleFieldInput(field: string) {
if (field === 'cardNumber') cardNumberTouched = false;
else if (field === 'cardExpiry') cardExpiryTouched = false;
else if (field === 'cardCVC') cardCVCTouched = false;
}
// Exposed derived state — same semantics as the other card flows.
const cardError = $derived(
showNewCardForm || cards.length === 0
? cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched && hasInvalidMonth
? 'Invalid expiry month'
: cardExpiryTouched && isExpiryInPast
? 'This card has expired'
: cardExpiryTouched && newCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(newCardExpiry)
? 'Enter expiry as MM/YY'
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(newCardNumber) &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3
? null
: newCardNumber.length === 0 &&
newCardExpiry.length === 0 &&
newCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
);
const isCardValid = $derived(
(selectedCardId !== '' && cards.length > 0) ||
(isValidLuhn(newCardNumber) &&
expiryParts !== null &&
!isExpiryInPast &&
newCardCVC.length >= 3)
);
const isCardValid = $derived(selectedCardId !== '' && cards.length > 0);
$effect(() => {
onValidityChange(isCardValid);
@@ -170,44 +77,35 @@
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = '';
showNewCardForm = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
{#if !newCardDisabled}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = '';
showNewCardForm = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
</div>
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
</div>
{#if showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{/if}
{#if showNewCardForm || cards.length === 0}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard
showSaveCard={canSaveCards}
onfieldblur={handleFieldBlur}
onfieldinput={handleFieldInput}
/>
{#if cardError}
<p class="mt-1 text-xs font-semibold text-red-500">{cardError}</p>
{#if showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/if}
</div>
{/if}
{#if newCardDisabled}
{#if cards.length === 0}
<CardEntryUnavailable />
{/if}
{/if}
@@ -54,12 +54,6 @@
let stamps = $state(0);
let useLoyalty = $state(false);
// New card form fields (bound into CardSelection)
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let saveCardForFuture = $state(false);
// Partial payment amount (in pounds, user enters)
let partialAmount = $state<string>('');
let lastValidPartialAmount = $state<string>('');
@@ -347,24 +341,19 @@
}
let cardId: string | undefined;
let newCardToken: string | undefined;
let saveCard = false;
if (selectedCardId) {
cardId = selectedCardId;
} else if (newCardNumber) {
newCardToken = newCardNumber;
saveCard = saveCardForFuture;
} else {
status = 'error';
error = 'Please select or enter card details';
toast.error('Please select or enter card details');
error = 'Please select a saved card';
toast.error('Please select a saved card');
return;
}
// Cache the idempotency key per amount+type+card so a lost-response
// retry reuses it (backend dedups) instead of double-charging.
const cardKey = cardId ?? newCardToken ?? '';
const cardKey = cardId ?? '';
if (
!payIdempotencyKey ||
payKeyedAmount !== amountCents ||
@@ -385,8 +374,6 @@
amount: amountCents,
payment_type: paymentType,
card_id: cardId,
new_card_token: newCardToken,
save_card: saveCard,
idempotency_key: payIdempotencyKey
})
});
@@ -686,11 +673,8 @@
<CardSelection
cards={paymentMethods}
{canSaveCards}
newCardDisabled
bind:selectedCardId
bind:newCardNumber
bind:newCardExpiry
bind:newCardCVC
bind:saveCard={saveCardForFuture}
onValidityChange={(v) => (cardSelectionValid = v)}
/>
{/if}
+3
View File
@@ -0,0 +1,3 @@
// Backend rejects raw PANs; Square token minting is deferred (P11).
export const NEW_CARD_ENTRY_UNAVAILABLE_MESSAGE =
'Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method.';
+26 -424
View File
@@ -11,6 +11,7 @@
import { isValidUKPhone, formatPhoneDisplay, toE164UK } from '$lib/utils/phone';
import { range } from '$lib/utils/format';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
// zxcvbn-ts imports
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
@@ -29,7 +30,6 @@
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { EmailInput } from '$lib/components/ui/email-input/index.js';
@@ -153,12 +153,6 @@
}
}
let showAddCard = $state(false);
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let addingCard = $state(false);
// =============== Gift Card State ===============
let giftCardBalance = $state(0);
let loadingBalance = $state(false);
@@ -172,11 +166,6 @@
let buyRecipientType = $state<'self' | 'friend'>('self');
let buyRecipientEmail = $state('');
let buySelectedCard = $state('');
let buyShowNewCard = $state(false);
let buyNewCardNumber = $state('');
let buyNewCardExpiry = $state('');
let buyNewCardCVC = $state('');
let buySaveCard = $state(false);
let buyingGiftCard = $state(false);
let purchaseResultCode = $state<string | null>(null);
@@ -188,90 +177,18 @@
let buyKeyedCard = $state('');
$effect(() => {
// Auto-select the default saved card only once, when cards first load.
// Do NOT re-select when the user explicitly chooses "Use a new card"
// (buySelectedCard === ''), otherwise the click is immediately overridden.
if (savedCardsStore.cards.length > 0 && !buySelectedCard && !buyShowNewCard) {
// Auto-select the default saved card when cards first load. A new card
// cannot be entered online right now (see CardEntryUnavailable), so this
// only ever needs to pick between saved cards.
if (savedCardsStore.cards.length > 0 && !buySelectedCard) {
const defaultCard =
savedCardsStore.cards.find((c) => c.is_default) || savedCardsStore.cards[0];
buySelectedCard = defaultCard.id;
}
});
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
// Derived validations for Add Saved Card form
const newCardExpiryParts = $derived(parseExpiryParts(newCardExpiry));
const isNewCardExpiryInPast = $derived(
newCardExpiryParts !== null &&
(() => {
const expiryYearMonth = newCardExpiryParts.year * 12 + newCardExpiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);
const isNewCardExpiryInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null
);
const addCardError = $derived(
newCardNumber.length > 0 && !isValidLuhn(newCardNumber)
? 'Invalid card number'
: isNewCardExpiryInvalidMonth
? 'Invalid expiry month'
: isNewCardExpiryInPast
? 'This card has already expired'
: newCardCVC.length > 0 && newCardCVC.length < 3
? 'CVC must be at least 3 digits'
: null
);
const isAddCardValid = $derived(
isValidLuhn(newCardNumber) && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3
);
// Derived validations for Buy Gift Card form
const buyNewCardExpiryParts = $derived(parseExpiryParts(buyNewCardExpiry));
const isBuyNewCardExpiryInPast = $derived(
buyNewCardExpiryParts !== null &&
(() => {
const expiryYearMonth = buyNewCardExpiryParts.year * 12 + buyNewCardExpiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);
const isBuyNewCardExpiryInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(buyNewCardExpiry) && buyNewCardExpiryParts === null
);
const buyCardError = $derived(
buyNewCardNumber.length > 0 && !isValidLuhn(buyNewCardNumber)
? 'Invalid card number'
: isBuyNewCardExpiryInvalidMonth
? 'Invalid expiry month'
: isBuyNewCardExpiryInPast
? 'This card has already expired'
: buyNewCardCVC.length > 0 && buyNewCardCVC.length < 3
? 'CVC must be at least 3 digits'
: null
);
const isBuyCardValid = $derived(
buySelectedCard !== '' ||
(isValidLuhn(buyNewCardNumber) &&
buyNewCardExpiryParts !== null &&
!isBuyNewCardExpiryInPast &&
buyNewCardCVC.length >= 3)
);
// Derived validation for Buy Gift Card form
const isBuyCardValid = $derived(buySelectedCard !== '');
async function fetchGiftCardBalance() {
loadingBalance = true;
@@ -318,36 +235,20 @@
}
async function buyGiftCard() {
if (!buySelectedCard) {
toast.error('Please select a saved card');
buyingGiftCard = false;
return;
}
buyingGiftCard = true;
try {
let cardId: string | undefined;
let newCardToken: string | undefined;
let saveCard = false;
if (buySelectedCard) {
cardId = buySelectedCard;
} else if (buyNewCardNumber) {
if (
!isValidLuhn(buyNewCardNumber) ||
!/^\d{2}\/\d{2}$/.test(buyNewCardExpiry) ||
buyNewCardCVC.length < 3
) {
toast.error('Please enter valid credit card details');
buyingGiftCard = false;
return;
}
newCardToken = buyNewCardNumber;
saveCard = buySaveCard;
} else {
toast.error('Please select or enter card details');
buyingGiftCard = false;
return;
}
const cardId = buySelectedCard;
// Cache the idempotency key per amount+card so a lost-response retry
// reuses the same key (backend dedups) instead of double-charging.
// Regenerate when the amount or card changes.
const cardKey = cardId ?? newCardToken ?? '';
const cardKey = cardId;
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
buyIdempotencyKey = generateIdempotencyKey();
buyKeyedAmount = buyAmount;
@@ -362,8 +263,6 @@
recipient_type: buyRecipientType,
recipient_email: buyRecipientEmail,
card_id: cardId,
new_card_token: newCardToken,
save_card: saveCard,
idempotency_key: buyIdempotencyKey
})
});
@@ -372,19 +271,10 @@
const data = await res.json();
toast.success('Gift card purchased successfully!');
purchaseResultCode = data.code;
buyNewCardNumber = '';
buyNewCardExpiry = '';
buyNewCardCVC = '';
buyIdempotencyKey = '';
buyKeyedAmount = 0;
buyKeyedCard = '';
await fetchGiftCardBalance();
if (buySelectedCard === '') {
await savedCardsStore.fetch();
// If the new card was saved, return to saved-card selection so the
// auto-select effect picks a default for the next purchase.
buyShowNewCard = false;
}
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to purchase gift card');
@@ -475,77 +365,6 @@
);
}
function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
function formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 16);
const groups = digits.match(/.{1,4}/g);
return groups ? groups.join(' ') : digits;
}
function formatExpiryDate(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 4);
if (digits.length >= 3) {
return digits.substring(0, 2) + '/' + digits.substring(2);
}
return digits;
}
function handleCardNumberInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatCardNumber);
newCardNumber = formatted;
}
function handleExpiryInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
newCardExpiry = formatted;
}
function handleCvcInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, (val) =>
val.replace(/\D/g, '').substring(0, 4)
);
newCardCVC = formatted;
}
function handleBuyCardNumberInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatCardNumber);
buyNewCardNumber = formatted;
}
// Uses custom formatter with MM/YY slash and preserves cursor position
function handleBuyExpiryInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatExpiryDate);
buyNewCardExpiry = formatted;
}
function handleBuyCvcInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, (val) =>
val.replace(/\D/g, '').substring(0, 4)
);
buyNewCardCVC = formatted;
}
function generateIdempotencyKey(): string {
const array = new Uint8Array(16);
if (typeof window !== 'undefined' && window.crypto) {
@@ -564,58 +383,6 @@
.join('');
}
async function addCard() {
if (
!isValidLuhn(newCardNumber) ||
!/^\d{2}\/\d{2}$/.test(newCardExpiry) ||
newCardCVC.length < 3
) {
toast.error('Please fill in all card details correctly');
return;
}
const cardNum = newCardNumber.replace(/\s/g, '');
const [monthStr, yearStr] = newCardExpiry.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) {
toast.error('Invalid expiry month');
return;
}
const expiryDate = new Date(year, month);
if (expiryDate < new Date()) {
toast.error('This card has already expired');
return;
}
addingCard = true;
try {
const res = await apiFetch('/api/user/payment-methods', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
card_number: cardNum,
expiry: newCardExpiry,
cvc: newCardCVC
})
});
if (res.ok) {
toast.success('Card added');
showAddCard = false;
newCardNumber = '';
newCardExpiry = '';
newCardCVC = '';
savedCardsStore.invalidate();
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to add card');
}
} catch (err) {
console.error('addCard error:', err);
toast.error('Network error');
} finally {
addingCard = false;
}
}
async function fetchNotifPrefs() {
try {
const res = await apiFetch('/api/user/notification-preferences');
@@ -2039,88 +1806,12 @@
<Skeleton class="h-16 w-full" />
<Skeleton class="h-16 w-full" />
</div>
{:else if showAddCard}
<div class="space-y-4">
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="mb-3 text-sm font-medium text-gray-700">Add New Card</h4>
<div class="space-y-3">
<div>
<label for="account-cardNumber" class="text-sm font-medium text-gray-700"
>Card Number</label
>
<Input
id="account-cardNumber"
type="text"
inputmode="numeric"
value={newCardNumber}
oninput={handleCardNumberInput}
placeholder="1234 5678 9012 3456"
maxlength={19}
class="mt-1"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="account-cardExpiry" class="text-sm font-medium text-gray-700"
>Expiry (MM/YY)</label
>
<Input
id="account-cardExpiry"
type="text"
inputmode="numeric"
value={newCardExpiry}
oninput={handleExpiryInput}
placeholder="MM/YY"
maxlength={5}
class="mt-1"
/>
</div>
<div>
<label for="account-cardCVC" class="text-sm font-medium text-gray-700"
>CVC</label
>
<Input
id="account-cardCVC"
type="text"
inputmode="numeric"
value={newCardCVC}
oninput={handleCvcInput}
placeholder="123"
maxlength={4}
class="mt-1"
/>
</div>
</div>
{#if addCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{addCardError}</div>
{/if}
</div>
</div>
<div class="flex gap-3">
<Button
variant="ghost"
onclick={() => {
showAddCard = false;
newCardNumber = '';
newCardExpiry = '';
newCardCVC = '';
}}
>
Cancel
</Button>
<Button
onclick={addCard}
loading={addingCard}
disabled={addingCard || !isAddCardValid}
>
Add Card
</Button>
</div>
</div>
{:else if savedCardsStore.cards.length === 0}
<div class="py-8 text-center">
<p class="text-gray-500">No saved cards yet</p>
<Button class="mt-4" onclick={() => (showAddCard = true)}>Add a Card</Button>
<div class="space-y-4 py-2">
<p class="text-center text-gray-500">No saved cards yet</p>
<CardEntryUnavailable
message="Online card entry is temporarily unavailable, so new cards cannot be added right now. Please contact the salon to pay by another method."
/>
</div>
{:else}
<div class="space-y-3">
@@ -2150,9 +1841,9 @@
</Button>
</div>
{/each}
<Button variant="outline" class="w-full" onclick={() => (showAddCard = true)}>
+ Add a Card
</Button>
<CardEntryUnavailable
message="Online card entry is temporarily unavailable, so new cards cannot be added right now. Please contact the salon to pay by another method."
/>
</div>
{/if}
</Card.Content>
@@ -2419,10 +2110,7 @@
card.id
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
buySelectedCard = card.id;
buyShowNewCard = false;
}}
onclick={() => (buySelectedCard = card.id)}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
@@ -2438,95 +2126,9 @@
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard ===
''
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
buySelectedCard = '';
buyShowNewCard = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700"
>Use a new card</span
>
</div>
{#if buySelectedCard === ''}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{/if}
{#if buySelectedCard === ''}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<div>
<label for="buy-card-num" class="text-xs font-medium text-gray-600"
>Card Number</label
>
<Input
id="buy-card-num"
type="text"
inputmode="numeric"
placeholder="1234 5678 9012 3456"
value={buyNewCardNumber}
oninput={handleBuyCardNumberInput}
maxlength={19}
class="mt-1 h-8 bg-white text-xs"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="buy-card-exp" class="text-xs font-medium text-gray-600"
>Expiry (MM/YY)</label
>
<Input
id="buy-card-exp"
type="text"
inputmode="numeric"
placeholder="MM/YY"
value={buyNewCardExpiry}
oninput={handleBuyExpiryInput}
maxlength={5}
class="mt-1 h-8 bg-white text-xs"
/>
</div>
<div>
<label for="buy-card-cvc" class="text-xs font-medium text-gray-600"
>CVC</label
>
<Input
id="buy-card-cvc"
type="text"
inputmode="numeric"
placeholder="123"
value={buyNewCardCVC}
oninput={handleBuyCvcInput}
maxlength={4}
class="mt-1 h-8 bg-white text-xs"
/>
</div>
</div>
{#if buyCardError}
<div class="mt-1 text-[10px] font-semibold text-red-500">
{buyCardError}
</div>
{/if}
<div class="flex items-center gap-2 pt-1">
<Checkbox id="buy-save-card" bind:checked={buySaveCard} />
<label for="buy-save-card" class="text-[10px] text-gray-500"
>Save card for future purchases</label
>
</div>
</div>
{:else}
<CardEntryUnavailable />
{/if}
</div>
@@ -109,7 +109,8 @@
deposit_not_paid_by_deadline: 'Deposit Deadline Passed',
affiliate_claim: 'Affiliate Referral Claimed',
'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'
};
function hasAction(reason: string): string | null {
@@ -125,6 +126,7 @@
case 'deposit_not_paid_by_deadline':
case '1_week_no_pay':
case '1_month_no_pay':
case 'refund_failed':
return 'see_user';
default:
return null;
@@ -156,8 +156,9 @@
</ul>
<p class="mb-4 text-xs leading-relaxed text-gray-500 italic">
If a card refund cannot be processed (e.g. the card is expired or the Square payment
reference is unavailable), the refund amount will be credited to your account balance as a
fallback, so you are never left out of pocket.
reference is unavailable), we will notify you via your account and arrange collection of the
refund in person at the salon — please allow at least a day's notice so we can have cash on
hand. You will never be left out of pocket.
</p>
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Guest / Walk-In Bookings</h3>
+19 -185
View File
@@ -11,8 +11,8 @@
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import CardInput from '$lib/components/payments/CardInput.svelte';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
// Types
@@ -53,16 +53,8 @@
// Card selection state
let savedCards = $state<SavedCard[]>([]);
let selectedCardId = $state<string | null>(null);
let showNewCardForm = $state(false);
// New card form state
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let saveCardForFuture = $state(false);
let cardNumberTouched = $state(false);
let cardExpiryTouched = $state(false);
let cardCVCTouched = $state(false);
const isCardValid = $derived(selectedCardId !== null);
// Tip selection state
let selectedTip = $state<number | null>(null);
@@ -84,96 +76,6 @@
];
});
const canSaveCards = $derived(
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
);
// Card validation (matching UserPaymentModal pattern)
function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
const newCardExpiryParts = $derived(parseExpiryParts(newCardExpiry));
const isNewCardExpiryPast = $derived(
newCardExpiryParts !== null &&
(() => {
const expiryYearMonth = newCardExpiryParts.year * 12 + newCardExpiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);
const hasNewCardInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null
);
const newCardError = $derived(
showNewCardForm || savedCards.length === 0
? cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched && hasNewCardInvalidMonth
? 'Invalid expiry month'
: cardExpiryTouched && isNewCardExpiryPast
? 'This card has expired'
: cardExpiryTouched && newCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(newCardExpiry)
? 'Enter expiry as MM/YY'
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(newCardNumber) &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3
? null
: newCardNumber.length === 0 &&
newCardExpiry.length === 0 &&
newCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
);
const isCardValid = $derived(
selectedCardId !== null ||
(isValidLuhn(newCardNumber) &&
newCardExpiryParts !== null &&
!isNewCardExpiryPast &&
newCardCVC.length >= 3)
);
function handleFieldBlur(field: string) {
if (field === 'cardNumber') cardNumberTouched = true;
else if (field === 'cardExpiry') cardExpiryTouched = true;
else if (field === 'cardCVC') cardCVCTouched = true;
}
function handleFieldInput(field: string) {
if (field === 'cardNumber') cardNumberTouched = false;
else if (field === 'cardExpiry') cardExpiryTouched = false;
else if (field === 'cardCVC') cardCVCTouched = false;
}
const onfieldblur = handleFieldBlur;
const onfieldinput = handleFieldInput;
// Format functions
function formatDate(dateStr: string): string {
const date = new SvelteDate(dateStr);
@@ -291,31 +193,17 @@
return;
}
if (savedCards.length > 0 && !selectedCardId && !showNewCardForm) {
toast.error('Please select a payment method');
if (savedCards.length === 0) {
toast.error('Please add a saved card or contact the salon to pay by another method');
return;
}
if ((showNewCardForm || savedCards.length === 0) && !newCardNumber.replace(/\s/g, '')) {
toast.error('Please enter your card number');
if (!selectedCardId) {
toast.error('Please select a payment method');
return;
}
paymentState = 'processing';
// Validate card details for new card payments
if (showNewCardForm || savedCards.length === 0) {
if (
!isValidLuhn(newCardNumber) ||
!/^\d{2}\/\d{2}$/.test(newCardExpiry) ||
isNewCardExpiryPast ||
newCardCVC.length < 3
) {
paymentState = 'idle';
toast.error(newCardError || 'Please enter valid credit card details');
return;
}
}
try {
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
tipIdempotencyKey = crypto.randomUUID();
@@ -324,16 +212,10 @@
const amountInPence = Math.round(tipAmount * 100);
const body: Record<string, unknown> = {
amount: amountInPence,
idempotency_key: tipIdempotencyKey
idempotency_key: tipIdempotencyKey,
card_id: selectedCardId
};
if (showNewCardForm || savedCards.length === 0) {
body.new_card_token = newCardNumber.replace(/\s/g, '');
body.save_card = saveCardForFuture;
} else {
body.card_id = selectedCardId;
}
const response = await apiFetch(`/api/bookings/${bookingId}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -573,13 +455,10 @@
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
card.id && !showNewCardForm
card.id
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = card.id;
showNewCardForm = false;
}}
onclick={() => (selectedCardId = card.id)}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
@@ -590,67 +469,22 @@
>
</div>
</div>
{#if selectedCardId === card.id && !showNewCardForm}
{#if selectedCardId === card.id}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = null;
showNewCardForm = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700"
>Use a new card</span
>
</div>
{#if showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{#if showNewCardForm}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
{onfieldblur}
{onfieldinput}
/>
{#if newCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
{/if}
</div>
{/if}
</div>
{:else}
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
{onfieldblur}
{onfieldinput}
/>
{#if newCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
{/if}
<div class="space-y-3">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">
Payment Method
</span>
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
/>
</div>
{/if}
</Card.Content>
</Card.Root>
+14 -189
View File
@@ -6,8 +6,8 @@
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import CardInput from '$lib/components/payments/CardInput.svelte';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -59,16 +59,8 @@
// Card selection state (same pattern as UserPaymentModal)
let savedCards = $state<SavedCard[]>([]);
let selectedCardId = $state<string | null>(null);
let showNewCardForm = $state(false);
// New card form state
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let saveCardForFuture = $state(false);
let cardNumberTouched = $state(false);
let cardExpiryTouched = $state(false);
let cardCVCTouched = $state(false);
const isCardValid = $derived(selectedCardId !== null);
let selectedTip = $state<number | null>(null);
let customTip = $state('');
@@ -93,99 +85,6 @@
];
});
const canSaveCards = $derived(
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
);
// Card validation (matching UserPaymentModal pattern)
function isValidLuhn(cardNumber: string): boolean {
const s = cardNumber.replace(/\D/g, '');
let sum = 0;
let alternate = false;
for (let i = s.length - 1; i >= 0; i--) {
let n = parseInt(s[i], 10);
if (alternate) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alternate = !alternate;
}
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
function handleFieldBlur(field: string) {
if (field === 'cardNumber') cardNumberTouched = true;
else if (field === 'cardExpiry') cardExpiryTouched = true;
else if (field === 'cardCVC') cardCVCTouched = true;
}
function handleFieldInput(field: string) {
if (field === 'cardNumber') cardNumberTouched = false;
else if (field === 'cardExpiry') cardExpiryTouched = false;
else if (field === 'cardCVC') cardCVCTouched = false;
}
const onfieldblur = handleFieldBlur;
const onfieldinput = handleFieldInput;
const newCardExpiryParts = $derived(parseExpiryParts(newCardExpiry));
const isNewCardExpiryPast = $derived(
newCardExpiryParts !== null &&
(() => {
// parseExpiryParts returns months 1-indexed (1=Jan, 12=Dec).
// Use year-month arithmetic to avoid Date constructor 0-index confusion.
const expiryYearMonth = newCardExpiryParts.year * 12 + newCardExpiryParts.month;
const now = new SvelteDate();
const currentYearMonth = now.getFullYear() * 12 + now.getMonth() + 1;
return expiryYearMonth < currentYearMonth;
})()
);
const hasNewCardInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null
);
const newCardError = $derived(
showNewCardForm || savedCards.length === 0
? cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched && hasNewCardInvalidMonth
? 'Invalid expiry month'
: cardExpiryTouched && isNewCardExpiryPast
? 'This card has expired'
: cardExpiryTouched && newCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(newCardExpiry)
? 'Enter expiry as MM/YY'
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(newCardNumber) &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3
? null
: newCardNumber.length === 0 &&
newCardExpiry.length === 0 &&
newCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
);
const isCardValid = $derived(
selectedCardId !== null ||
(isValidLuhn(newCardNumber) &&
newCardExpiryParts !== null &&
!isNewCardExpiryPast &&
newCardCVC.length >= 3)
);
function formatDate(dateStr: string): string {
const date = new SvelteDate(dateStr);
return date.toLocaleDateString('en-GB', {
@@ -243,31 +142,17 @@
return;
}
if (savedCards.length > 0 && !selectedCardId && !showNewCardForm) {
toast.error('Please select a payment method');
if (savedCards.length === 0) {
toast.error('Please add a saved card or contact the salon to pay by another method');
return;
}
if ((showNewCardForm || savedCards.length === 0) && !newCardNumber.replace(/\s/g, '')) {
toast.error('Please enter your card number');
if (!selectedCardId) {
toast.error('Please select a payment method');
return;
}
paymentState = 'processing';
// Validate card details for new card payments
if (showNewCardForm || savedCards.length === 0) {
if (
!isValidLuhn(newCardNumber) ||
!/^\d{2}\/\d{2}$/.test(newCardExpiry) ||
isNewCardExpiryPast ||
newCardCVC.length < 3
) {
paymentState = 'idle';
toast.error(newCardError || 'Please enter valid credit card details');
return;
}
}
try {
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
tipIdempotencyKey = crypto.randomUUID();
@@ -276,16 +161,10 @@
const amountInPence = Math.round(tipAmount * 100);
const body: Record<string, unknown> = {
amount: amountInPence,
idempotency_key: tipIdempotencyKey
idempotency_key: tipIdempotencyKey,
card_id: selectedCardId
};
if (showNewCardForm || savedCards.length === 0) {
body.new_card_token = newCardNumber.replace(/\s/g, '');
body.save_card = saveCardForFuture;
} else {
body.card_id = selectedCardId;
}
const response = await apiFetch(`/api/bookings/${booking.id}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -594,13 +473,10 @@
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
card.id && !showNewCardForm
card.id
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = card.id;
showNewCardForm = false;
}}
onclick={() => (selectedCardId = card.id)}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
@@ -611,67 +487,16 @@
>
</div>
</div>
{#if selectedCardId === card.id && !showNewCardForm}
{#if selectedCardId === card.id}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = null;
showNewCardForm = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700"
>Use a new card</span
>
</div>
{#if showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{/if}
{#if showNewCardForm && savedCards.length > 0}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
{onfieldblur}
{onfieldinput}
/>
{#if newCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
{/if}
</div>
{:else if savedCards.length === 0}
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
{onfieldblur}
{onfieldinput}
{:else}
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
/>
{#if newCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
{/if}
{/if}
</div>
</Card.Content>
+3 -1
View File
@@ -817,7 +817,7 @@ INSERT INTO business_settings (
'https://www.website.co.uk'
);
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'deposit_paid', 'edit_request', 'edit_requested', 'new_booking', 'deposit_not_paid_by_deadline', 'gift_card_purchased_for_friend', 'default_hours_changed');
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'deposit_paid', 'edit_request', 'edit_requested', 'new_booking', 'deposit_not_paid_by_deadline', 'gift_card_purchased_for_friend', 'default_hours_changed', 'refund_failed');
CREATE TABLE admin_notifications (
id CHAR(12) PRIMARY KEY DEFAULT generate_admin_notifications_id(),
@@ -1988,6 +1988,8 @@ CREATE TABLE refunds (
amount NUMERIC(10,2) NOT NULL CHECK (amount > 0),
square_refund_id TEXT,
status payment_status NOT NULL DEFAULT 'pending',
refund_attempts INT NOT NULL DEFAULT 0,
origin VARCHAR(16) NOT NULL DEFAULT 'manual',
reason TEXT NOT NULL,
idempotency_key VARCHAR(64) UNIQUE,
created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL,