Close refund system and gate raw-PAN card entry
Refund system (Round 3 fixes + follow-up + alignment): - Serialize cancellation refunds against the manual handler via per-payment advisory locks taken before the prior-refunds read (pg_advisory_xact_lock, ascending, same crussell:refund: key space) - Aggregate pending cancellation refunds into ONE Square refund per charge (stable charge-level -square-agg key); atomic group UPDATE keeps crash-retry amounts identical for Square key-dedup - Persist paymentID-square-amount idempotency keys on cancellation refunds; scheduler reads the stored key (legacy fallback for old rows) - Add sweep-pending-square-refunds cron (*/5, concurrency 1) with refund_attempts cap; sweep retries stale manual pending refunds with each row's own stored idempotency key - Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every terminal failed transition: tri-state result leaves rows pending on reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED resolves to completed - Move over-refund guard inside the lock, counting completed + pending (excluding failed); ErrRefundDeclined distinguishes definitive vs ambiguous outcomes - forgiveFees now executes a real full refund (forceFullRefund override) with admin_forgiven_fees reason threaded to Square - Surface failed card refunds in the admin notification centre (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup) - Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key) DO NOTHING without consuming refundRemaining Frontend: - Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token in request bodies; gate new-card entry behind CardEntryUnavailable notice + newCardDisabled prop across all 8 flows - Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI and CardEntryUnavailable fallback - Update cancellation-policy page to in-person cash pickup wording Tests: - Rewrite the two amount-blind dedup tests to assert real money movement (single call, aggregated amount, shared refund ID) - Add coverage: manual refund vs cancellation serialization (concurrent goroutines), reconcile error vs no-match branches, stale manual retry, forgive-fees real refund row + reason, double-cancel dedup, mock refund key dedup, ListPaymentRefunds filtering - Fix time-dependent booking flakes with fixtures.NextWorkingDayAt - 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
This commit is contained in:
@@ -6,14 +6,17 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
@@ -360,9 +363,11 @@ func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
// Booking starting in 48 hours (24-72h window, no payments).
|
||||
midRange := clock.Now().Add(48 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, midRange)
|
||||
// Booking 24-72h from now (no-show warning tier) at a fixed working-hour slot:
|
||||
// 10:00 UTC two days out is always 35-58h away, so the warning fires but the
|
||||
// "too close" 403 never does, at any wall-clock hour.
|
||||
bookingTime := fixtures.NextWorkingDayAt(2, 10)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingTime)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
@@ -373,7 +378,8 @@ func TestRequestEditHandler_NoticePeriod_SetsNoShowWarningHeader(t *testing.T) {
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
newTime := midRange.Add(48 * time.Hour)
|
||||
// 10:00 UTC four days out — same slot as bookingTime, inside working hours.
|
||||
newTime := bookingTime.Add(48 * time.Hour)
|
||||
|
||||
handler := http.HandlerFunc(RequestEditHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", map[string]interface{}{
|
||||
@@ -574,6 +580,12 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
// A real admin user so the refund row's created_by FK is satisfied.
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
@@ -591,20 +603,45 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
|
||||
t.Fatalf("failed to confirm booking: %v", err)
|
||||
}
|
||||
|
||||
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
// Give the payment a Square reference so the post-commit sweep does NOT
|
||||
// terminal-pre-pass it to 'failed' (NULL square refs are unresolvable).
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_admin_forgive_fees' WHERE id = $1", paymentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set square_payment_id: %v", err)
|
||||
}
|
||||
|
||||
// Commit the setup so the handler runs at pool level: AdminCancelBookingHandler
|
||||
// acquires pg_advisory_xact_lock on the card payments, and inside the test-env
|
||||
// outer per-test transaction (savepoints don't release xact locks) the locks
|
||||
// would deadlock the post-commit sweep's session locks. Pool-level mirrors
|
||||
// production, where the handler's tx commits and releases the locks.
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
if pgxTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := pgxTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit test tx: %v", err)
|
||||
}
|
||||
freshCtx := context.Background()
|
||||
|
||||
// Make the post-commit refund processing leave the row 'pending' (ambiguous
|
||||
// Square transport failure → refund_attempts=1) so the test can assert the
|
||||
// row exists as pending with the full amount.
|
||||
origSquare := payments.SquareClient
|
||||
forgiveFeesClient := &forgiveFeesAmbiguousClient{SquareClient: square.NewDevClient()}
|
||||
payments.SquareClient = forgiveFeesClient
|
||||
defer func() { payments.SquareClient = origSquare }()
|
||||
|
||||
w := serveChiHandler(AdminCancelBookingHandler, "POST", "/api/admin/bookings/"+bookingID+"/cancel", "/api/admin/bookings/{id}/cancel", map[string]interface{}{
|
||||
"forgive_fees": true,
|
||||
}, func(baseCtx context.Context) context.Context {
|
||||
baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
if info := extractUserFromTestJWT(adminToken); info != nil {
|
||||
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, info.userID)
|
||||
}
|
||||
return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
|
||||
baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
|
||||
return baseCtx
|
||||
})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
@@ -625,6 +662,70 @@ func TestAdminCancelBookingHandler_ForgiveFeesFullRefund(t *testing.T) {
|
||||
if refundCalc["refundable_amount"] != 100.0 {
|
||||
t.Errorf("expected refundable_amount 100, got %v", refundCalc["refundable_amount"])
|
||||
}
|
||||
|
||||
// The forgiven-fees FULL refund must actually execute — a refund row for
|
||||
// the whole £100 must exist as 'pending' (created by ProcessCancellationRefundTx,
|
||||
// resolved post-commit), not just a synthetic response claim.
|
||||
var refundCount int
|
||||
err = db.Conn.QueryRow(freshCtx,
|
||||
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query refunds: %v", err)
|
||||
}
|
||||
if refundCount < 1 {
|
||||
t.Errorf("expected at least 1 refund row for forgiven fees full refund, got %d", refundCount)
|
||||
}
|
||||
var refundStatus string
|
||||
var refundAmount float64
|
||||
err = db.Conn.QueryRow(freshCtx,
|
||||
"SELECT status, amount FROM refunds WHERE booking_id = $1 LIMIT 1", bookingID).Scan(&refundStatus, &refundAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query refund row: %v", err)
|
||||
}
|
||||
if refundStatus != "pending" {
|
||||
t.Errorf("expected refund status 'pending', got %q", refundStatus)
|
||||
}
|
||||
if refundAmount != 100 {
|
||||
t.Errorf("expected refund amount 100, got %.2f", refundAmount)
|
||||
}
|
||||
|
||||
// The Square sweep must be called with the forgiven-fees reason — the
|
||||
// reason is threaded from the refund rows through to the post-commit
|
||||
// ProcessPendingSquareRefunds call (P3b), not the generic admin_cancelled.
|
||||
calls := forgiveFeesClient.refundCalls()
|
||||
if len(calls) == 0 {
|
||||
t.Fatal("expected the post-commit sweep to call Square RefundPayment")
|
||||
}
|
||||
if calls[0].Reason != "admin_forgiven_fees" {
|
||||
t.Errorf("expected Square refund reason %q, got %q", "admin_forgiven_fees", calls[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// forgiveFeesAmbiguousClient simulates a transport-level Square failure so the
|
||||
// ForgiveFees test's post-commit refund processing leaves the row 'pending'.
|
||||
// It also records every RefundPayment request so the test can assert the
|
||||
// forgiven-fees reason reaches Square.
|
||||
type forgiveFeesAmbiguousClient struct {
|
||||
square.SquareClient
|
||||
mu sync.Mutex
|
||||
calls []square.RefundPaymentReq
|
||||
}
|
||||
|
||||
func (c *forgiveFeesAmbiguousClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
|
||||
c.mu.Lock()
|
||||
c.calls = append(c.calls, req)
|
||||
c.mu.Unlock()
|
||||
return nil, fmt.Errorf("network error: connection reset by peer")
|
||||
}
|
||||
|
||||
func (c *forgiveFeesAmbiguousClient) refundCalls() []square.RefundPaymentReq {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]square.RefundPaymentReq(nil), c.calls...)
|
||||
}
|
||||
|
||||
func (c *forgiveFeesAmbiguousClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) {
|
||||
return nil, fmt.Errorf("network error: connection reset by peer")
|
||||
}
|
||||
|
||||
func TestAdminCancelBookingHandler_NormalRefundOver72h(t *testing.T) {
|
||||
|
||||
@@ -198,20 +198,20 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Status update succeeded — now process the refund in the SAME transaction
|
||||
// so that a commit failure rolls back both the status change and the refund.
|
||||
if forgiveFees && totalPaid > 0 {
|
||||
refundResult = &payments.RefundCalculationResult{
|
||||
TotalPrePaid: totalPaid,
|
||||
RefundableAmount: totalPaid,
|
||||
KeptAmount: 0,
|
||||
Tier: "admin_full_refund",
|
||||
// refundReason threads through to the refund rows AND the post-commit Square
|
||||
// sweep so forgiven-fee bookings are labelled "admin_forgiven_fees" (audit).
|
||||
refundReason := "admin_cancelled"
|
||||
if totalPaid > 0 {
|
||||
if forgiveFees {
|
||||
refundReason = "admin_forgiven_fees"
|
||||
// Full refund of the net pre-paid amount regardless of notice tier —
|
||||
// the override makes the refund actually execute (rows created and
|
||||
// swept), instead of the old synthetic response-only claim.
|
||||
refundResult, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), refundReason, &adminID, true)
|
||||
} else if calculatedRefund {
|
||||
refundResult, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), refundReason, &adminID, false)
|
||||
}
|
||||
}
|
||||
if calculatedRefund {
|
||||
var calc *payments.RefundCalculationResult
|
||||
calc, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), "admin_cancelled", &adminID)
|
||||
if err == nil {
|
||||
refundResult = calc
|
||||
} else {
|
||||
if err != nil {
|
||||
refundFailed = true
|
||||
log.Printf("ALERT: AdminCancelBookingHandler — ProcessCancellationRefundTx failed for booking %s after status was updated to we_cancelled. Refund was NOT processed. The transaction WILL be committed (cancellation stands, no refund). Error: %v", bookingID, err)
|
||||
}
|
||||
@@ -275,8 +275,8 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Process pending Square refunds after the transaction commits successfully.
|
||||
// This ensures Square API calls only happen if the DB records persist.
|
||||
if calculatedRefund {
|
||||
payments.ProcessPendingSquareRefunds(r.Context(), bookingID, "admin_cancelled")
|
||||
if totalPaid > 0 {
|
||||
payments.ProcessPendingSquareRefunds(r.Context(), bookingID, refundReason)
|
||||
}
|
||||
|
||||
if refundFailed || (refundResult != nil && refundResult.RefundableAmount > 0) {
|
||||
|
||||
@@ -1852,8 +1852,14 @@ func TestAdminApproveEditRequest_EvictsPendingRelease(t *testing.T) {
|
||||
}
|
||||
dur := durationMinutes(t, ctx, tx, serviceID)
|
||||
|
||||
// Use a booking <48h from now so RequestEdit does NOT auto-approve
|
||||
nearTime := clock.Now().Add(40 * time.Hour).Truncate(time.Second)
|
||||
// Use a booking 24-48h from now at a fixed working-hour slot so RequestEdit
|
||||
// neither 403s (<24h away = too close) nor auto-approves (>48h away would
|
||||
// consume the edit request before the admin can approve it). A raw
|
||||
// clock.Now().Add(Kh) can land after 19:00 London and fail closing hours.
|
||||
nearTime := fixtures.NextWorkingDayAt(1, 10)
|
||||
if nearTime.Sub(clock.Now()) < 24*time.Hour {
|
||||
nearTime = fixtures.NextWorkingDayAt(2, 10)
|
||||
}
|
||||
|
||||
bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, nearTime)
|
||||
if err != nil {
|
||||
|
||||
@@ -646,10 +646,14 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if paymentResult.Status == "COMPLETED" {
|
||||
service := NewPaymentService()
|
||||
idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(paymentResult.Amount, 10)
|
||||
// Deterministic idempotency key derived from booking + amount + Square
|
||||
// payment ID. The Square payment ID disambiguates two distinct
|
||||
// equal-amount charges on the same booking, so equal amounts never
|
||||
// collide on the UNIQUE constraint.
|
||||
idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(paymentResult.Amount, 10) + "-" + paymentResult.SquarePayID
|
||||
|
||||
// Begin the transaction BEFORE the idempotency check so it's atomic
|
||||
// with the payment insert.
|
||||
// Begin the transaction BEFORE the dedup lookup so it's atomic with the
|
||||
// payment insert.
|
||||
tx, err := db.Conn.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction: %v", err)
|
||||
@@ -662,26 +666,26 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Check for existing payment inside the transaction.
|
||||
// Dedup by Square payment ID: a double poll of the same terminal
|
||||
// checkout must return the existing payment row instead of inserting a
|
||||
// duplicate (which previously 500'd on the idempotency-key UNIQUE
|
||||
// violation after the customer had already paid).
|
||||
var existingID string
|
||||
var existingSquarePayID sql.NullString
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
SELECT id, COALESCE(square_payment_id, '') FROM payments
|
||||
WHERE booking_id = $1 AND idempotency_key = $2
|
||||
`, bookingID, idempotencyKey).Scan(&existingID, &existingSquarePayID); err == nil {
|
||||
if existingSquarePayID.Valid && existingSquarePayID.String == paymentResult.SquarePayID {
|
||||
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{
|
||||
Status: "COMPLETED",
|
||||
PaymentID: existingID,
|
||||
Amount: paymentResult.Amount,
|
||||
CardBrand: paymentResult.CardBrand,
|
||||
CardLast4: paymentResult.CardLast4,
|
||||
ReceiptURL: paymentResult.ReceiptURL,
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
SELECT id FROM payments
|
||||
WHERE booking_id = $1 AND square_payment_id = $2
|
||||
`, bookingID, paymentResult.SquarePayID).Scan(&existingID); err == nil {
|
||||
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{
|
||||
Status: "COMPLETED",
|
||||
PaymentID: existingID,
|
||||
Amount: paymentResult.Amount,
|
||||
CardBrand: paymentResult.CardBrand,
|
||||
CardLast4: paymentResult.CardLast4,
|
||||
ReceiptURL: paymentResult.ReceiptURL,
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Failed to check for existing payment: %v", err)
|
||||
}
|
||||
@@ -1662,17 +1666,9 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
alreadyRefunded, err := service.GetAlreadyRefundedAmount(r.Context(), paymentID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get already refunded amount: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount+alreadyRefunded > int64(math.Round(payment.Amount*100)) {
|
||||
http.Error(w, "Refund amount exceeds payment amount", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Deterministic idempotency key so a same-key retry (network timeout)
|
||||
// does not create a second Square refund.
|
||||
idempotencyKey := paymentID + "-refund-" + strconv.FormatInt(req.Amount, 10)
|
||||
|
||||
// Serialize refund attempts per payment to prevent two concurrent refunds
|
||||
// both passing the over-refund guard and both charging Square. Mirrors the
|
||||
@@ -1700,15 +1696,24 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Deterministic idempotency key so a same-key retry (network timeout)
|
||||
// does not create a second Square refund.
|
||||
idempotencyKey := paymentID + "-refund-" + strconv.FormatInt(req.Amount, 10)
|
||||
|
||||
// Check for an existing refund with this key — dedup completed refunds.
|
||||
// Dedup/resume (inside the lock): a same-key retry of a completed or
|
||||
// in-flight (pending) refund must not create a second Square refund. Runs
|
||||
// BEFORE the over-refund guard so a resuming refund never evaluates its own
|
||||
// pending row against the guard.
|
||||
var existingRefundID sql.NullString
|
||||
var existingRefundStatus sql.NullString
|
||||
err = db.Conn.QueryRow(r.Context(), `SELECT status FROM refunds WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingRefundStatus)
|
||||
if err == nil && existingRefundStatus.String == "completed" {
|
||||
var existingRefundAmount sql.NullFloat64
|
||||
var existingRefundOrigin sql.NullString
|
||||
var existingRefundReason sql.NullString
|
||||
var existingRefundCreatedAt sql.NullTime
|
||||
var existingRefundKey sql.NullString
|
||||
err = db.Conn.QueryRow(r.Context(), `
|
||||
SELECT id, status, amount, origin, reason, created_at, idempotency_key FROM refunds WHERE idempotency_key = $1
|
||||
`, idempotencyKey).Scan(&existingRefundID, &existingRefundStatus, &existingRefundAmount, &existingRefundOrigin, &existingRefundReason, &existingRefundCreatedAt, &existingRefundKey)
|
||||
switch {
|
||||
case err == nil && existingRefundStatus.String == "completed":
|
||||
if err := json.NewEncoder(w).Encode(RefundResponse{
|
||||
ID: existingRefundID.String,
|
||||
PaymentID: paymentID,
|
||||
Amount: req.Amount,
|
||||
Status: "completed",
|
||||
@@ -1718,9 +1723,198 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
case err == nil && existingRefundStatus.String == "pending":
|
||||
// Resume the in-flight refund: the DB row was committed but the Square
|
||||
// call never completed (network timeout, crash, etc.). Retry Square with
|
||||
// the same idempotency key so Square returns the original refund if one
|
||||
// exists, never a second one.
|
||||
resumeAmount := int64(math.Round(existingRefundAmount.Float64 * 100))
|
||||
resumeReq := square.RefundPaymentReq{
|
||||
PaymentID: *payment.SquarePaymentID,
|
||||
Amount: resumeAmount,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
Reason: req.Reason,
|
||||
}
|
||||
resumeResult, resumeErr := SquareClient.RefundPayment(r.Context(), resumeReq)
|
||||
if resumeErr != nil {
|
||||
if errors.Is(resumeErr, square.ErrRefundAlreadyProcessed) {
|
||||
// PAYMENT_ALREADY_REFUNDED — money already moved at Square.
|
||||
// Resolve the pending row to completed (square_refund_id stays
|
||||
// NULL) so the guard can never over-refund on top of it.
|
||||
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, existingRefundID.String); upErr != nil {
|
||||
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", existingRefundID.String, upErr)
|
||||
}
|
||||
log.Printf("Refund %s already processed at Square — marked completed", existingRefundID.String)
|
||||
if err := json.NewEncoder(w).Encode(RefundResponse{
|
||||
ID: existingRefundID.String,
|
||||
PaymentID: paymentID,
|
||||
Amount: req.Amount,
|
||||
Status: "completed",
|
||||
Reason: req.Reason,
|
||||
CreatedAt: clock.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if errors.Is(resumeErr, square.ErrRefundDeclined) {
|
||||
// Definitive rejection — mark failed so it never retries and
|
||||
// never blocks future refunds.
|
||||
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, existingRefundID.String); upErr != nil {
|
||||
log.Printf("Failed to mark refund %s failed after definitive rejection: %v", existingRefundID.String, upErr)
|
||||
}
|
||||
log.Printf("Refund %s definitively declined by Square: %v", existingRefundID.String, resumeErr)
|
||||
http.Error(w, "Refund failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Ambiguous error — leave pending for the scheduler to retry.
|
||||
log.Printf("Failed to resume refund %s (left pending): %v", existingRefundID.String, resumeErr)
|
||||
http.Error(w, "Refund failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, upErr := db.Conn.Exec(r.Context(),
|
||||
`UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`,
|
||||
resumeResult.ID, existingRefundID.String,
|
||||
); upErr != nil {
|
||||
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", resumeResult.ID, existingRefundID.String, upErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(RefundResponse{
|
||||
ID: existingRefundID.String,
|
||||
PaymentID: paymentID,
|
||||
Amount: req.Amount,
|
||||
Status: "completed",
|
||||
Reason: req.Reason,
|
||||
CreatedAt: clock.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
case err == nil && existingRefundStatus.String == "failed":
|
||||
// A failed row with origin='manual' may actually have moved money at
|
||||
// Square (response loss after a definitive decline). Reconcile FIRST —
|
||||
// an exact-amount COMPLETED refund resolves the row to completed.
|
||||
// Otherwise the reconcile proves money did NOT move, so re-issuing with
|
||||
// the stored key/amount is safe (Square dedups same-key retries).
|
||||
if existingRefundOrigin.String == "manual" {
|
||||
refundSqPaymentID := *payment.SquarePaymentID
|
||||
resumeAmount := int64(math.Round(existingRefundAmount.Float64 * 100))
|
||||
var reconcileTime time.Time
|
||||
if existingRefundCreatedAt.Valid {
|
||||
reconcileTime = existingRefundCreatedAt.Time
|
||||
}
|
||||
sqRefundID, rcErr := reconcileRefundAtSquare(r.Context(), refundSqPaymentID, resumeAmount, reconcileTime)
|
||||
switch {
|
||||
case rcErr != nil:
|
||||
// Reconcile failed — unknown whether Square refunded. Do NOT
|
||||
// re-issue on an unknown state: re-issuing would be safe
|
||||
// against Square's key dedup, but if money already moved the
|
||||
// over-refund guard would lose sight of it. Surface a retry.
|
||||
log.Printf("Failed to reconcile refund %s against Square before re-issue (%v) — not re-issuing, ask the admin to retry", existingRefundID.String, rcErr)
|
||||
http.Error(w, "Unable to verify refund status with Square, please retry", http.StatusServiceUnavailable)
|
||||
return
|
||||
case sqRefundID != nil:
|
||||
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`, *sqRefundID, existingRefundID.String); upErr != nil {
|
||||
log.Printf("Failed to mark refund %s completed after Square reconcile: %v", existingRefundID.String, upErr)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(RefundResponse{
|
||||
ID: existingRefundID.String,
|
||||
PaymentID: paymentID,
|
||||
Amount: req.Amount,
|
||||
Status: "completed",
|
||||
Reason: req.Reason,
|
||||
CreatedAt: clock.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
reissueReq := square.RefundPaymentReq{
|
||||
PaymentID: refundSqPaymentID,
|
||||
Amount: resumeAmount,
|
||||
IdempotencyKey: existingRefundKey.String,
|
||||
Reason: existingRefundReason.String,
|
||||
}
|
||||
reissueResult, reissueErr := SquareClient.RefundPayment(r.Context(), reissueReq)
|
||||
switch {
|
||||
case reissueErr == nil:
|
||||
if _, upErr := db.Conn.Exec(r.Context(),
|
||||
`UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`,
|
||||
reissueResult.ID, existingRefundID.String,
|
||||
); upErr != nil {
|
||||
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", reissueResult.ID, existingRefundID.String, upErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(RefundResponse{
|
||||
ID: existingRefundID.String,
|
||||
PaymentID: paymentID,
|
||||
Amount: req.Amount,
|
||||
Status: "completed",
|
||||
Reason: req.Reason,
|
||||
CreatedAt: clock.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
case errors.Is(reissueErr, square.ErrRefundAlreadyProcessed):
|
||||
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, existingRefundID.String); upErr != nil {
|
||||
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", existingRefundID.String, upErr)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(RefundResponse{
|
||||
ID: existingRefundID.String,
|
||||
PaymentID: paymentID,
|
||||
Amount: req.Amount,
|
||||
Status: "completed",
|
||||
Reason: req.Reason,
|
||||
CreatedAt: clock.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
case errors.Is(reissueErr, square.ErrRefundDeclined):
|
||||
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, existingRefundID.String); upErr != nil {
|
||||
log.Printf("Failed to mark refund %s failed after re-issue rejection: %v", existingRefundID.String, upErr)
|
||||
}
|
||||
log.Printf("Refund %s re-issued with stored key definitively declined by Square: %v", existingRefundID.String, reissueErr)
|
||||
http.Error(w, "Refund failed", http.StatusInternalServerError)
|
||||
return
|
||||
default:
|
||||
// Ambiguous re-issue — put the row back to 'pending' so the
|
||||
// sweep's manual retry pass can re-attempt it.
|
||||
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'pending' WHERE id = $1`, existingRefundID.String); upErr != nil {
|
||||
log.Printf("Failed to mark refund %s pending after ambiguous re-issue: %v", existingRefundID.String, upErr)
|
||||
}
|
||||
log.Printf("Refund %s re-issue left pending (ambiguous): %v", existingRefundID.String, reissueErr)
|
||||
http.Error(w, "Refund failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Previously definitively rejected (non-manual) — a same-key retry cannot
|
||||
// succeed and the UNIQUE key would block re-insertion. Surface the
|
||||
// failure instead of 500-ing on a duplicate.
|
||||
log.Printf("Refund %s was previously marked failed — same-key retry rejected", existingRefundID.String)
|
||||
http.Error(w, "Refund failed", http.StatusInternalServerError)
|
||||
return
|
||||
case err != nil && !errors.Is(err, pgx.ErrNoRows):
|
||||
log.Printf("Failed to check refund idempotency: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Over-refund guard (inside the lock so concurrent refunds can't both pass).
|
||||
// GetAlreadyRefundedAmount counts completed AND pending refunds.
|
||||
alreadyRefunded, err := service.GetAlreadyRefundedAmount(r.Context(), paymentID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get already refunded amount: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount+alreadyRefunded > int64(math.Round(payment.Amount*100)) {
|
||||
http.Error(w, "Refund amount exceeds payment amount", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Begin a transaction. Insert the refund record as 'pending' first, commit,
|
||||
@@ -1740,8 +1934,8 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var refundID string
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at)
|
||||
VALUES ($1, $2, $3, 'pending', $4, $5, $6, $7)
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin)
|
||||
VALUES ($1, $2, $3, 'pending', $4, $5, $6, $7, 'manual')
|
||||
RETURNING id
|
||||
`,
|
||||
paymentID,
|
||||
@@ -1773,8 +1967,39 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
refundResult, err := SquareClient.RefundPayment(r.Context(), refundReq)
|
||||
if err != nil {
|
||||
// Refund record intentionally left as 'pending' for the scheduler to
|
||||
// re-attempt (refunds.go ProcessPendingSquareRefunds).
|
||||
if errors.Is(err, square.ErrRefundAlreadyProcessed) {
|
||||
// PAYMENT_ALREADY_REFUNDED — money already moved at Square. Resolve
|
||||
// to completed (square_refund_id stays NULL) rather than failed so
|
||||
// the over-refund guard can never issue money on top of it.
|
||||
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, refundID); upErr != nil {
|
||||
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", refundID, upErr)
|
||||
}
|
||||
log.Printf("Refund %s already processed at Square — marked completed", refundID)
|
||||
if err := json.NewEncoder(w).Encode(RefundResponse{
|
||||
ID: refundID,
|
||||
PaymentID: paymentID,
|
||||
Amount: req.Amount,
|
||||
Status: "completed",
|
||||
Reason: req.Reason,
|
||||
CreatedAt: clock.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if errors.Is(err, square.ErrRefundDeclined) {
|
||||
// Definitive rejection (declined / already refunded / invalid
|
||||
// payment) — mark the refund failed so it never retries and never
|
||||
// blocks future refunds.
|
||||
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, refundID); upErr != nil {
|
||||
log.Printf("Failed to mark refund %s failed after definitive rejection: %v", refundID, upErr)
|
||||
}
|
||||
log.Printf("Refund %s definitively declined by Square: %v", refundID, err)
|
||||
http.Error(w, "Refund failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Ambiguous error — refund record intentionally left as 'pending' for
|
||||
// the scheduler to re-attempt (refunds.go ProcessPendingSquareRefunds).
|
||||
log.Printf("Failed to refund payment (refund %s left pending): %v", refundID, err)
|
||||
http.Error(w, "Refund failed", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -678,3 +678,134 @@ func TestGetCheckoutStatus_Completed(t *testing.T) {
|
||||
t.Error("expected card_last4 to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// pollCheckoutStatus polls GetCheckoutStatus until the checkout reports
|
||||
// COMPLETED, returning the decoded response.
|
||||
func pollCheckoutStatus(t *testing.T, ctx context.Context, checkoutID, bookingID, adminToken string) PaymentStatusResponse {
|
||||
t.Helper()
|
||||
var resp PaymentStatusResponse
|
||||
assert.Eventually(t, func() bool {
|
||||
statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil)
|
||||
statusRCtx := chi.NewRouteContext()
|
||||
statusRCtx.URLParams.Add("checkout_id", checkoutID)
|
||||
statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx)
|
||||
if info := extractUserFromTestJWT(adminToken); info != nil {
|
||||
statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID)
|
||||
statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role)
|
||||
}
|
||||
statusReq = statusReq.WithContext(statusCtx)
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
GetCheckoutStatus(w2, statusReq)
|
||||
if w2.Code != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil {
|
||||
return false
|
||||
}
|
||||
return resp.Status == "COMPLETED"
|
||||
}, 10*time.Second, 200*time.Millisecond, "expected checkout to complete")
|
||||
return resp
|
||||
}
|
||||
|
||||
// createTerminalCheckout creates a terminal checkout via CreateTerminalPayment
|
||||
// and returns the checkout ID from the response.
|
||||
func createTerminalCheckout(t *testing.T, ctx context.Context, bookingID, adminToken string, amount int64) string {
|
||||
t.Helper()
|
||||
handler := CreateTerminalPayment
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: amount,
|
||||
PaymentType: "full",
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var createResp CheckoutResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
|
||||
t.Fatalf("failed to decode create response: %v", err)
|
||||
}
|
||||
if createResp.CheckoutID == "" {
|
||||
t.Fatal("expected checkout_id to be set")
|
||||
}
|
||||
return createResp.CheckoutID
|
||||
}
|
||||
|
||||
func TestGetCheckoutStatus_DoublePoll_SinglePaymentRow(t *testing.T) {
|
||||
// A double poll of the same terminal checkout must return the existing
|
||||
// payment row instead of inserting a duplicate (which previously 500'd on
|
||||
// the idempotency-key UNIQUE violation after the customer had paid).
|
||||
origClient := SquareClient
|
||||
SquareClient = &testCheckoutClient{
|
||||
SquareClient: origClient,
|
||||
hexIDs: make(map[string]string),
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
checkoutID := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
|
||||
|
||||
first := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken)
|
||||
if first.PaymentID == "" {
|
||||
t.Fatal("expected payment_id from first poll")
|
||||
}
|
||||
|
||||
// Second poll of the same checkout — deduped against the existing row.
|
||||
second := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken)
|
||||
if second.PaymentID == "" {
|
||||
t.Fatal("expected payment_id from second poll")
|
||||
}
|
||||
if second.PaymentID != first.PaymentID {
|
||||
t.Errorf("expected same payment_id on re-poll, got %q then %q", first.PaymentID, second.PaymentID)
|
||||
}
|
||||
|
||||
var rowCount int
|
||||
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count payment rows: %v", err)
|
||||
}
|
||||
if rowCount != 1 {
|
||||
t.Errorf("expected exactly 1 payment row after double poll, got %d", rowCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCheckoutStatus_TwoEqualAmountCharges_NoCollision(t *testing.T) {
|
||||
// Two distinct terminal charges on the same booking with the same final
|
||||
// amount must each create their own payment row (the deposit + equal-amount
|
||||
// balance case) — no 500 on the idempotency-key UNIQUE collision.
|
||||
origClient := SquareClient
|
||||
SquareClient = &testCheckoutClient{
|
||||
SquareClient: origClient,
|
||||
hexIDs: make(map[string]string),
|
||||
}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
checkoutA := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
|
||||
checkoutB := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
|
||||
|
||||
respA := pollCheckoutStatus(t, ctx, checkoutA, bookingID, adminToken)
|
||||
respB := pollCheckoutStatus(t, ctx, checkoutB, bookingID, adminToken)
|
||||
|
||||
if respA.PaymentID == "" || respB.PaymentID == "" {
|
||||
t.Fatal("expected payment_ids for both checkouts")
|
||||
}
|
||||
if respA.PaymentID == respB.PaymentID {
|
||||
t.Error("expected two distinct payment rows for two distinct Square charges")
|
||||
}
|
||||
|
||||
var rowCount int
|
||||
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count payment rows: %v", err)
|
||||
}
|
||||
if rowCount != 2 {
|
||||
t.Errorf("expected exactly 2 payment rows for two equal-amount charges, got %d", rowCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
@@ -772,6 +773,180 @@ func TestRefund_PendingPaymentRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefund_PendingSameKeyRetry_Resumes(t *testing.T) {
|
||||
// A retry of an in-flight refund (DB row pending, Square call never
|
||||
// completed) must resume rather than insert a second row or issue a second
|
||||
// Square refund. The handler retries Square with the same idempotency key
|
||||
// and completes the pending row.
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_pending_resume' WHERE id = $1", paymentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update payment: %v", err)
|
||||
}
|
||||
|
||||
// Seed a pending refund with the same key the handler will compute.
|
||||
key := paymentID + "-refund-2500"
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_at)
|
||||
VALUES ($1, $2, 25, 'pending', 'customer request', $3, NOW())
|
||||
`, paymentID, bookingID, key)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed pending refund: %v", err)
|
||||
}
|
||||
|
||||
req := RefundRequest{
|
||||
Amount: 2500,
|
||||
Reason: "customer request",
|
||||
}
|
||||
|
||||
handler := RefundPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The pending row must now be completed with a Square refund ID — and no
|
||||
// second refund row inserted.
|
||||
var refundCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query refunds: %v", err)
|
||||
}
|
||||
if refundCount != 1 {
|
||||
t.Errorf("expected 1 refund row (resumed, not duplicated), got %d", refundCount)
|
||||
}
|
||||
|
||||
var status string
|
||||
var squareRefundID *string
|
||||
err = tx.QueryRow(ctx, `SELECT status, square_refund_id FROM refunds WHERE idempotency_key = $1`, key).Scan(&status, &squareRefundID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query refund: %v", err)
|
||||
}
|
||||
if status != "completed" {
|
||||
t.Errorf("expected status completed, got %q", status)
|
||||
}
|
||||
if squareRefundID == nil || *squareRefundID == "" {
|
||||
t.Error("expected square_refund_id to be set after resume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefund_GuardCountsPendingRefunds(t *testing.T) {
|
||||
// The over-refund guard must count pending refunds (an in-flight Square
|
||||
// refund) as well as completed ones, so a second refund cannot push the
|
||||
// total past the payment amount.
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
|
||||
adminToken := jwt.GenerateAdminToken()
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100.00, "in_person_card", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_guard_pending' WHERE id = $1", paymentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update payment: %v", err)
|
||||
}
|
||||
|
||||
// Seed a pending refund of £70 (as if a previous attempt's Square call is in flight).
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
|
||||
VALUES ($1, $2, 70, 'pending', 'in flight', NOW())
|
||||
`, paymentID, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed pending refund: %v", err)
|
||||
}
|
||||
|
||||
// A further £40 refund would total £110 > £100 — must be rejected.
|
||||
req := RefundRequest{
|
||||
Amount: 4000,
|
||||
Reason: "over refund attempt",
|
||||
}
|
||||
|
||||
handler := RefundPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefund_PaymentAlreadyRefunded_MarksCompleted(t *testing.T) {
|
||||
// Square reports PAYMENT_ALREADY_REFUNDED — the money has already moved.
|
||||
// The refund row must resolve to 'completed' (NOT 'failed', which would let
|
||||
// the over-refund guard re-issue money on top of it), square_refund_id
|
||||
// stays NULL, and the API returns 200.
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_already_refunded' WHERE id = $1", paymentID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set square_payment_id: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
mock.FailRefundCode = "PAYMENT_ALREADY_REFUNDED"
|
||||
SquareClient = mock
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
req := RefundRequest{
|
||||
Amount: 5000,
|
||||
Reason: "customer request",
|
||||
}
|
||||
|
||||
handler := RefundPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp RefundResponse
|
||||
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.Status != "completed" {
|
||||
t.Errorf("expected response status 'completed', got %q", resp.Status)
|
||||
}
|
||||
|
||||
var status string
|
||||
var squareRefundID *string
|
||||
err = tx.QueryRow(ctx,
|
||||
"SELECT status, square_refund_id FROM refunds WHERE payment_id = $1", paymentID).Scan(&status, &squareRefundID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query refund: %v", err)
|
||||
}
|
||||
if status != "completed" {
|
||||
t.Errorf("expected refund status 'completed', got %q", status)
|
||||
}
|
||||
if squareRefundID != nil && *squareRefundID != "" {
|
||||
t.Errorf("expected square_refund_id NULL (no new refund issued), got %q", *squareRefundID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTipPayment_HappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,8 @@ type RefundRecord struct {
|
||||
SquareRefundID *string
|
||||
Status string
|
||||
Reason string
|
||||
Origin string // 'manual' (admin handler) or 'cancellation' (cancellation loop)
|
||||
IdempotencyKey *string
|
||||
CreatedBy *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -154,8 +156,8 @@ func (s *PaymentService) CreateRefundRecord(ctx context.Context, record RefundRe
|
||||
var id string
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
INSERT INTO refunds (
|
||||
payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
payment_id, booking_id, amount, square_refund_id, status, reason, idempotency_key, created_by, created_at, origin
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id
|
||||
`,
|
||||
record.PaymentID,
|
||||
@@ -164,8 +166,10 @@ func (s *PaymentService) CreateRefundRecord(ctx context.Context, record RefundRe
|
||||
record.SquareRefundID,
|
||||
record.Status,
|
||||
record.Reason,
|
||||
record.IdempotencyKey,
|
||||
record.CreatedBy,
|
||||
record.CreatedAt,
|
||||
record.Origin,
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
@@ -239,7 +243,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
|
||||
summary.TotalNetAmount = totalNetAmount
|
||||
|
||||
refundRows, err := db.Conn.Query(ctx, `
|
||||
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
|
||||
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, idempotency_key, created_by, created_at, origin
|
||||
FROM refunds
|
||||
WHERE booking_id = $1 AND status = 'completed'
|
||||
ORDER BY created_at ASC
|
||||
@@ -255,7 +259,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
|
||||
var r RefundRecord
|
||||
err := refundRows.Scan(
|
||||
&r.ID, &r.PaymentID, &r.BookingID, &r.Amount, &r.SquareRefundID,
|
||||
&r.Status, &r.Reason, &r.CreatedBy, &r.CreatedAt,
|
||||
&r.Status, &r.Reason, &r.IdempotencyKey, &r.CreatedBy, &r.CreatedAt, &r.Origin,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -348,11 +352,17 @@ func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) (
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// GetAlreadyRefundedAmount returns the total refunded amount (in pence) for a
|
||||
// payment, counting both 'completed' and 'pending' refunds. Pending refunds are
|
||||
// counted because a Square call may already be in flight for them — excluding
|
||||
// them would let a concurrent refund over-refund the payment. 'failed' refunds
|
||||
// are excluded: they were definitively rejected by Square and must not block
|
||||
// future refund attempts.
|
||||
func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID string) (int64, error) {
|
||||
var amount float64
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(amount), 0) FROM refunds
|
||||
WHERE payment_id = $1 AND status = 'completed'
|
||||
WHERE payment_id = $1 AND status IN ('completed', 'pending')
|
||||
`, paymentID).Scan(&amount)
|
||||
|
||||
if err != nil {
|
||||
@@ -391,18 +401,27 @@ type BookingPaymentInfo struct {
|
||||
}
|
||||
|
||||
// GetBookingPaymentInfo fetches the booking start time, total service amount, and
|
||||
// total completed payments for a booking.
|
||||
// total net paid (completed payments minus completed/pending refunds) for a
|
||||
// booking. Refunds are subtracted so cancellation refunds never double-refund
|
||||
// money that has already been returned (e.g. via a manual admin refund).
|
||||
func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID string) (*BookingPaymentInfo, error) {
|
||||
var info BookingPaymentInfo
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
SELECT b.start_time, b.status,
|
||||
COALESCE(b.total_amount, 0),
|
||||
COALESCE(pt.total_paid, 0)
|
||||
COALESCE(pt.total_paid, 0) - COALESCE(rr.total_refunded, 0)
|
||||
FROM bookings b
|
||||
LEFT JOIN (
|
||||
SELECT booking_id, SUM(amount) AS total_paid
|
||||
FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') GROUP BY booking_id
|
||||
) pt ON b.id = pt.booking_id
|
||||
LEFT JOIN (
|
||||
SELECT p.booking_id, SUM(r.amount) AS total_refunded
|
||||
FROM refunds r
|
||||
JOIN payments p ON r.payment_id = p.id
|
||||
WHERE p.booking_id = $1 AND r.status IN ('completed', 'pending')
|
||||
GROUP BY p.booking_id
|
||||
) rr ON b.id = rr.booking_id
|
||||
WHERE b.id = $1
|
||||
`, bookingID).Scan(&info.StartTime, &info.Status, &info.TotalAmount, &info.TotalPaid)
|
||||
if err != nil {
|
||||
|
||||
@@ -100,6 +100,14 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "gift_card_id is required for topup", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Action == "topup" && req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
|
||||
// Redeem is create-only. Topping up an unredeemed card (which can still
|
||||
// carry residual balance) and then redeeming would zero amount_remaining
|
||||
// while crediting the user only the top-up amount — destroying money.
|
||||
// Fail loudly rather than silently ignoring the redeem.
|
||||
http.Error(w, "Cannot redeem a top-up to a user account", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Idempotency check: if key provided, return existing sale if found
|
||||
// Idempotency handling. A 'completed' sale is a dedup (return it). A
|
||||
@@ -183,6 +191,40 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// If the gift card should be immediately redeemed to a user's account balance
|
||||
// (e.g. admin selected "add to account" rather than "generate gift code").
|
||||
// Runs only on the FIRST attempt of a create — a pending retry reuses the
|
||||
// already-funded gift card and must never re-run this, or the user's balance
|
||||
// would be credited a second time (money loss to the business).
|
||||
if req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gift_cards
|
||||
SET amount_remaining = 0,
|
||||
redeemed_at = NOW(),
|
||||
redeemed_by = $1,
|
||||
last_used_at = NOW()
|
||||
WHERE id = $2
|
||||
`, *req.RedeemToUserID, giftCardID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to redeem gift card to user account: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
balance = user_giftcard_balances.balance + EXCLUDED.balance,
|
||||
updated_at = NOW()
|
||||
`, *req.RedeemToUserID, req.Amount)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update user gift card balance: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cardID := validators.NormalizeGiftCardCode(*req.GiftCardID)
|
||||
var redeemedBy sql.NullString
|
||||
@@ -244,37 +286,6 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// If the gift card should be immediately redeemed to a user's account balance
|
||||
// (e.g. admin selected "add to account" rather than "generate gift code")
|
||||
if req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gift_cards
|
||||
SET amount_remaining = 0,
|
||||
redeemed_at = NOW(),
|
||||
redeemed_by = $1,
|
||||
last_used_at = NOW()
|
||||
WHERE id = $2
|
||||
`, *req.RedeemToUserID, giftCardID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to redeem gift card to user account: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
balance = user_giftcard_balances.balance + EXCLUDED.balance,
|
||||
updated_at = NOW()
|
||||
`, *req.RedeemToUserID, req.Amount)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update user gift card balance: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
penceAmount := int64(math.Round(req.Amount * 100))
|
||||
|
||||
var squarePaymentID *string
|
||||
@@ -288,6 +299,21 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
var needsSquarePayment bool
|
||||
var savedCardSqCardID string
|
||||
|
||||
// Pending-retry for card_machine: the original Square checkout may still be
|
||||
// live at the terminal. If the pending till_sales row already recorded a
|
||||
// square_checkout_id, reuse it instead of creating a second checkout — a
|
||||
// fresh checkout would orphan the original, which can still complete and
|
||||
// become an untracked charge.
|
||||
var existingPendingCheckoutID string
|
||||
if existingPendingID != "" {
|
||||
err = tx.QueryRow(ctx, `SELECT COALESCE(square_checkout_id, '') FROM till_sales WHERE id = $1`, existingPendingID).Scan(&existingPendingCheckoutID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to query existing pending sale checkout: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch req.PaymentMethod {
|
||||
case "cash":
|
||||
saleStatus = "completed"
|
||||
@@ -333,23 +359,32 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
req.IdempotencyKey = uniqueTillKey()
|
||||
}
|
||||
|
||||
checkoutReq := square.CreateCheckoutReq{
|
||||
Amount: penceAmount,
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
ReferenceID: giftCardID,
|
||||
TipEnabled: false,
|
||||
}
|
||||
if existingPendingCheckoutID != "" {
|
||||
// Pending retry — reuse the checkout already created for this sale
|
||||
// instead of creating a second one. The original checkout may still
|
||||
// be live at the terminal; a fresh checkout would orphan it into an
|
||||
// untracked charge.
|
||||
squareCheckoutID = &existingPendingCheckoutID
|
||||
saleStatus = "pending"
|
||||
} else {
|
||||
checkoutReq := square.CreateCheckoutReq{
|
||||
Amount: penceAmount,
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
ReferenceID: giftCardID,
|
||||
TipEnabled: false,
|
||||
}
|
||||
|
||||
checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Square checkout: %v", err)
|
||||
http.Error(w, "Failed to create card machine payment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Square checkout: %v", err)
|
||||
http.Error(w, "Failed to create card machine payment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
squareCheckoutID = &checkout.ID
|
||||
saleStatus = "pending"
|
||||
squareCheckoutID = &checkout.ID
|
||||
saleStatus = "pending"
|
||||
}
|
||||
case "online_square":
|
||||
dbPaymentMethod = "online_square"
|
||||
if req.IdempotencyKey == "" {
|
||||
|
||||
@@ -1038,3 +1038,297 @@ func TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed(t *testing.T) {
|
||||
require.Equal(t, http.StatusCreated, w.Code, "sale %d: expected 201, got %d. body: %s", i+1, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce verifies that
|
||||
// a same-key retry of a pending create-with-redeem sale does NOT re-credit the
|
||||
// user's balance. The redeem (zeroing the gift card + crediting
|
||||
// user_giftcard_balances) must only run on the FIRST attempt of a create; a
|
||||
// pending retry reuses the already-redeemed gift card and would otherwise credit
|
||||
// the user a second time (money loss to the business).
|
||||
func TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "sq_test_card_id", "VISA", "1234")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create saved card: %v", err)
|
||||
}
|
||||
|
||||
// Seed a PENDING till_sale (prior attempt where Square failed after the DB
|
||||
// transaction committed) whose gift card was already redeemed and the user
|
||||
// already credited the first-attempt amount (£50).
|
||||
key := "till-pending-retry-redeem-key"
|
||||
var giftCardID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by, redeemed_at, is_inventory, voucher_type_at_purchase)
|
||||
VALUES (50.00, 0.00, $1, $2, NOW(), FALSE, 'SPV')
|
||||
RETURNING id
|
||||
`, adminID, userID).Scan(&giftCardID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed redeemed gift card: %v", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
||||
VALUES ($1, 50.00, NOW())
|
||||
`, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed user gift card balance: %v", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
||||
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
|
||||
$2, $3, $4, $5, NOW(), NOW())
|
||||
`, giftCardID, userID, cardID, key, adminID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed pending till sale: %v", err)
|
||||
}
|
||||
|
||||
// Retry with the same key, requesting the redeem again.
|
||||
redeemUserID := userID
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "saved_card",
|
||||
UserSavedCardID: &cardID,
|
||||
UserID: &userID,
|
||||
IdempotencyKey: key,
|
||||
RedeemToUserID: &redeemUserID,
|
||||
}
|
||||
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The sale must now be 'completed' (Square re-attempted and succeeded).
|
||||
var saleStatus string
|
||||
err = tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query till sale: %v", err)
|
||||
}
|
||||
if saleStatus != "completed" {
|
||||
t.Errorf("expected sale status 'completed' after retry, got %s", saleStatus)
|
||||
}
|
||||
|
||||
// The user balance must still be £50 — credited EXACTLY ONCE, not £100.
|
||||
var balance float64
|
||||
err = tx.QueryRow(ctx, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query user gift card balance: %v", err)
|
||||
}
|
||||
if balance != 50.00 {
|
||||
t.Errorf("expected balance 50.00 (credited once), got %.2f", balance)
|
||||
}
|
||||
|
||||
// The gift card must remain fully redeemed (amount_remaining still 0).
|
||||
var amountRemaining float64
|
||||
err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, giftCardID).Scan(&amountRemaining)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query gift card: %v", err)
|
||||
}
|
||||
if amountRemaining != 0.00 {
|
||||
t.Errorf("expected amount_remaining 0.00, got %.2f", amountRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTillSale_TopupWithRedeem_Rejected verifies that a top-up request
|
||||
// carrying a redeem_to_user_id is rejected outright. Allowing redeem on a first
|
||||
// attempt topup destroys money: the topup branch accepts unredeemed cards with
|
||||
// residual balance, and topping up £20 onto a card with £40 residual then
|
||||
// redeeming would zero amount_remaining (£40 lost) while crediting the user only
|
||||
// the top-up amount.
|
||||
func TestCreateTillSale_TopupWithRedeem_Rejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
redeemerID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create redeemer user: %v", err)
|
||||
}
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
// An unredeemed card carrying residual balance — the dangerous topup+redeem case.
|
||||
var cardID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES (40.00, 40.00, $1, FALSE, 'SPV')
|
||||
RETURNING id
|
||||
`, adminID).Scan(&cardID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert gift card: %v", err)
|
||||
}
|
||||
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "topup",
|
||||
Amount: 20.00,
|
||||
PaymentMethod: "on_the_house",
|
||||
GiftCardID: &cardID,
|
||||
RedeemToUserID: &redeemerID,
|
||||
}
|
||||
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// No rows created: no till_sale, card untouched, no user balance credited.
|
||||
var saleCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales`).Scan(&saleCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query till_sales: %v", err)
|
||||
}
|
||||
if saleCount != 0 {
|
||||
t.Errorf("expected 0 till_sales, got %d", saleCount)
|
||||
}
|
||||
|
||||
var totalFunds, amountRemaining float64
|
||||
err = tx.QueryRow(ctx, `SELECT total_funds_added, amount_remaining FROM gift_cards WHERE id = $1`, cardID).Scan(&totalFunds, &amountRemaining)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query gift card: %v", err)
|
||||
}
|
||||
if totalFunds != 40.00 {
|
||||
t.Errorf("expected total_funds_added 40.00, got %.2f", totalFunds)
|
||||
}
|
||||
if amountRemaining != 40.00 {
|
||||
t.Errorf("expected amount_remaining 40.00, got %.2f", amountRemaining)
|
||||
}
|
||||
|
||||
var balanceCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1`, redeemerID).Scan(&balanceCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query user_giftcard_balances: %v", err)
|
||||
}
|
||||
if balanceCount != 0 {
|
||||
t.Errorf("expected no user gift card balance row, got %d", balanceCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTillSale_PendingRetry_CardMachine_ReusesCheckout verifies that a
|
||||
// same-key retry of a pending card_machine sale reuses the checkout already
|
||||
// stored on the till_sales row instead of calling Square CreateCheckout a second
|
||||
// time. The original checkout may still be live at the terminal; a fresh
|
||||
// checkout would orphan it into an untracked charge.
|
||||
func TestCreateTillSale_PendingRetry_CardMachine_ReusesCheckout(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
// Seed a PENDING card_machine till_sale that already has a Square checkout.
|
||||
key := "till-card-machine-reuse-key"
|
||||
storedCheckoutID := "chk_pending_retry_stored"
|
||||
var giftCardID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
|
||||
RETURNING id
|
||||
`, adminID).Scan(&giftCardID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create gift card: %v", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
||||
payment_method, status, square_checkout_id, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending',
|
||||
$2, $3, $4, NOW(), NOW())
|
||||
`, giftCardID, storedCheckoutID, key, adminID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed pending card_machine till sale: %v", err)
|
||||
}
|
||||
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "card_machine",
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp TillSaleResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
// The response must reference the STORED checkout, not a freshly created one.
|
||||
// CreateCheckout always generates a new chk_mock_* id, so any second call
|
||||
// would surface a different id here.
|
||||
if resp.CheckoutID == nil || *resp.CheckoutID != storedCheckoutID {
|
||||
t.Errorf("expected checkout_id to be the stored %q, got %v", storedCheckoutID, resp.CheckoutID)
|
||||
}
|
||||
if resp.Status != "pending" {
|
||||
t.Errorf("expected status 'pending', got %s", resp.Status)
|
||||
}
|
||||
|
||||
// The till_sales row must still reference the same checkout id, unchanged.
|
||||
var rowCheckoutID string
|
||||
var saleCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(MAX(square_checkout_id), '') FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &rowCheckoutID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query till sale: %v", err)
|
||||
}
|
||||
if saleCount != 1 {
|
||||
t.Errorf("expected 1 till sale (reuse, not duplicate), got %d", saleCount)
|
||||
}
|
||||
if rowCheckoutID != storedCheckoutID {
|
||||
t.Errorf("expected till_sales.square_checkout_id to remain %q, got %q", storedCheckoutID, rowCheckoutID)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user