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)
}
}