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:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.';
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user