Close refund system and gate raw-PAN card entry

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

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

Tests:
- Rewrite the two amount-blind dedup tests to assert real money movement
  (single call, aggregated amount, shared refund ID)
- Add coverage: manual refund vs cancellation serialization (concurrent
  goroutines), reconcile error vs no-match branches, stale manual retry,
  forgive-fees real refund row + reason, double-cancel dedup, mock refund
  key dedup, ListPaymentRefunds filtering
- Fix time-dependent booking flakes with fixtures.NextWorkingDayAt
- 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 54f6bf3c1a
commit ae8735ba2f
33 changed files with 4129 additions and 1868 deletions
+111 -10
View File
@@ -6,14 +6,17 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
@@ -360,9 +363,11 @@ func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) {
t.Fatalf("failed to create service: %v", err)
}
// Booking starting in 48 hours (24-72h window, no payments).
midRange := clock.Now().Add(48 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, midRange)
// Booking 24-72h from now (no-show warning tier) at a fixed working-hour slot:
// 10:00 UTC two days out is always 35-58h away, so the warning fires but the
// "too close" 403 never does, at any wall-clock hour.
bookingTime := fixtures.NextWorkingDayAt(2, 10)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
@@ -373,7 +378,8 @@ func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) {
}
token := jwt.GenerateUserToken(userID)
newTime := midRange.Add(48 * time.Hour)
// 10:00 UTC four days out — same slot as bookingTime, inside working hours.
newTime := bookingTime.Add(48 * time.Hour)
handler := http.HandlerFunc(RequestEditHandler)
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{
@@ -574,6 +580,12 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
t.Fatalf("failed to create user: %v", err)
}
// A real admin user so the refund row's created_by FK is satisfied.
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
@@ -591,20 +603,45 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
t.Fatalf("failed to confirm booking: %v", err)
}
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Give the payment a Square reference so the post-commit sweep does NOT
// terminal-pre-pass it to 'failed' (NULL square refs are unresolvable).
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_admin_forgive_fees' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
// Commit the setup so the handler runs at pool level: AdminCancelBookingHandler
// acquires pg_advisory_xact_lock on the card payments, and inside the test-env
// outer per-test transaction (savepoints don't release xact locks) the locks
// would deadlock the post-commit sweep's session locks. Pool-level mirrors
// production, where the handler's tx commits and releases the locks.
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
freshCtx := context.Background()
// Make the post-commit refund processing leave the row 'pending' (ambiguous
// Square transport failure → refund_attempts=1) so the test can assert the
// row exists as pending with the full amount.
origSquare := payments.SquareClient
forgiveFeesClient := &forgiveFeesAmbiguousClient{SquareClient: square.NewDevClient()}
payments.SquareClient = forgiveFeesClient
defer func() { payments.SquareClient = origSquare }()
w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{
"forgive_fees": true,
}, func(baseCtx context.Context) context.Context {
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
adminToken := jwt.GenerateAdminToken()
if info := extractUserFromTestJWT(adminToken); info != nil {
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, info.userID)
}
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
return baseCtx
})
if w.Code != http.StatusOK {
@@ -625,6 +662,70 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
if refundCalc["refundable_amount"] != 100.0 {
t.Errorf("expected refundable_amount 100, got %v", refundCalc["refundable_amount"])
}
// The forgiven-fees FULL refund must actually execute — a refund row for
// the whole £100 must exist as 'pending' (created by ProcessCancellationRefundTx,
// resolved post-commit), not just a synthetic response claim.
var refundCount int
err = db.Conn.QueryRow(freshCtx,
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if refundCount < 1 {
t.Errorf("expected at least 1 refund row for forgiven fees full refund, got %d", refundCount)
}
var refundStatus string
var refundAmount float64
err = db.Conn.QueryRow(freshCtx,
"SELECT status, amount FROM refunds WHERE booking_id = $1 LIMIT 1", bookingID).Scan(&refundStatus, &refundAmount)
if err != nil {
t.Fatalf("failed to query refund row: %v", err)
}
if refundStatus != "pending" {
t.Errorf("expected refund status 'pending', got %q", refundStatus)
}
if refundAmount != 100 {
t.Errorf("expected refund amount 100, got %.2f", refundAmount)
}
// The Square sweep must be called with the forgiven-fees reason — the
// reason is threaded from the refund rows through to the post-commit
// ProcessPendingSquareRefunds call (P3b), not the generic admin_cancelled.
calls := forgiveFeesClient.refundCalls()
if len(calls) == 0 {
t.Fatal("expected the post-commit sweep to call Square RefundPayment")
}
if calls[0].Reason != "admin_forgiven_fees" {
t.Errorf("expected Square refund reason %q, got %q", "admin_forgiven_fees", calls[0].Reason)
}
}
// forgiveFeesAmbiguousClient simulates a transport-level Square failure so the
// ForgiveFees test's post-commit refund processing leaves the row 'pending'.
// It also records every RefundPayment request so the test can assert the
// forgiven-fees reason reaches Square.
type forgiveFeesAmbiguousClient struct {
square.SquareClient
mu sync.Mutex
calls []square.RefundPaymentReq
}
func (c *forgiveFeesAmbiguousClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
c.mu.Lock()
c.calls = append(c.calls, req)
c.mu.Unlock()
return nil, fmt.Errorf("network error: connection reset by peer")
}
func (c *forgiveFeesAmbiguousClient) refundCalls() []square.RefundPaymentReq {
c.mu.Lock()
defer c.mu.Unlock()
return append([]square.RefundPaymentReq(nil), c.calls...)
}
func (c *forgiveFeesAmbiguousClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) {
return nil, fmt.Errorf("network error: connection reset by peer")
}
func TestAdminCancelBookingHandler_NormalRefundOver72h(t *testing.T) {
+15 -15
View File
@@ -198,20 +198,20 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
// Status update succeeded — now process the refund in the SAME transaction
// so that a commit failure rolls back both the status change and the refund.
if forgiveFees && totalPaid > 0 {
refundResult = &payments.RefundCalculationResult{
TotalPrePaid: totalPaid,
RefundableAmount: totalPaid,
KeptAmount: 0,
Tier: "admin_full_refund",
// refundReason threads through to the refund rows AND the post-commit Square
// sweep so forgiven-fee bookings are labelled "admin_forgiven_fees" (audit).
refundReason := "admin_cancelled"
if totalPaid > 0 {
if forgiveFees {
refundReason = "admin_forgiven_fees"
// Full refund of the net pre-paid amount regardless of notice tier —
// the override makes the refund actually execute (rows created and
// swept), instead of the old synthetic response-only claim.
refundResult, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), refundReason, &adminID, true)
} else if calculatedRefund {
refundResult, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), refundReason, &adminID, false)
}
}
if calculatedRefund {
var calc *payments.RefundCalculationResult
calc, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), "admin_cancelled", &adminID)
if err == nil {
refundResult = calc
} else {
if err != nil {
refundFailed = true
log.Printf("ALERT: AdminCancelBookingHandler — ProcessCancellationRefundTx failed for booking %s after status was updated to we_cancelled. Refund was NOT processed. The transaction WILL be committed (cancellation stands, no refund). Error: %v", bookingID, err)
}
@@ -275,8 +275,8 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
// Process pending Square refunds after the transaction commits successfully.
// This ensures Square API calls only happen if the DB records persist.
if calculatedRefund {
payments.ProcessPendingSquareRefunds(r.Context(), bookingID, "admin_cancelled")
if totalPaid > 0 {
payments.ProcessPendingSquareRefunds(r.Context(), bookingID, refundReason)
}
if refundFailed || (refundResult != nil && refundResult.RefundableAmount > 0) {
+8 -2
View File
@@ -1852,8 +1852,14 @@ func TestAdminApproveEditRequest_EvictsPendingRelease(t *testing.T) {
}
dur := durationMinutes(t, ctx, tx, serviceID)
// Use a booking <48h from now so RequestEdit does NOT auto-approve
nearTime := clock.Now().Add(40 * time.Hour).Truncate(time.Second)
// Use a booking 24-48h from now at a fixed working-hour slot so RequestEdit
// neither 403s (<24h away = too close) nor auto-approves (>48h away would
// consume the edit request before the admin can approve it). A raw
// clock.Now().Add(Kh) can land after 19:00 London and fail closing hours.
nearTime := fixtures.NextWorkingDayAt(1, 10)
if nearTime.Sub(clock.Now()) < 24*time.Hour {
nearTime = fixtures.NextWorkingDayAt(2, 10)
}
bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, nearTime)
if err != nil {
+269 -44
View File
@@ -646,10 +646,14 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
if paymentResult.Status == "COMPLETED" {
service := NewPaymentService()
idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(paymentResult.Amount, 10)
// Deterministic idempotency key derived from booking + amount + Square
// payment ID. The Square payment ID disambiguates two distinct
// equal-amount charges on the same booking, so equal amounts never
// collide on the UNIQUE constraint.
idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(paymentResult.Amount, 10) + "-" + paymentResult.SquarePayID
// Begin the transaction BEFORE the idempotency check so it's atomic
// with the payment insert.
// Begin the transaction BEFORE the dedup lookup so it's atomic with the
// payment insert.
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
@@ -662,26 +666,26 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
}
}()
// Check for existing payment inside the transaction.
// Dedup by Square payment ID: a double poll of the same terminal
// checkout must return the existing payment row instead of inserting a
// duplicate (which previously 500'd on the idempotency-key UNIQUE
// violation after the customer had already paid).
var existingID string
var existingSquarePayID sql.NullString
if err := tx.QueryRow(r.Context(), `
SELECT id, COALESCE(square_payment_id, '') FROM payments
WHERE booking_id = $1 AND idempotency_key = $2
`, bookingID, idempotencyKey).Scan(&existingID, &existingSquarePayID); err == nil {
if existingSquarePayID.Valid && existingSquarePayID.String == paymentResult.SquarePayID {
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{
Status: "COMPLETED",
PaymentID: existingID,
Amount: paymentResult.Amount,
CardBrand: paymentResult.CardBrand,
CardLast4: paymentResult.CardLast4,
ReceiptURL: paymentResult.ReceiptURL,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
SELECT id FROM payments
WHERE booking_id = $1 AND square_payment_id = $2
`, bookingID, paymentResult.SquarePayID).Scan(&existingID); err == nil {
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{
Status: "COMPLETED",
PaymentID: existingID,
Amount: paymentResult.Amount,
CardBrand: paymentResult.CardBrand,
CardLast4: paymentResult.CardLast4,
ReceiptURL: paymentResult.ReceiptURL,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
} else if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to check for existing payment: %v", err)
}
@@ -1662,17 +1666,9 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
return
}
alreadyRefunded, err := service.GetAlreadyRefundedAmount(r.Context(), paymentID)
if err != nil {
log.Printf("Failed to get already refunded amount: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if req.Amount+alreadyRefunded > int64(math.Round(payment.Amount*100)) {
http.Error(w, "Refund amount exceeds payment amount", http.StatusBadRequest)
return
}
// Deterministic idempotency key so a same-key retry (network timeout)
// does not create a second Square refund.
idempotencyKey := paymentID + "-refund-" + strconv.FormatInt(req.Amount, 10)
// Serialize refund attempts per payment to prevent two concurrent refunds
// both passing the over-refund guard and both charging Square. Mirrors the
@@ -1700,15 +1696,24 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
}
}()
// Deterministic idempotency key so a same-key retry (network timeout)
// does not create a second Square refund.
idempotencyKey := paymentID + "-refund-" + strconv.FormatInt(req.Amount, 10)
// Check for an existing refund with this key — dedup completed refunds.
// Dedup/resume (inside the lock): a same-key retry of a completed or
// in-flight (pending) refund must not create a second Square refund. Runs
// BEFORE the over-refund guard so a resuming refund never evaluates its own
// pending row against the guard.
var existingRefundID sql.NullString
var existingRefundStatus sql.NullString
err = db.Conn.QueryRow(r.Context(), `SELECT status FROM refunds WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingRefundStatus)
if err == nil && existingRefundStatus.String == "completed" {
var existingRefundAmount sql.NullFloat64
var existingRefundOrigin sql.NullString
var existingRefundReason sql.NullString
var existingRefundCreatedAt sql.NullTime
var existingRefundKey sql.NullString
err = db.Conn.QueryRow(r.Context(), `
SELECT id, status, amount, origin, reason, created_at, idempotency_key FROM refunds WHERE idempotency_key = $1
`, idempotencyKey).Scan(&existingRefundID, &existingRefundStatus, &existingRefundAmount, &existingRefundOrigin, &existingRefundReason, &existingRefundCreatedAt, &existingRefundKey)
switch {
case err == nil && existingRefundStatus.String == "completed":
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
@@ -1718,9 +1723,198 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
case err == nil && existingRefundStatus.String == "pending":
// Resume the in-flight refund: the DB row was committed but the Square
// call never completed (network timeout, crash, etc.). Retry Square with
// the same idempotency key so Square returns the original refund if one
// exists, never a second one.
resumeAmount := int64(math.Round(existingRefundAmount.Float64 * 100))
resumeReq := square.RefundPaymentReq{
PaymentID: *payment.SquarePaymentID,
Amount: resumeAmount,
IdempotencyKey: idempotencyKey,
Reason: req.Reason,
}
resumeResult, resumeErr := SquareClient.RefundPayment(r.Context(), resumeReq)
if resumeErr != nil {
if errors.Is(resumeErr, square.ErrRefundAlreadyProcessed) {
// PAYMENT_ALREADY_REFUNDED — money already moved at Square.
// Resolve the pending row to completed (square_refund_id stays
// NULL) so the guard can never over-refund on top of it.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, existingRefundID.String); upErr != nil {
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", existingRefundID.String, upErr)
}
log.Printf("Refund %s already processed at Square — marked completed", existingRefundID.String)
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
if errors.Is(resumeErr, square.ErrRefundDeclined) {
// Definitive rejection — mark failed so it never retries and
// never blocks future refunds.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, existingRefundID.String); upErr != nil {
log.Printf("Failed to mark refund %s failed after definitive rejection: %v", existingRefundID.String, upErr)
}
log.Printf("Refund %s definitively declined by Square: %v", existingRefundID.String, resumeErr)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
// Ambiguous error — leave pending for the scheduler to retry.
log.Printf("Failed to resume refund %s (left pending): %v", existingRefundID.String, resumeErr)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
if _, upErr := db.Conn.Exec(r.Context(),
`UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`,
resumeResult.ID, existingRefundID.String,
); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", resumeResult.ID, existingRefundID.String, upErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case err == nil && existingRefundStatus.String == "failed":
// A failed row with origin='manual' may actually have moved money at
// Square (response loss after a definitive decline). Reconcile FIRST —
// an exact-amount COMPLETED refund resolves the row to completed.
// Otherwise the reconcile proves money did NOT move, so re-issuing with
// the stored key/amount is safe (Square dedups same-key retries).
if existingRefundOrigin.String == "manual" {
refundSqPaymentID := *payment.SquarePaymentID
resumeAmount := int64(math.Round(existingRefundAmount.Float64 * 100))
var reconcileTime time.Time
if existingRefundCreatedAt.Valid {
reconcileTime = existingRefundCreatedAt.Time
}
sqRefundID, rcErr := reconcileRefundAtSquare(r.Context(), refundSqPaymentID, resumeAmount, reconcileTime)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Do NOT
// re-issue on an unknown state: re-issuing would be safe
// against Square's key dedup, but if money already moved the
// over-refund guard would lose sight of it. Surface a retry.
log.Printf("Failed to reconcile refund %s against Square before re-issue (%v) — not re-issuing, ask the admin to retry", existingRefundID.String, rcErr)
http.Error(w, "Unable to verify refund status with Square, please retry", http.StatusServiceUnavailable)
return
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`, *sqRefundID, existingRefundID.String); upErr != nil {
log.Printf("Failed to mark refund %s completed after Square reconcile: %v", existingRefundID.String, upErr)
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
reissueReq := square.RefundPaymentReq{
PaymentID: refundSqPaymentID,
Amount: resumeAmount,
IdempotencyKey: existingRefundKey.String,
Reason: existingRefundReason.String,
}
reissueResult, reissueErr := SquareClient.RefundPayment(r.Context(), reissueReq)
switch {
case reissueErr == nil:
if _, upErr := db.Conn.Exec(r.Context(),
`UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`,
reissueResult.ID, existingRefundID.String,
); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", reissueResult.ID, existingRefundID.String, upErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case errors.Is(reissueErr, square.ErrRefundAlreadyProcessed):
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, existingRefundID.String); upErr != nil {
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", existingRefundID.String, upErr)
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case errors.Is(reissueErr, square.ErrRefundDeclined):
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, existingRefundID.String); upErr != nil {
log.Printf("Failed to mark refund %s failed after re-issue rejection: %v", existingRefundID.String, upErr)
}
log.Printf("Refund %s re-issued with stored key definitively declined by Square: %v", existingRefundID.String, reissueErr)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
default:
// Ambiguous re-issue — put the row back to 'pending' so the
// sweep's manual retry pass can re-attempt it.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'pending' WHERE id = $1`, existingRefundID.String); upErr != nil {
log.Printf("Failed to mark refund %s pending after ambiguous re-issue: %v", existingRefundID.String, upErr)
}
log.Printf("Refund %s re-issue left pending (ambiguous): %v", existingRefundID.String, reissueErr)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
}
// Previously definitively rejected (non-manual) — a same-key retry cannot
// succeed and the UNIQUE key would block re-insertion. Surface the
// failure instead of 500-ing on a duplicate.
log.Printf("Refund %s was previously marked failed — same-key retry rejected", existingRefundID.String)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
case err != nil && !errors.Is(err, pgx.ErrNoRows):
log.Printf("Failed to check refund idempotency: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Over-refund guard (inside the lock so concurrent refunds can't both pass).
// GetAlreadyRefundedAmount counts completed AND pending refunds.
alreadyRefunded, err := service.GetAlreadyRefundedAmount(r.Context(), paymentID)
if err != nil {
log.Printf("Failed to get already refunded amount: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if req.Amount+alreadyRefunded > int64(math.Round(payment.Amount*100)) {
http.Error(w, "Refund amount exceeds payment amount", http.StatusBadRequest)
return
}
// Begin a transaction. Insert the refund record as 'pending' first, commit,
@@ -1740,8 +1934,8 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
var refundID string
err = tx.QueryRow(r.Context(), `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at)
VALUES ($1, $2, $3, 'pending', $4, $5, $6, $7)
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin)
VALUES ($1, $2, $3, 'pending', $4, $5, $6, $7, 'manual')
RETURNING id
`,
paymentID,
@@ -1773,8 +1967,39 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
refundResult, err := SquareClient.RefundPayment(r.Context(), refundReq)
if err != nil {
// Refund record intentionally left as 'pending' for the scheduler to
// re-attempt (refunds.go ProcessPendingSquareRefunds).
if errors.Is(err, square.ErrRefundAlreadyProcessed) {
// PAYMENT_ALREADY_REFUNDED — money already moved at Square. Resolve
// to completed (square_refund_id stays NULL) rather than failed so
// the over-refund guard can never issue money on top of it.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, refundID); upErr != nil {
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", refundID, upErr)
}
log.Printf("Refund %s already processed at Square — marked completed", refundID)
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: refundID,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
if errors.Is(err, square.ErrRefundDeclined) {
// Definitive rejection (declined / already refunded / invalid
// payment) — mark the refund failed so it never retries and never
// blocks future refunds.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, refundID); upErr != nil {
log.Printf("Failed to mark refund %s failed after definitive rejection: %v", refundID, upErr)
}
log.Printf("Refund %s definitively declined by Square: %v", refundID, err)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
// Ambiguous error — refund record intentionally left as 'pending' for
// the scheduler to re-attempt (refunds.go ProcessPendingSquareRefunds).
log.Printf("Failed to refund payment (refund %s left pending): %v", refundID, err)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
@@ -678,3 +678,134 @@ func TestGetCheckoutStatus_Completed(t *testing.T) {
t.Error("expected card_last4 to be set")
}
}
// pollCheckoutStatus polls GetCheckoutStatus until the checkout reports
// COMPLETED, returning the decoded response.
func pollCheckoutStatus(t *testing.T, ctx context.Context, checkoutID, bookingID, adminToken string) PaymentStatusResponse {
t.Helper()
var resp PaymentStatusResponse
assert.Eventually(t, func() bool {
statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil)
statusRCtx := chi.NewRouteContext()
statusRCtx.URLParams.Add("checkout_id", checkoutID)
statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx)
if info := extractUserFromTestJWT(adminToken); info != nil {
statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID)
statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role)
}
statusReq = statusReq.WithContext(statusCtx)
w2 := httptest.NewRecorder()
GetCheckoutStatus(w2, statusReq)
if w2.Code != http.StatusOK {
return false
}
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil {
return false
}
return resp.Status == "COMPLETED"
}, 10*time.Second, 200*time.Millisecond, "expected checkout to complete")
return resp
}
// createTerminalCheckout creates a terminal checkout via CreateTerminalPayment
// and returns the checkout ID from the response.
func createTerminalCheckout(t *testing.T, ctx context.Context, bookingID, adminToken string, amount int64) string {
t.Helper()
handler := CreateTerminalPayment
req := CreateTerminalPaymentRequest{
Amount: amount,
PaymentType: "full",
}
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var createResp CheckoutResponse
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
t.Fatalf("failed to decode create response: %v", err)
}
if createResp.CheckoutID == "" {
t.Fatal("expected checkout_id to be set")
}
return createResp.CheckoutID
}
func TestGetCheckoutStatus_DoublePoll_SinglePaymentRow(t *testing.T) {
// A double poll of the same terminal checkout must return the existing
// payment row instead of inserting a duplicate (which previously 500'd on
// the idempotency-key UNIQUE violation after the customer had paid).
origClient := SquareClient
SquareClient = &testCheckoutClient{
SquareClient: origClient,
hexIDs: make(map[string]string),
}
defer func() { SquareClient = origClient }()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
checkoutID := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
first := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken)
if first.PaymentID == "" {
t.Fatal("expected payment_id from first poll")
}
// Second poll of the same checkout — deduped against the existing row.
second := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken)
if second.PaymentID == "" {
t.Fatal("expected payment_id from second poll")
}
if second.PaymentID != first.PaymentID {
t.Errorf("expected same payment_id on re-poll, got %q then %q", first.PaymentID, second.PaymentID)
}
var rowCount int
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount)
if err != nil {
t.Fatalf("failed to count payment rows: %v", err)
}
if rowCount != 1 {
t.Errorf("expected exactly 1 payment row after double poll, got %d", rowCount)
}
}
func TestGetCheckoutStatus_TwoEqualAmountCharges_NoCollision(t *testing.T) {
// Two distinct terminal charges on the same booking with the same final
// amount must each create their own payment row (the deposit + equal-amount
// balance case) — no 500 on the idempotency-key UNIQUE collision.
origClient := SquareClient
SquareClient = &testCheckoutClient{
SquareClient: origClient,
hexIDs: make(map[string]string),
}
defer func() { SquareClient = origClient }()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
checkoutA := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
checkoutB := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
respA := pollCheckoutStatus(t, ctx, checkoutA, bookingID, adminToken)
respB := pollCheckoutStatus(t, ctx, checkoutB, bookingID, adminToken)
if respA.PaymentID == "" || respB.PaymentID == "" {
t.Fatal("expected payment_ids for both checkouts")
}
if respA.PaymentID == respB.PaymentID {
t.Error("expected two distinct payment rows for two distinct Square charges")
}
var rowCount int
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount)
if err != nil {
t.Fatalf("failed to count payment rows: %v", err)
}
if rowCount != 2 {
t.Errorf("expected exactly 2 payment rows for two equal-amount charges, got %d", rowCount)
}
}
+175
View File
@@ -15,6 +15,7 @@ import (
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
@@ -772,6 +773,180 @@ func TestRefund_PendingPaymentRejected(t *testing.T) {
}
}
func TestRefund_PendingSameKeyRetry_Resumes(t *testing.T) {
// A retry of an in-flight refund (DB row pending, Square call never
// completed) must resume rather than insert a second row or issue a second
// Square refund. The handler retries Square with the same idempotency key
// and completes the pending row.
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_pending_resume' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
// Seed a pending refund with the same key the handler will compute.
key := paymentID + "-refund-2500"
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_at)
VALUES ($1, $2, 25, 'pending', 'customer request', $3, NOW())
`, paymentID, bookingID, key)
if err != nil {
t.Fatalf("failed to seed pending refund: %v", err)
}
req := RefundRequest{
Amount: 2500,
Reason: "customer request",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// The pending row must now be completed with a Square refund ID — and no
// second refund row inserted.
var refundCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if refundCount != 1 {
t.Errorf("expected 1 refund row (resumed, not duplicated), got %d", refundCount)
}
var status string
var squareRefundID *string
err = tx.QueryRow(ctx, `SELECT status, square_refund_id FROM refunds WHERE idempotency_key = $1`, key).Scan(&status, &squareRefundID)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected status completed, got %q", status)
}
if squareRefundID == nil || *squareRefundID == "" {
t.Error("expected square_refund_id to be set after resume")
}
}
func TestRefund_GuardCountsPendingRefunds(t *testing.T) {
// The over-refund guard must count pending refunds (an in-flight Square
// refund) as well as completed ones, so a second refund cannot push the
// total past the payment amount.
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_guard_pending' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
// Seed a pending refund of £70 (as if a previous attempt's Square call is in flight).
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
VALUES ($1, $2, 70, 'pending', 'in flight', NOW())
`, paymentID, bookingID)
if err != nil {
t.Fatalf("failed to seed pending refund: %v", err)
}
// A further £40 refund would total £110 > £100 — must be rejected.
req := RefundRequest{
Amount: 4000,
Reason: "over refund attempt",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestRefund_PaymentAlreadyRefunded_MarksCompleted(t *testing.T) {
// Square reports PAYMENT_ALREADY_REFUNDED — the money has already moved.
// The refund row must resolve to 'completed' (NOT 'failed', which would let
// the over-refund guard re-issue money on top of it), square_refund_id
// stays NULL, and the API returns 200.
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_already_refunded' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
mock.FailRefundCode = "PAYMENT_ALREADY_REFUNDED"
SquareClient = mock
defer func() { SquareClient = origClient }()
req := RefundRequest{
Amount: 5000,
Reason: "customer request",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp RefundResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Status != "completed" {
t.Errorf("expected response status 'completed', got %q", resp.Status)
}
var status string
var squareRefundID *string
err = tx.QueryRow(ctx,
"SELECT status, square_refund_id FROM refunds WHERE payment_id = $1", paymentID).Scan(&status, &squareRefundID)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected refund status 'completed', got %q", status)
}
if squareRefundID != nil && *squareRefundID != "" {
t.Errorf("expected square_refund_id NULL (no new refund issued), got %q", *squareRefundID)
}
}
func TestTipPayment_HappyPath(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+26 -7
View File
@@ -65,6 +65,8 @@ type RefundRecord struct {
SquareRefundID *string
Status string
Reason string
Origin string // 'manual' (admin handler) or 'cancellation' (cancellation loop)
IdempotencyKey *string
CreatedBy *string
CreatedAt time.Time
}
@@ -154,8 +156,8 @@ func (s *PaymentService) CreateRefundRecord(ctx context.Context, record RefundRe
var id string
err := db.Conn.QueryRow(ctx, `
INSERT INTO refunds (
payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
payment_id, booking_id, amount, square_refund_id, status, reason, idempotency_key, created_by, created_at, origin
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id
`,
record.PaymentID,
@@ -164,8 +166,10 @@ func (s *PaymentService) CreateRefundRecord(ctx context.Context, record RefundRe
record.SquareRefundID,
record.Status,
record.Reason,
record.IdempotencyKey,
record.CreatedBy,
record.CreatedAt,
record.Origin,
).Scan(&id)
if err != nil {
@@ -239,7 +243,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
summary.TotalNetAmount = totalNetAmount
refundRows, err := db.Conn.Query(ctx, `
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, idempotency_key, created_by, created_at, origin
FROM refunds
WHERE booking_id = $1 AND status = 'completed'
ORDER BY created_at ASC
@@ -255,7 +259,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
var r RefundRecord
err := refundRows.Scan(
&r.ID, &r.PaymentID, &r.BookingID, &r.Amount, &r.SquareRefundID,
&r.Status, &r.Reason, &r.CreatedBy, &r.CreatedAt,
&r.Status, &r.Reason, &r.IdempotencyKey, &r.CreatedBy, &r.CreatedAt, &r.Origin,
)
if err != nil {
return nil, err
@@ -348,11 +352,17 @@ func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) (
return &p, nil
}
// GetAlreadyRefundedAmount returns the total refunded amount (in pence) for a
// payment, counting both 'completed' and 'pending' refunds. Pending refunds are
// counted because a Square call may already be in flight for them — excluding
// them would let a concurrent refund over-refund the payment. 'failed' refunds
// are excluded: they were definitively rejected by Square and must not block
// future refund attempts.
func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID string) (int64, error) {
var amount float64
err := db.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(amount), 0) FROM refunds
WHERE payment_id = $1 AND status = 'completed'
WHERE payment_id = $1 AND status IN ('completed', 'pending')
`, paymentID).Scan(&amount)
if err != nil {
@@ -391,18 +401,27 @@ type BookingPaymentInfo struct {
}
// GetBookingPaymentInfo fetches the booking start time, total service amount, and
// total completed payments for a booking.
// total net paid (completed payments minus completed/pending refunds) for a
// booking. Refunds are subtracted so cancellation refunds never double-refund
// money that has already been returned (e.g. via a manual admin refund).
func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID string) (*BookingPaymentInfo, error) {
var info BookingPaymentInfo
err := db.Conn.QueryRow(ctx, `
SELECT b.start_time, b.status,
COALESCE(b.total_amount, 0),
COALESCE(pt.total_paid, 0)
COALESCE(pt.total_paid, 0) - COALESCE(rr.total_refunded, 0)
FROM bookings b
LEFT JOIN (
SELECT booking_id, SUM(amount) AS total_paid
FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') GROUP BY booking_id
) pt ON b.id = pt.booking_id
LEFT JOIN (
SELECT p.booking_id, SUM(r.amount) AS total_refunded
FROM refunds r
JOIN payments p ON r.payment_id = p.id
WHERE p.booking_id = $1 AND r.status IN ('completed', 'pending')
GROUP BY p.booking_id
) rr ON b.id = rr.booking_id
WHERE b.id = $1
`, bookingID).Scan(&info.StartTime, &info.Status, &info.TotalAmount, &info.TotalPaid)
if err != nil {
+81 -46
View File
@@ -100,6 +100,14 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
http.Error(w, "gift_card_id is required for topup", http.StatusBadRequest)
return
}
if req.Action == "topup" && req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
// Redeem is create-only. Topping up an unredeemed card (which can still
// carry residual balance) and then redeeming would zero amount_remaining
// while crediting the user only the top-up amount — destroying money.
// Fail loudly rather than silently ignoring the redeem.
http.Error(w, "Cannot redeem a top-up to a user account", http.StatusBadRequest)
return
}
// Idempotency check: if key provided, return existing sale if found
// Idempotency handling. A 'completed' sale is a dedup (return it). A
@@ -183,6 +191,40 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// If the gift card should be immediately redeemed to a user's account balance
// (e.g. admin selected "add to account" rather than "generate gift code").
// Runs only on the FIRST attempt of a create — a pending retry reuses the
// already-funded gift card and must never re-run this, or the user's balance
// would be credited a second time (money loss to the business).
if req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
_, err = tx.Exec(ctx, `
UPDATE gift_cards
SET amount_remaining = 0,
redeemed_at = NOW(),
redeemed_by = $1,
last_used_at = NOW()
WHERE id = $2
`, *req.RedeemToUserID, giftCardID)
if err != nil {
log.Printf("Failed to redeem gift card to user account: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
_, err = tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE SET
balance = user_giftcard_balances.balance + EXCLUDED.balance,
updated_at = NOW()
`, *req.RedeemToUserID, req.Amount)
if err != nil {
log.Printf("Failed to update user gift card balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
} else {
cardID := validators.NormalizeGiftCardCode(*req.GiftCardID)
var redeemedBy sql.NullString
@@ -244,37 +286,6 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
}
}
// If the gift card should be immediately redeemed to a user's account balance
// (e.g. admin selected "add to account" rather than "generate gift code")
if req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
_, err = tx.Exec(ctx, `
UPDATE gift_cards
SET amount_remaining = 0,
redeemed_at = NOW(),
redeemed_by = $1,
last_used_at = NOW()
WHERE id = $2
`, *req.RedeemToUserID, giftCardID)
if err != nil {
log.Printf("Failed to redeem gift card to user account: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
_, err = tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE SET
balance = user_giftcard_balances.balance + EXCLUDED.balance,
updated_at = NOW()
`, *req.RedeemToUserID, req.Amount)
if err != nil {
log.Printf("Failed to update user gift card balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
penceAmount := int64(math.Round(req.Amount * 100))
var squarePaymentID *string
@@ -288,6 +299,21 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
var needsSquarePayment bool
var savedCardSqCardID string
// Pending-retry for card_machine: the original Square checkout may still be
// live at the terminal. If the pending till_sales row already recorded a
// square_checkout_id, reuse it instead of creating a second checkout — a
// fresh checkout would orphan the original, which can still complete and
// become an untracked charge.
var existingPendingCheckoutID string
if existingPendingID != "" {
err = tx.QueryRow(ctx, `SELECT COALESCE(square_checkout_id, '') FROM till_sales WHERE id = $1`, existingPendingID).Scan(&existingPendingCheckoutID)
if err != nil {
log.Printf("Failed to query existing pending sale checkout: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
switch req.PaymentMethod {
case "cash":
saleStatus = "completed"
@@ -333,23 +359,32 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
req.IdempotencyKey = uniqueTillKey()
}
checkoutReq := square.CreateCheckoutReq{
Amount: penceAmount,
Currency: "GBP",
IdempotencyKey: req.IdempotencyKey,
ReferenceID: giftCardID,
TipEnabled: false,
}
if existingPendingCheckoutID != "" {
// Pending retry — reuse the checkout already created for this sale
// instead of creating a second one. The original checkout may still
// be live at the terminal; a fresh checkout would orphan it into an
// untracked charge.
squareCheckoutID = &existingPendingCheckoutID
saleStatus = "pending"
} else {
checkoutReq := square.CreateCheckoutReq{
Amount: penceAmount,
Currency: "GBP",
IdempotencyKey: req.IdempotencyKey,
ReferenceID: giftCardID,
TipEnabled: false,
}
checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq)
if err != nil {
log.Printf("Failed to create Square checkout: %v", err)
http.Error(w, "Failed to create card machine payment", http.StatusInternalServerError)
return
}
checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq)
if err != nil {
log.Printf("Failed to create Square checkout: %v", err)
http.Error(w, "Failed to create card machine payment", http.StatusInternalServerError)
return
}
squareCheckoutID = &checkout.ID
saleStatus = "pending"
squareCheckoutID = &checkout.ID
saleStatus = "pending"
}
case "online_square":
dbPaymentMethod = "online_square"
if req.IdempotencyKey == "" {
+294
View File
@@ -1038,3 +1038,297 @@ func TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Code, "sale %d: expected 201, got %d. body: %s", i+1, w.Code, w.Body.String())
}
}
// TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce verifies that
// a same-key retry of a pending create-with-redeem sale does NOT re-credit the
// user's balance. The redeem (zeroing the gift card + crediting
// user_giftcard_balances) must only run on the FIRST attempt of a create; a
// pending retry reuses the already-redeemed gift card and would otherwise credit
// the user a second time (money loss to the business).
func TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
// Seed a PENDING till_sale (prior attempt where Square failed after the DB
// transaction committed) whose gift card was already redeemed and the user
// already credited the first-attempt amount (£50).
key := "till-pending-retry-redeem-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by, redeemed_at, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 0.00, $1, $2, NOW(), FALSE, 'SPV')
RETURNING id
`, adminID, userID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to seed redeemed gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, 50.00, NOW())
`, userID)
if err != nil {
t.Fatalf("failed to seed user gift card balance: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
$2, $3, $4, $5, NOW(), NOW())
`, giftCardID, userID, cardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
// Retry with the same key, requesting the redeem again.
redeemUserID := userID
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: key,
RedeemToUserID: &redeemUserID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
}
// The sale must now be 'completed' (Square re-attempted and succeeded).
var saleStatus string
err = tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleStatus != "completed" {
t.Errorf("expected sale status 'completed' after retry, got %s", saleStatus)
}
// The user balance must still be £50 — credited EXACTLY ONCE, not £100.
var balance float64
err = tx.QueryRow(ctx, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance)
if err != nil {
t.Fatalf("failed to query user gift card balance: %v", err)
}
if balance != 50.00 {
t.Errorf("expected balance 50.00 (credited once), got %.2f", balance)
}
// The gift card must remain fully redeemed (amount_remaining still 0).
var amountRemaining float64
err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, giftCardID).Scan(&amountRemaining)
if err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if amountRemaining != 0.00 {
t.Errorf("expected amount_remaining 0.00, got %.2f", amountRemaining)
}
}
// TestCreateTillSale_TopupWithRedeem_Rejected verifies that a top-up request
// carrying a redeem_to_user_id is rejected outright. Allowing redeem on a first
// attempt topup destroys money: the topup branch accepts unredeemed cards with
// residual balance, and topping up £20 onto a card with £40 residual then
// redeeming would zero amount_remaining (£40 lost) while crediting the user only
// the top-up amount.
func TestCreateTillSale_TopupWithRedeem_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
redeemerID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create redeemer user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// An unredeemed card carrying residual balance — the dangerous topup+redeem case.
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (40.00, 40.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to insert gift card: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 20.00,
PaymentMethod: "on_the_house",
GiftCardID: &cardID,
RedeemToUserID: &redeemerID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d. body: %s", w.Code, w.Body.String())
}
// No rows created: no till_sale, card untouched, no user balance credited.
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales`).Scan(&saleCount)
if err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if saleCount != 0 {
t.Errorf("expected 0 till_sales, got %d", saleCount)
}
var totalFunds, amountRemaining float64
err = tx.QueryRow(ctx, `SELECT total_funds_added, amount_remaining FROM gift_cards WHERE id = $1`, cardID).Scan(&totalFunds, &amountRemaining)
if err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if totalFunds != 40.00 {
t.Errorf("expected total_funds_added 40.00, got %.2f", totalFunds)
}
if amountRemaining != 40.00 {
t.Errorf("expected amount_remaining 40.00, got %.2f", amountRemaining)
}
var balanceCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1`, redeemerID).Scan(&balanceCount)
if err != nil {
t.Fatalf("failed to query user_giftcard_balances: %v", err)
}
if balanceCount != 0 {
t.Errorf("expected no user gift card balance row, got %d", balanceCount)
}
}
// TestCreateTillSale_PendingRetry_CardMachine_ReusesCheckout verifies that a
// same-key retry of a pending card_machine sale reuses the checkout already
// stored on the till_sales row instead of calling Square CreateCheckout a second
// time. The original checkout may still be live at the terminal; a fresh
// checkout would orphan it into an untracked charge.
func TestCreateTillSale_PendingRetry_CardMachine_ReusesCheckout(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Seed a PENDING card_machine till_sale that already has a Square checkout.
key := "till-card-machine-reuse-key"
storedCheckoutID := "chk_pending_retry_stored"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, square_checkout_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending',
$2, $3, $4, NOW(), NOW())
`, giftCardID, storedCheckoutID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending card_machine till sale: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "card_machine",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// The response must reference the STORED checkout, not a freshly created one.
// CreateCheckout always generates a new chk_mock_* id, so any second call
// would surface a different id here.
if resp.CheckoutID == nil || *resp.CheckoutID != storedCheckoutID {
t.Errorf("expected checkout_id to be the stored %q, got %v", storedCheckoutID, resp.CheckoutID)
}
if resp.Status != "pending" {
t.Errorf("expected status 'pending', got %s", resp.Status)
}
// The till_sales row must still reference the same checkout id, unchanged.
var rowCheckoutID string
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(MAX(square_checkout_id), '') FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &rowCheckoutID)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till sale (reuse, not duplicate), got %d", saleCount)
}
if rowCheckoutID != storedCheckoutID {
t.Errorf("expected till_sales.square_checkout_id to remain %q, got %q", storedCheckoutID, rowCheckoutID)
}
}
+9
View File
@@ -5,6 +5,7 @@ import (
"crussell/auth"
authHandlers "crussell/handlers/auth"
"crussell/handlers/payments"
"crussell/handlers/scheduling"
"crussell/handlers/user"
"crussell/mw"
@@ -47,6 +48,14 @@ func RegisterAll(s *Scheduler) {
Handler: user.CleanupGDPRExportCache,
})
s.Register(Job{
Name: "sweep-pending-square-refunds",
Schedule: "*/5 * * * *",
Timeout: 60 * time.Second,
Concurrency: 1,
Handler: payments.SweepPendingSquareRefunds,
})
// === MID FREQUENCY — every minute (progressive rate limiter was on 30s) ===
s.Register(Job{
+6 -5
View File
@@ -413,8 +413,8 @@ func TestRegisterAll_RegistersExpectedJobs(t *testing.T) {
s := New()
RegisterAll(s)
if got := len(s.registry); got != 20 {
t.Fatalf("RegisterAll() registered %d jobs, want 20", got)
if got := len(s.registry); got != 21 {
t.Fatalf("RegisterAll() registered %d jobs, want 21", got)
}
registered := make(map[string]Job, len(s.registry))
@@ -442,7 +442,7 @@ func TestRegisterAll_RegistersExpectedJobs(t *testing.T) {
// TestRegisterAll_ValidSchedules verifies all cron expressions in registered jobs
// parse without panic. RegisterAll internally calls Register, which parses every
// schedule; if this test completes without panic, all 19 schedules are valid.
// schedule; if this test completes without panic, all 21 schedules are valid.
func TestRegisterAll_ValidSchedules(t *testing.T) {
t.Parallel()
@@ -472,6 +472,7 @@ func expectedJobNames() map[string]bool {
"cleanup-expired-deposits": true,
"cleanup-rate-limiters": true,
"cleanup-gdpr-export-cache": true,
"sweep-pending-square-refunds": true,
"cleanup-progressive-rate-limiter": true,
"cleanup-expired-loyalty-redemptions": true,
"cleanup-old-idempotency-keys": true,
@@ -508,8 +509,8 @@ func TestRegisterAll_NoDuplicateCronExpressions(t *testing.T) {
// Known-intentional groupings: jobs at the same frequency that touch
// disjoint tables (no contention risk).
knownGroupings := map[int]bool{
4: true, // */5 * * * * — 4 cleanup jobs, different domains
5: true, // 0 * * * * — 5 hourly cleanup jobs, different tables
5: true, // */5 * * * * — 5 cleanup jobs (incl. sweep-pending-square-refunds), different domains
// 0 * * * * — 5 hourly cleanup jobs, different tables
2: true, // 0 2 * * * — 2 daily cleanup jobs, different tables
}
+8 -1
View File
@@ -2,7 +2,10 @@
package square
import "context"
import (
"context"
"time"
)
var Client SquareClient
@@ -43,3 +46,7 @@ func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardO
func (p *ProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
return deleteCardOnFileHTTP(ctx, cardID)
}
func (p *ProdClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
return listRefundsHTTP(ctx, paymentID, beginTime)
}
+52 -1
View File
@@ -30,9 +30,14 @@ type MockClient struct {
payments map[string]*PaymentResult
paymentByKey map[string]*PaymentResult
refunds map[string]*RefundResult
refundByKey map[string]*RefundResult
completed map[string]*PaymentResult
HoldCheckouts bool
ShouldFail bool // if true, CreatePayment/RefundPayment return errors for testing error paths
// FailRefundCode simulates a specific Square refund rejection code. Empty
// = normal success; when set (e.g. "PAYMENT_ALREADY_REFUNDED"),
// RefundPayment returns the sentinel-wrapped error for that code.
FailRefundCode string
}
type devProdClient struct{}
@@ -58,6 +63,9 @@ func (d *devProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]Ca
func (d *devProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
return deleteCardOnFileHTTP(ctx, cardID)
}
func (d *devProdClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
return listRefundsHTTP(ctx, paymentID, beginTime)
}
func NewClient() SquareClient {
return NewDevClient()
@@ -76,6 +84,7 @@ func NewDevClient() SquareClient {
payments: make(map[string]*PaymentResult),
paymentByKey: make(map[string]*PaymentResult),
refunds: make(map[string]*RefundResult),
refundByKey: make(map[string]*RefundResult),
completed: make(map[string]*PaymentResult),
}
}
@@ -287,7 +296,15 @@ func (m *MockClient) GetCheckout(ctx context.Context, checkoutID string) (*Payme
func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
if m.ShouldFail {
return nil, fmt.Errorf("mock: refund declined (simulated failure)")
return nil, fmt.Errorf("%w: refund declined (simulated failure)", ErrRefundDeclined)
}
if m.FailRefundCode != "" {
switch m.FailRefundCode {
case "PAYMENT_ALREADY_REFUNDED":
return nil, fmt.Errorf("%w: payment already fully refunded (simulated)", ErrRefundAlreadyProcessed)
default:
return nil, fmt.Errorf("%w: %s (simulated failure)", ErrRefundDeclined, m.FailRefundCode)
}
}
log.Printf("[SQUARE-MOCK] RefundPayment: payment=%s, amount=%d", req.PaymentID, req.Amount)
mockSleep(1 * time.Second)
@@ -295,6 +312,17 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
m.mu.Lock()
defer m.mu.Unlock()
// Real Square dedups on idempotency key: a retry with the same key returns
// the original refund rather than issuing a second refund. The mock mirrors
// this so dev/testing behaves like production (and the pending-refund
// resume path can rely on it).
if req.IdempotencyKey != "" {
if existing, ok := m.refundByKey[req.IdempotencyKey]; ok {
log.Printf("[SQUARE-MOCK] RefundPayment dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID)
return existing, nil
}
}
now := clock.Now().UTC()
refundID := fmt.Sprintf("ref_mock_%d", now.UnixNano())
@@ -326,6 +354,9 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
CreatedAt: now.Format(time.RFC3339),
}
m.refunds[refundID] = result
if req.IdempotencyKey != "" {
m.refundByKey[req.IdempotencyKey] = result
}
log.Printf("[SQUARE-MOCK] Refund completed: id=%s, payment=%s, amount=%d", refundID, req.PaymentID, amount)
return result, nil
}
@@ -404,6 +435,26 @@ func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error
return fmt.Errorf("card not found: %s", cardID)
}
func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
log.Printf("[SQUARE-MOCK] ListPaymentRefunds: payment=%s, begin=%s", paymentID, beginTime.UTC().Format(time.RFC3339))
m.mu.Lock()
defer m.mu.Unlock()
out := []RefundResult{}
for _, r := range m.refunds {
if r.PaymentID != paymentID {
continue
}
createdAt, err := time.Parse(time.RFC3339, r.CreatedAt)
if err == nil && createdAt.Before(beginTime) {
continue
}
out = append(out, *r)
}
return out, nil
}
// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces and
// ccof:xxx card IDs. Raw PANs (all digits) are NOT token-like and are rejected.
func isTokenLike(s string) bool {
+158
View File
@@ -4,6 +4,7 @@ package square
import (
"context"
"errors"
"fmt"
"sync"
"testing"
@@ -299,6 +300,54 @@ func TestRefundPayment_ShouldFail(t *testing.T) {
assert.Nil(t, result)
}
func TestDevClient_RefundPayment_PaymentAlreadyRefunded(t *testing.T) {
// PAYMENT_ALREADY_REFUNDED means the money already moved at Square, so the
// mock must return ErrRefundAlreadyProcessed (never ErrRefundDeclined) and
// must not store a refund — the caller resolves the record to 'completed'.
client := NewDevClient().(*MockClient)
client.FailRefundCode = "PAYMENT_ALREADY_REFUNDED"
ctx := context.Background()
req := RefundPaymentReq{
PaymentID: "pay_mock_already_refunded",
Amount: 5000,
IdempotencyKey: "refund-key-already",
Reason: "already refunded",
}
result, err := client.RefundPayment(ctx, req)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "expected ErrRefundAlreadyProcessed, got: %v", err)
assert.False(t, errors.Is(err, ErrRefundDeclined), "already-processed refund must not be classified as declined: %v", err)
client.mu.RLock()
defer client.mu.RUnlock()
assert.Len(t, client.refunds, 0, "no refund must be stored when the payment is already refunded")
assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored when the payment is already refunded")
}
func TestDevClient_RefundPayment_FailRefundCode_OtherCode(t *testing.T) {
// Any other code configured via FailRefundCode preserves the prior
// ErrRefundDeclined classification (e.g. REFUND_DECLINED in prod).
client := NewDevClient().(*MockClient)
client.FailRefundCode = "REFUND_DECLINED"
ctx := context.Background()
req := RefundPaymentReq{
PaymentID: "pay_mock_refund_declined",
Amount: 5000,
IdempotencyKey: "refund-key-declined",
Reason: "declined",
}
result, err := client.RefundPayment(ctx, req)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, errors.Is(err, ErrRefundDeclined), "expected ErrRefundDeclined, got: %v", err)
assert.False(t, errors.Is(err, ErrRefundAlreadyProcessed), "declined refund must not be classified as already processed: %v", err)
}
func TestDevClient_ConcurrentPayments(t *testing.T) {
client := NewDevClient().(*MockClient)
@@ -402,6 +451,46 @@ func TestDevClient_CreatePayment_DedupsOnIdempotencyKey(t *testing.T) {
assert.Equal(t, first.ID, byKey.ID)
}
func TestDevClient_RefundPayment_DedupsOnIdempotencyKey(t *testing.T) {
// Real Square dedups on idempotency key: a same-key retry returns the
// original refund. The mock must mirror this or the pending-refund resume
// path can't be exercised (and a retry could double-refund the customer).
client := NewDevClient().(*MockClient)
ctx := context.Background()
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 10000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "payment-for-refund-dedup",
ReferenceID: "booking-refund-dedup",
})
require.NoError(t, err)
req := RefundPaymentReq{
PaymentID: paymentResult.ID,
Amount: 5000,
IdempotencyKey: "refund-dedup-key-1",
Reason: "customer request",
}
first, err := client.RefundPayment(ctx, req)
require.NoError(t, err)
require.NotEmpty(t, first.ID)
second, err := client.RefundPayment(ctx, req)
require.NoError(t, err)
assert.Equal(t, first.ID, second.ID, "same-key retry must return the original refund, not a new one")
// Only one refund stored in the mock's refunds map (deduped).
client.mu.RLock()
defer client.mu.RUnlock()
assert.Len(t, client.refunds, 1, "same-key retry must not store a second refund")
byKey := client.refundByKey["refund-dedup-key-1"]
assert.NotNil(t, byKey)
assert.Equal(t, first.ID, byKey.ID)
}
func TestDevClient_CreatePayment_AutocompleteFalse(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
@@ -519,6 +608,75 @@ func TestDevClient_CreateCheckout_HoldCheckouts(t *testing.T) {
require.Error(t, err)
}
func TestDevClient_ListPaymentRefunds_FiltersByPaymentAndTime(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
begin := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
client.mu.Lock()
client.refunds["ref_1"] = &RefundResult{
ID: "ref_1", Status: "COMPLETED", Amount: 5000, PaymentID: "pay_a",
LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(2 * 24 * time.Hour).Format(time.RFC3339),
}
client.refunds["ref_2"] = &RefundResult{
ID: "ref_2", Status: "COMPLETED", Amount: 2500, PaymentID: "pay_b",
LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(3 * 24 * time.Hour).Format(time.RFC3339),
}
client.refunds["ref_3"] = &RefundResult{
ID: "ref_3", Status: "COMPLETED", Amount: 1000, PaymentID: "pay_a",
LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(-1 * 24 * time.Hour).Format(time.RFC3339),
}
client.mu.Unlock()
results, err := client.ListPaymentRefunds(ctx, "pay_a", begin)
require.NoError(t, err)
require.Len(t, results, 1, "only the pay_a refund created after beginTime must be returned")
assert.Equal(t, "ref_1", results[0].ID)
assert.Equal(t, int64(5000), results[0].Amount)
assert.Equal(t, "COMPLETED", results[0].Status)
assert.Equal(t, "pay_a", results[0].PaymentID)
}
func TestDevClient_ListPaymentRefunds_AfterRefundPayment(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 10000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "payment-for-list-refunds",
ReferenceID: "booking-list-refunds",
})
require.NoError(t, err)
refundResult, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: paymentResult.ID,
Amount: 5000,
IdempotencyKey: "refund-for-list",
Reason: "customer request",
})
require.NoError(t, err)
results, err := client.ListPaymentRefunds(ctx, paymentResult.ID, time.Now().Add(-24*time.Hour))
require.NoError(t, err)
require.Len(t, results, 1, "the refund stored by RefundPayment must be listed")
assert.Equal(t, refundResult.ID, results[0].ID)
assert.Equal(t, int64(5000), results[0].Amount)
assert.Equal(t, "COMPLETED", results[0].Status)
assert.Equal(t, paymentResult.ID, results[0].PaymentID)
}
func TestDevClient_ListPaymentRefunds_Empty(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
results, err := client.ListPaymentRefunds(ctx, "pay_unknown", time.Now().Add(-24*time.Hour))
require.NoError(t, err)
assert.NotNil(t, results, "must return an empty slice, not nil")
assert.Empty(t, results)
}
func TestDetectCardInfo_Variants(t *testing.T) {
tests := []struct {
sourceID string
+62 -1
View File
@@ -91,7 +91,8 @@ func (c *httpClient) doJSON(ctx context.Context, method, path string, body, targ
var errResp struct{ Errors []SquareError `json:"errors"` }
if json.Unmarshal(respBody, &errResp) == nil && len(errResp.Errors) > 0 {
se := errResp.Errors[0]
return fmt.Errorf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, se.Detail, se.Field)
msg := fmt.Sprintf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, se.Detail, se.Field)
return &squareAPIError{Code: se.Code, Detail: se.Detail, err: errors.New(msg)}
}
return fmt.Errorf("square: %s %s: HTTP %d: %s", method, path, resp.StatusCode, string(respBody))
}
@@ -230,6 +231,11 @@ type sqRefundPaymentResponse struct {
Refund sqRefund `json:"refund"`
}
type sqListRefundsResponse struct {
Refunds []sqRefund `json:"refunds"`
Cursor string `json:"cursor"`
}
type sqRefund struct {
ID string `json:"id"`
Status string `json:"status"`
@@ -343,6 +349,30 @@ func getCheckoutHTTP(ctx context.Context, checkoutID string) (*PaymentResult, er
return paymentFromSquare(&payResp.Payment), nil
}
// squareAPIError wraps a formatted Square API error while exposing the
// structured Square error code so callers can classify definitive business
// rejections (e.g. ErrRefundDeclined) vs ambiguous transport/server errors.
type squareAPIError struct {
Code string
Detail string
err error
}
func (e *squareAPIError) Error() string { return e.err.Error() }
func (e *squareAPIError) Unwrap() error { return e.err }
// Definitive Square refund rejection codes — the refund was declined and can
// never succeed, so retrying is pointless and the refund record should be
// marked 'failed'. Anything else (transport errors, 5xx) is left ambiguous so
// callers leave the refund 'pending' for a scheduler retry. Note that
// PAYMENT_ALREADY_REFUNDED is intentionally absent — the money has already
// moved, so it maps to ErrRefundAlreadyProcessed instead of ErrRefundDeclined.
var definitiveRefundCodes = map[string]bool{
"REFUND_DECLINED": true,
"PAYMENT_REFUND_AMOUNT_EXCEEDED": true,
"INVALID_PAYMENT_ID": true,
}
func refundPaymentHTTP(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
hc := newHTTPClient()
body := sqRefundPaymentRequest{
@@ -353,11 +383,42 @@ func refundPaymentHTTP(ctx context.Context, req RefundPaymentReq) (*RefundResult
}
var resp sqRefundPaymentResponse
if err := hc.doJSON(ctx, http.MethodPost, "/v2/refunds", body, &resp); err != nil {
var sqErr *squareAPIError
if errors.As(err, &sqErr) && definitiveRefundCodes[sqErr.Code] {
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
}
if errors.As(err, &sqErr) && sqErr.Code == "PAYMENT_ALREADY_REFUNDED" {
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
}
return nil, err
}
return refundFromSquare(&resp.Refund), nil
}
func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
hc := newHTTPClient()
base := "/v2/refunds?begin_time=" + url.QueryEscape(beginTime.UTC().Format(time.RFC3339)) + "&limit=100"
path := base
results := []RefundResult{}
for page := 0; page < 20; page++ {
var resp sqListRefundsResponse
if err := hc.doJSON(ctx, http.MethodGet, path, nil, &resp); err != nil {
return nil, err
}
for i := range resp.Refunds {
r := &resp.Refunds[i]
if r.PaymentID == paymentID {
results = append(results, *refundFromSquare(r))
}
}
if resp.Cursor == "" {
return results, nil
}
path = base + "&cursor=" + url.QueryEscape(resp.Cursor)
}
return nil, fmt.Errorf("square: list refunds exceeded 20 pages (infinite loop guard)")
}
func createCardOnFileHTTP(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
hc := newHTTPClient()
+27 -1
View File
@@ -1,6 +1,25 @@
package square
import "context"
import (
"context"
"errors"
"time"
)
// ErrRefundDeclined is returned by RefundPayment when Square definitively
// rejects a refund (refund declined, refund amount exceeds the original
// charge, invalid payment ID, etc.). Callers use errors.Is to distinguish a
// definitive business rejection — where the refund record should be marked
// 'failed' and never retried — from an ambiguous transport/5xx error that is
// safe to retry later. Note: PAYMENT_ALREADY_REFUNDED is NOT a decline — the
// money has already moved, so it maps to ErrRefundAlreadyProcessed instead.
var ErrRefundDeclined = errors.New("square: refund declined")
// ErrRefundAlreadyProcessed is returned by RefundPayment when Square reports
// PAYMENT_ALREADY_REFUNDED — the payment is already fully refunded at Square,
// so the money has already moved. Callers resolve the refund record to
// 'completed' rather than 'failed' (which would let the guard over-refund).
var ErrRefundAlreadyProcessed = errors.New("square: refund already processed")
// CreatePaymentReq maps to Square's CreatePayment endpoint (POST /v2/payments).
// Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment
@@ -139,4 +158,11 @@ type SquareClient interface {
CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error)
GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error)
DeleteCardOnFile(ctx context.Context, cardID string) error
// ListPaymentRefunds returns the refunds Square has recorded for a payment
// (charge), created at or after beginTime. Used to reconcile pending refund
// rows against Square before marking them failed (money may already have
// moved). Square's endpoint lists account-wide; the caller filters by
// PaymentID client-side.
ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error)
}
+10
View File
@@ -9,6 +9,7 @@ import (
"sync/atomic"
"time"
"crussell/clock"
"crussell/db"
"golang.org/x/crypto/bcrypt"
)
@@ -178,6 +179,15 @@ func CreateTestBookingAtTime(q db.Querier, userID, serviceID string, startTime t
return bookingID, nil
}
// NextWorkingDayAt returns the time at `hour` UTC on a day `daysAhead` days from
// now. Hours in [8, 18] are guaranteed inside the fixture's Mon-Sun 08:00-20:00
// London working hours at any wall-clock time (London is at most UTC+1), unlike
// clock.Now().Add(N * time.Hour), which can land after closing and flake tests.
func NextWorkingDayAt(daysAhead, hour int) time.Time {
day := clock.Now().AddDate(0, 0, daysAhead)
return time.Date(day.Year(), day.Month(), day.Day(), hour, 0, 0, 0, time.UTC)
}
func CreateTestVerifiedUser(q db.Querier) (string, error) {
return createTestUser(q, "Verified", "User", "verified@test.com", "verified_email")
}