fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs

Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes:
- CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back
- A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit)
- A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds
- A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows
- A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs
- A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point)
- A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface
- A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction
- M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test
- Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status)

All 25 backend packages pass; frontend 41/41; build + env-docs green.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 78e6d00dc5
commit 6d82535780
60 changed files with 6608 additions and 801 deletions
+123
View File
@@ -1,10 +1,20 @@
package payments
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"crussell/db"
"crussell/internal/square"
@@ -186,3 +196,116 @@ func recheckBookingPayable(ctx context.Context, q db.Querier, bookingID string)
}
return status, bookingStatusAllowsCompletedPayment(status), nil
}
// snapshotEncMarker prefixes the at-rest encrypted form of a stored
// square_request_snapshot (PII: buyer email + ccof tokens) so decryptSnapshot
// can distinguish encrypted values from plaintext (dev/mock environments and
// legacy pre-encryption rows). The marker itself is not secret.
const snapshotEncMarker = "enc:v1:"
// snapshotEncKeyWarningOnce throttles the missing-key CRITICAL log to one line
// per process: a deployment without a usable SNAPSHOT_ENC_KEY falls back to
// plaintext (money-safety first — the replayable snapshot must not be lost),
// and the single loud warning makes the misconfiguration impossible to miss.
var snapshotEncKeyWarningOnce sync.Once
// snapshotEncKey parses the AES-256-GCM key from the SNAPSHOT_ENC_KEY
// environment variable (base64-encoded 32 bytes). It is read on every call so
// tests can flip the env; the parse is cheap and encryption happens once per
// payment.
func snapshotEncKey() ([]byte, error) {
raw := strings.TrimSpace(os.Getenv("SNAPSHOT_ENC_KEY"))
if raw == "" {
return nil, errors.New("SNAPSHOT_ENC_KEY is not set")
}
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("SNAPSHOT_ENC_KEY is not valid base64: %w", err)
}
if len(decoded) != 32 {
return nil, fmt.Errorf("SNAPSHOT_ENC_KEY must decode to 32 bytes for AES-256, got %d", len(decoded))
}
return decoded, nil
}
// encryptSnapshot returns the snapshot body ready for storage. In dev/mock
// environments it returns the body unchanged (no key required, tests keep
// passing); in non-mock environments (SQUARE_ENVIRONMENT production/sandbox —
// the same gate IsExplicitDevOrMockEnv drives) it AES-256-GCM-encrypts the
// body and returns "enc:v1:" + base64(nonce || ciphertext) so the PII at rest
// (buyer email, ccof card tokens) is encrypted. The transformation is
// lossless: decryptSnapshot recovers the ORIGINAL bytes exactly, which Square's
// identical-body idempotency replay depends on. A missing/unusable key in a
// non-mock deployment falls back to plaintext with a one-time CRITICAL log —
// breaking the replayable snapshot to protect PII would strand pending rows,
// so money-safety wins over best-effort hardening.
func encryptSnapshot(body []byte) ([]byte, error) {
if IsExplicitDevOrMockEnv() {
return body, nil
}
key, err := snapshotEncKey()
if err != nil {
snapshotEncKeyWarningOnce.Do(func() {
log.Printf("CRITICAL: %v — storing square_request_snapshot PLAINTEXT; set SNAPSHOT_ENC_KEY to a base64-encoded 32-byte key in non-mock deployments", err)
})
return body, nil
}
gcm, err := newSnapshotGCM(key)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("failed to read snapshot encryption nonce: %w", err)
}
sealed := gcm.Seal(nonce, nonce, body, nil)
out := append([]byte(snapshotEncMarker), []byte(base64.StdEncoding.EncodeToString(sealed))...)
return out, nil
}
// decryptSnapshot reverses encryptSnapshot for a stored square_request_snapshot.
// Marker-prefixed values are base64-decoded and AES-256-GCM-decrypted back to
// the byte-identical original request body (the sweep's by-key replay depends
// on this); values without the marker (dev/mock plaintext or legacy
// pre-encryption rows) are returned unchanged. Exporting it lets the sweep's
// stale-pending reconcile decrypt stored snapshots before replay.
func decryptSnapshot(data []byte) ([]byte, error) {
if !bytes.HasPrefix(data, []byte(snapshotEncMarker)) {
return data, nil
}
key, err := snapshotEncKey()
if err != nil {
return nil, fmt.Errorf("cannot decrypt stored square_request_snapshot: %w", err)
}
gcm, err := newSnapshotGCM(key)
if err != nil {
return nil, err
}
sealed, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(string(data), snapshotEncMarker))
if err != nil {
return nil, fmt.Errorf("stored square_request_snapshot is not valid base64: %w", err)
}
nonceSize := gcm.NonceSize()
if len(sealed) < nonceSize {
return nil, errors.New("stored square_request_snapshot ciphertext is too short")
}
nonce, ciphertext := sealed[:nonceSize], sealed[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("stored square_request_snapshot failed AES-GCM authentication: %w", err)
}
return plaintext, nil
}
// newSnapshotGCM builds the AES-256-GCM AEAD for the given 32-byte key.
func newSnapshotGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to init snapshot AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to init snapshot AES-GCM: %w", err)
}
return gcm, nil
}
@@ -3,7 +3,9 @@
package payments
import (
"bytes"
"context"
"encoding/base64"
"errors"
"net/http/httptest"
"strings"
@@ -132,3 +134,73 @@ func TestResolveChargeSource_SaveCard_CleanupFailureStillCharges(t *testing.T) {
require.True(t, strings.HasPrefix(sourceID, "ccof:"), "source must be the created card-on-file, got %q", sourceID)
require.Equal(t, []string{sourceID}, rec.deletedIDs(), "the disable must be attempted even when it will fail")
}
// snapshotEncKeyForTest returns a deterministic base64-encoded 32-byte
// AES-256 key so encryption tests do not depend on a real env secret.
func snapshotEncKeyForTest() string {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
return base64.StdEncoding.EncodeToString(key)
}
// TestEncryptDecryptSnapshot_RoundTrip pins the M9 lossless constraint: in a
// non-mock environment with SNAPSHOT_ENC_KEY set, encryptSnapshot must not
// store plaintext and decryptSnapshot must recover the ORIGINAL bytes exactly
// — Square's identical-body idempotency replay depends on byte-for-byte
// fidelity. It also covers the dev/mock path (plaintext passthrough) and the
// legacy/unmarked plaintext path through decryptSnapshot.
func TestEncryptDecryptSnapshot_RoundTrip(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "production")
t.Setenv("SNAPSHOT_ENC_KEY", snapshotEncKeyForTest())
body := []byte(`{"source_id":"cnon:test-nonce","buyer_email_address":"buyer@example.com","idempotency_key":"test-key"}`)
enc, err := encryptSnapshot(body)
require.NoError(t, err)
require.False(t, bytes.Equal(enc, body), "production-mode snapshots must not be stored in plaintext")
require.True(t, bytes.HasPrefix(enc, []byte(snapshotEncMarker)), "encrypted snapshot must carry the enc:v1: marker")
dec, err := decryptSnapshot(enc)
require.NoError(t, err)
require.True(t, bytes.Equal(dec, body), "decrypt must recover the byte-identical original snapshot (Square idempotent replay depends on it)")
// Plaintext / legacy / dev-mock values pass through decrypt unchanged.
decPlain, err := decryptSnapshot(body)
require.NoError(t, err)
require.True(t, bytes.Equal(decPlain, body), "unmarked snapshot values must pass through unchanged")
}
// TestEncryptSnapshot_DevMockStoresPlaintext pins the M9 gate: in dev/mock
// environments the snapshot stays plaintext (no key required), so the mock
// test suite keeps working unchanged.
func TestEncryptSnapshot_DevMockStoresPlaintext(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "mock")
t.Setenv("SNAPSHOT_ENC_KEY", "")
body := []byte(`{"source_id":"cnon:test-nonce"}`)
enc, err := encryptSnapshot(body)
require.NoError(t, err)
require.True(t, bytes.Equal(enc, body), "mock-mode snapshots must stay plaintext")
}
// TestEncryptDecryptSnapshot_WrongKeyFails pins the auth failure path: a
// snapshot encrypted with one key must not decrypt (silently or otherwise)
// with a different key — GCM authentication must reject it.
func TestEncryptDecryptSnapshot_WrongKeyFails(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "production")
t.Setenv("SNAPSHOT_ENC_KEY", snapshotEncKeyForTest())
enc, err := encryptSnapshot([]byte(`{"source_id":"cnon:test-nonce"}`))
require.NoError(t, err)
// A different valid 32-byte key must fail GCM authentication.
other := make([]byte, 32)
for i := range other {
other[i] = 0xFF
}
t.Setenv("SNAPSHOT_ENC_KEY", base64.StdEncoding.EncodeToString(other))
_, err = decryptSnapshot(enc)
require.Error(t, err, "a snapshot encrypted with a different key must not decrypt")
}
+10 -3
View File
@@ -339,8 +339,15 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
}
// bookingIsFullyPaid reports whether completed payments toward the booking
// (excluding tips, discounts and on-the-house rows — the same definition as
// GetBookingPaymentInfo.TotalPaid) cover 100% of the booking total.
// (excluding tips and on-the-house rows, but INCLUDING discount rows) cover
// 100% of the booking total. A discount row represents real value applied
// toward the booking: the customer's total obligation is the DISCOUNTED total,
// so a booking is fully paid when real money + applied discounts == total
// (e.g. a 10% campaign on a £50 booking completes once £45 + £5 discount is
// recorded). Tips are excluded (gratuity, not payment toward the booking) as
// are on-the-house rows (no real value moved). This deliberately differs from
// GetBookingPaymentInfo.TotalPaid, which excludes discount rows because the
// deposit/balance SPLIT must run against the full total and real money only.
func bookingIsFullyPaid(ctx context.Context, q db.Querier, bookingID string) bool {
var fullyPaid bool
if err := q.QueryRow(ctx, `
@@ -352,7 +359,7 @@ func bookingIsFullyPaid(ctx context.Context, q db.Querier, bookingID string) boo
FROM payments
WHERE booking_id = $1 AND status = 'completed'
AND payment_type != 'tip'
AND payment_method NOT IN ('discount', 'on_the_house')
AND payment_method NOT IN ('on_the_house')
)
SELECT pt.paid_cents >= bt.total_cents AND bt.total_cents > 0
FROM booking_total bt, paid_total pt
+456
View File
@@ -4,13 +4,20 @@ package payments
import (
"context"
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"testing"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/stretchr/testify/require"
)
// structuredSquareAPIError returns an error of the SAME concrete type the real
@@ -200,3 +207,452 @@ func TestCreateBookingPayment_AmbiguousSquareFailure_Returns503(t *testing.T) {
t.Errorf("expected payment status 'pending' after ambiguous failure, got %q", status)
}
}
// =============================================================================
// M3 — till HTTP status classification (till.go CreateTillSale error path)
// =============================================================================
// tillChargeFailureClient injects a Square CreatePayment failure into the till
// sale handler. The embedded client carries every other method so the sale
// setup (gift-card create/commit, advisory locks) runs exactly as in
// production; only CreatePayment is overridden to return the fault.
type tillChargeFailureClient struct {
square.SquareClient
createErr error
}
func (c *tillChargeFailureClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
return nil, c.createErr
}
// structuredSquareErrorFull builds a structured *square.squareAPIError of the
// same concrete type the real client produces, re-stamped with an arbitrary
// HTTP status, Square error code, AND error category. The type is not nameable
// outside internal/square, so the clone-through-reflection technique mirrors
// structuredSquareAPIError above (which rewrites only the status code); here
// the Code and Category are also rewritten so a CARD_DECLINED decline or an
// INVALID_REQUEST_ERROR category can be produced for isDefinitiveCardSaveFailure
// classification.
func structuredSquareErrorFull(t *testing.T, status int, code, category string) error {
t.Helper()
mc := square.NewDevClient().(*square.MockClient)
_, err := mc.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 1000,
Currency: "GBP",
SourceID: "ccof:card_1",
})
if err == nil {
t.Fatal("expected the mock to reject a ccof charge without a customer")
}
v := reflect.ValueOf(err)
if v.Kind() != reflect.Ptr {
t.Fatalf("expected the structured error to be a pointer, got %v", v.Kind())
}
clone := reflect.New(v.Elem().Type())
clone.Elem().Set(v.Elem())
clone.Elem().FieldByName("StatusCode").SetInt(int64(status))
if code != "" {
clone.Elem().FieldByName("Code").SetString(code)
}
if category != "" {
clone.Elem().FieldByName("Category").SetString(category)
}
return clone.Interface().(error)
}
// TestCreateTillSale_DefinitiveDecline_Returns402 covers M3: a definitive
// Square decline (structured CARD_DECLINED) on a fresh online-square till sale
// must surface as 402 (Payment Required) — never 503 — and the funded gift card
// must be clawed back.
func TestCreateTillSale_DefinitiveDecline_Returns402(t *testing.T) {
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")
origClient := SquareClient
SquareClient = &tillChargeFailureClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR")}
defer func() { SquareClient = origClient }()
req := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:till-status-definitive",
IdempotencyKey: "till-status-definitive-key",
}
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
if w.Code != http.StatusPaymentRequired {
t.Fatalf("expected 402 for a definitive CARD_DECLINED till charge, got %d: %s", w.Code, w.Body.String())
}
// Definitive rejection → the sale is marked failed and the funded gift
// card is clawed back (a late retry must not re-complete against it).
var status string
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&status); err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if status != "failed" {
t.Errorf("expected till_sale status 'failed' after a definitive decline, got %q", status)
}
var gcCount int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*) FROM gift_cards gc
JOIN till_sales ts ON gc.id = ts.item_id
WHERE ts.idempotency_key = $1`, req.IdempotencyKey).Scan(&gcCount); err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if gcCount != 0 {
t.Errorf("expected the created gift card to be clawed back after a definitive decline, got %d rows", gcCount)
}
}
// TestCreateTillSale_AmbiguousFailure_Returns503 covers M3: an ambiguous
// failure (simulated transport error — a plain error with no structured Square
// status) must surface as 503 (Service Unavailable), NEVER 402: the money state
// at Square is unknown, so the pending sale must stay resumable on a same-key
// retry.
func TestCreateTillSale_AmbiguousFailure_Returns503(t *testing.T) {
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")
origClient := SquareClient
SquareClient = &tillChargeFailureClient{SquareClient: square.NewDevClient(), createErr: errors.New("mock: payment declined (simulated failure)")}
defer func() { SquareClient = origClient }()
req := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:till-status-ambiguous",
IdempotencyKey: "till-status-ambiguous-key",
}
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for an ambiguous till charge failure, got %d: %s", w.Code, w.Body.String())
}
// Ambiguous failure → the sale stays pending for the stale-pending sweep
// and the gift card stays funded so a late same-key retry can complete it.
var status string
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&status); err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if status != "pending" {
t.Errorf("expected till_sale status 'pending' after an ambiguous failure, got %q", status)
}
var remaining float64
if err := tx.QueryRow(ctx, `
SELECT amount_remaining FROM gift_cards gc
JOIN till_sales ts ON gc.id = ts.item_id
WHERE ts.idempotency_key = $1`, req.IdempotencyKey).Scan(&remaining); err != nil {
t.Fatalf("failed to query gift card balance: %v", err)
}
if remaining != 50.00 {
t.Errorf("expected the gift card to stay funded (£50.00) after an ambiguous failure, got £%.2f", remaining)
}
}
// =============================================================================
// H3 — isDefinitiveCardSaveFailure (handlers.go CreatePaymentMethod path)
// =============================================================================
// TestIsDefinitiveCardSaveFailure pins the H3 classification: a card-save
// failure carrying Square's INVALID_REQUEST_ERROR category (e.g.
// MISSING_REQUIRED_PARAMETER) is DEFINITIVE — the card can never be saved, so
// the attempt must fail immediately (400) instead of being retried as 500. The
// card-on-file creation codes SOURCE_USED / CARD_TOKEN_USED /
// CARD_TOKEN_EXPIRED / INVALID_CARD are definitive too, as are the shared
// definitive charge-decline codes. Generic structured errors (5xx, unknown
// code/category) and plain transport errors are AMBIGUOUS.
func TestIsDefinitiveCardSaveFailure(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"INVALID_REQUEST_ERROR category (MISSING_REQUIRED_PARAMETER) → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "MISSING_REQUIRED_PARAMETER", "INVALID_REQUEST_ERROR"), true},
{"SOURCE_USED → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "SOURCE_USED", "INVALID_REQUEST_ERROR"), true},
{"CARD_TOKEN_USED → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "CARD_TOKEN_USED", "PAYMENT_METHOD_ERROR"), true},
{"CARD_TOKEN_EXPIRED → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "CARD_TOKEN_EXPIRED", "PAYMENT_METHOD_ERROR"), true},
{"INVALID_CARD → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "INVALID_CARD", "PAYMENT_METHOD_ERROR"), true},
{"CARD_DECLINED charge code → definitive", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR"), true},
{"generic structured 500 → ambiguous", structuredSquareErrorFull(t, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "API_ERROR"), false},
{"generic structured 400 unknown code/category → ambiguous", structuredSquareErrorFull(t, http.StatusBadRequest, "SOMETHING_ELSE", "PAYMENT_METHOD_ERROR"), false},
{"plain transport error → ambiguous", errors.New("network error: connection reset by peer"), false},
{"nil → ambiguous", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isDefinitiveCardSaveFailure(tt.err); got != tt.want {
t.Errorf("isDefinitiveCardSaveFailure(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// cardSaveFailClient overrides only CreateCardOnFile so a transport-level
// card-save failure can be injected into CreatePaymentMethod without breaking
// the customer-provisioning call that precedes it.
type cardSaveFailClient struct {
square.SquareClient
createCardErr error
}
func (c *cardSaveFailClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
return nil, c.createCardErr
}
// TestCreatePaymentMethod_SourceUsed_Definitive400 drives the H3 classification
// end to end through the add-card handler: Square consumes a cnon: nonce on
// card creation, so reusing it is rejected with a structured 400 SOURCE_USED.
// isDefinitiveCardSaveFailure classifies that as definitive → 400 "Invalid
// request" (the save fails immediately, no retry), NOT 500, and no card row is
// persisted by the failed attempt.
func TestCreatePaymentMethod_SourceUsed_Definitive400(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
t.Cleanup(func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
})
token := jwt.GenerateUserToken(userID)
origClient := SquareClient
mc := square.NewDevClient().(*square.MockClient)
mc.SimulateSourceUsed = true
SquareClient = mc
defer func() { SquareClient = origClient }()
handler := CreatePaymentMethod
cardToken := "cnon:reused-source"
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: cardToken}, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("first save with a fresh nonce must succeed, got %d: %s", w.Code, w.Body.String())
}
// Reusing the consumed nonce → SOURCE_USED (INVALID_REQUEST_ERROR) →
// definitive card-save failure → 400, NOT 500.
w = makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: cardToken}, token, ctx)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for a definitive SOURCE_USED card-save failure, got %d: %s", w.Code, w.Body.String())
}
var count int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&count); err != nil {
t.Fatalf("failed to count saved cards: %v", err)
}
if count != 1 {
t.Errorf("expected exactly 1 saved card (the failed re-save must not persist a row), got %d", count)
}
}
// TestCreatePaymentMethod_AmbiguousCardSaveFailure_500 drives the H3
// classification the other way: a plain transport error during card tokenization
// carries no structured Square code, so isDefinitiveCardSaveFailure is false and
// the handler returns 500 (retrying with the same inputs might succeed).
func TestCreatePaymentMethod_AmbiguousCardSaveFailure_500(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
t.Cleanup(func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
})
token := jwt.GenerateUserToken(userID)
origClient := SquareClient
SquareClient = &cardSaveFailClient{SquareClient: square.NewDevClient(), createCardErr: errors.New("network error: connection reset by peer")}
defer func() { SquareClient = origClient }()
handler := CreatePaymentMethod
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: "cnon:ambiguous-save"}, token, ctx)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 for an ambiguous card-save failure, got %d: %s", w.Code, w.Body.String())
}
var count int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&count); err != nil {
t.Fatalf("failed to count saved cards: %v", err)
}
if count != 0 {
t.Errorf("an ambiguous card-save failure must not persist a card, got %d rows", count)
}
}
// =============================================================================
// H4 — 2FA gate on CreatePaymentMethod and BuyGiftCard(SaveCard)
// =============================================================================
// TestTwoFactorEnforced_CreatePaymentMethod_Blocked_403 verifies the H4 gate on
// the dedicated add-card endpoint: with REQUIRE_2FA enforced and the user NOT
// having completed 2FA setup, persisting a card is blocked with 403 and no card
// row is created — the save-card endpoint is not an un-gated side door.
func TestTwoFactorEnforced_CreatePaymentMethod_Blocked_403(t *testing.T) {
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "production")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := CreatePaymentMethod
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: "cnon:2fa-blocked"}, token, ctx)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 when 2FA is enforced and the user has not enabled it, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Contains(t, body["error"], "Two-factor")
var cardCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount); err != nil {
t.Fatalf("failed to count saved cards: %v", err)
}
if cardCount != 0 {
t.Errorf("a blocked 2FA save must not persist a card, got %d rows", cardCount)
}
}
// TestTwoFactorEnforced_CreatePaymentMethod_With2FA_Succeeds verifies the gate
// lets a user WHO HAS enabled 2FA save a card through the add-card endpoint.
func TestTwoFactorEnforced_CreatePaymentMethod_With2FA_Succeeds(t *testing.T) {
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "production")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
if _, err := tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true WHERE id = $1`, userID); err != nil {
t.Fatalf("failed to enable 2FA: %v", err)
}
t.Cleanup(func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
})
token := jwt.GenerateUserToken(userID)
handler := CreatePaymentMethod
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: "cnon:2fa-ok"}, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 when 2FA is enabled, got %d: %s", w.Code, w.Body.String())
}
var cardCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount); err != nil {
t.Fatalf("failed to count saved cards: %v", err)
}
if cardCount != 1 {
t.Errorf("expected exactly 1 saved card, got %d", cardCount)
}
}
// TestTwoFactorEnforced_BuyGiftCard_SaveCard_Blocked_403 verifies the H4 gate
// fires on the gift-card purchase path too: BuyGiftCard with req.SaveCard=true
// requires 2FA when enforced, mirroring CreatePaymentMethod/CreateBookingPayment.
// The purchase is rejected with 403 BEFORE any payment row is inserted.
func TestTwoFactorEnforced_BuyGiftCard_SaveCard_Blocked_403(t *testing.T) {
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "production")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
token := jwt.GenerateUserToken(userID)
cardToken := "cnon:2fa-buy-gc"
req := BuyGiftCardRequest{
Amount: 2000,
RecipientType: "self",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-buy-gc-blocked",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for BuyGiftCard with SaveCard=true without 2FA, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Contains(t, body["error"], "Two-factor")
var payCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE created_by = $1`, userID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 0 {
t.Errorf("a blocked gift-card purchase must not create a payment row, got %d", payCount)
}
}
// =============================================================================
// M7 — ConfirmOverflowTip (handlers.go CreateBookingPayment overflow gate)
// =============================================================================
// TestBookingPayment_Overflow_PostStart_Succeeds covers M7(c): once the booking
// has STARTED, an overpayment without confirm_overflow_tip succeeds (gratuity
// for service rendered is legitimate). The pre-start rejection (400
// overflow_tip_confirmation_required) and the confirmed pre-start tip path are
// covered in m4_tip_refund_redesign_test.go.
func TestBookingPayment_Overflow_PostStart_Succeeds(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// setupTestDataPast creates a booking whose start time is 1h ago — a
// post-start booking (the fixture booking total is £50).
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:overflow-post-start"
req := CreateBookingPaymentRequest{
Amount: 6000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "overflow-post-start-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for a post-start overflow without confirmation, got %d: %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "overflow_tip_confirmation_required") {
t.Fatalf("a post-start overpayment must not require the overflow confirmation, body: %s", w.Body.String())
}
var payCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected exactly 1 completed payment after the post-start overflow, got %d", payCount)
}
}
+3 -3
View File
@@ -16,9 +16,9 @@ import (
// - An admin may create/top-up/transfer at most £5,000 of gift-card value
// per UTC day.
//
// till.go keeps its own local `maxTillGiftCardAmountPence` copy of the £250
// transaction cap (see its comment); the shared constant lives here so both
// files can converge on the single owner decision.
// till.go uses the same £250 transaction cap (maxAdminGiftCardTransactionPence)
// for its gift-card creates/topups — this shared constant is the single source
// of the owner decision.
const (
// maxAdminGiftCardTransactionPence caps a single admin gift-card
// create/top-up/transfer at £250 (25,000 pence).
+130 -60
View File
@@ -2,7 +2,6 @@ package payments
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
@@ -443,8 +442,8 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
// C2: cap the admin-funded amount at £250 (25,000 pence) per transaction
// (owner decision — tighter than the £10,000 ceiling ValidateAmount
// enforces on other payment entry points, and matching the till's
// maxTillGiftCardAmountPence). An inventory card may still be created at £0.
// enforces on other payment entry points, and matching the till's cap).
// An inventory card may still be created at £0.
if int64(math.Round(req.Amount*100)) > maxAdminGiftCardTransactionPence {
http.Error(w, "Amount exceeds the maximum of £250", http.StatusBadRequest)
return
@@ -487,6 +486,12 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
if purchaseVoucherType == "" {
purchaseVoucherType = "SPV"
}
// HMRC VAT Notice 700/7: salon-only gift cards are SPV by definition, so
// the EFFECTIVE type is written even when the stored setting is 'MPV' —
// recording raw 'MPV' here would make the redemption path defer VAT a
// second time (VAT is already collected at sale via the GetVATConfig SPV
// override).
purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType)
expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx)
if err != nil {
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err)
@@ -771,13 +776,7 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
http.Error(w, "This gift card is being processed — please try again in a moment", http.StatusConflict)
return
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:giftcard-cancel:' || $1))
`, fromCardID); err != nil {
log.Printf("Failed to release gift-card cancel lock for %s: %v", fromCardID, err)
}
}()
defer releasePaymentLock(pinConn, "crussell:giftcard-cancel:"+fromCardID)
tx, err := db.Conn.Begin(ctx)
if err != nil {
@@ -951,13 +950,7 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
http.Error(w, "This gift card is being processed — please try again in a moment", http.StatusConflict)
return
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:giftcard-cancel:' || $1))
`, code); err != nil {
log.Printf("Failed to release gift-card cancel lock for %s: %v", code, err)
}
}()
defer releasePaymentLock(pinConn, "crussell:giftcard-cancel:"+code)
tx, err := db.Conn.Begin(ctx)
if err != nil {
@@ -1259,6 +1252,30 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("Failed to check idempotency: %v", err)
}
// A6: CheckIdempotencyByKey matches on the key ALONE (service.go), so a
// client-supplied deterministic/guessable key (e.g. another user's
// "gc-<userID>-<amount>-<type>-<cardPart>" fallback key) would resolve
// to ANOTHER user's payment row — returning it as the caller's completed
// purchase, reusing it for a charge, or rejecting the caller on it
// (cross-user hijack). Never reuse or return a row the caller does not
// own: a foreign match is treated as a fresh request.
if existing != nil && (existing.CreatedBy == nil || *existing.CreatedBy != userID) {
log.Printf("Gift card idempotency key %q matched payment row %s (status %s) belonging to a different user — treating as a fresh request", req.IdempotencyKey, existing.ID, existing.Status)
if clientSuppliedKey {
// The collided key is occupied (payments.idempotency_key is
// UNIQUE) — derive a fresh deterministic key for THIS user so
// the new purchase inserts a new row instead of 500ing on the
// constraint.
derivedKey, dErr := deriveGiftCardIdempotencyKey(ctx, db.Conn, userID, req.Amount, req.RecipientType, noKeyCardPart)
if dErr != nil {
log.Printf("Failed to derive fresh gift-card idempotency key after foreign-key collision: %v", dErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
req.IdempotencyKey = derivedKey
}
existing = nil
}
if existing != nil {
if existing.Status == "completed" {
if err := json.NewEncoder(w).Encode(existing); err != nil {
@@ -1303,9 +1320,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
// 2FA gating (C5): charging a SAVED card requires 2FA when the feature is
// enforced. New-card (nonce) charges are not gated.
if req.CardID != nil && *req.CardID != "" {
// 2FA gating (C5): persisting or charging a card requires 2FA when the
// feature is enforced — both paying with an existing saved card (CardID)
// and SAVING a new card during this purchase (SaveCard), mirroring
// CreateBookingPayment/CreateTipPayment. A one-off new-card (nonce) charge
// that is not saved is not gated.
if (req.CardID != nil && *req.CardID != "") || req.SaveCard {
if !requireTwoFactorForCardAccess(w, r, paymentService, userID) {
return
}
@@ -1350,22 +1370,65 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
// B6: keep square_request_snapshot's source_id in sync in the SAME
// statement — the sweep replays the stored snapshot verbatim, and a
// snapshot carrying the spent source would replay into
// IDEMPOTENCY_KEY_REUSED, stranding the row pending forever.
if _, srcErr := tx.Exec(ctx, `
UPDATE payments
SET square_source_id = $1,
square_request_snapshot = jsonb_set(
COALESCE(square_request_snapshot, '{}')::jsonb,
-- Go field-name JSON key, matching the other snapshot writers
-- (handlers.go) — a lowercase key would ADD a duplicate
-- {source_id} while the spent {SourceID} key stays stale,
-- making the replay body wrong (C6/F9).
'{SourceID}',
to_jsonb($1::text)
)::text
WHERE id = $2
`, sourceID, reusePendingID); srcErr != nil {
log.Printf("Failed to update square_source_id/square_request_snapshot on reused gift-card payment %s: %v", reusePendingID, srcErr)
// IDEMPOTENCY_KEY_REUSED, stranding the row pending forever. The
// at-rest snapshot is AES-256-GCM-encrypted in non-mock deployments
// (encryptSnapshot's "enc:v1:" marker), so the SourceID refresh must
// run in Go — decrypt → set SourceID → re-encrypt — instead of the
// legacy SQL jsonb_set, whose COALESCE(...,'{}')::jsonb cast cannot
// parse ciphertext. A row with no stored snapshot (legacy) gets just
// the source column refreshed, mirroring the booking reuse path
// (handlers.go). Best-effort: any failure leaves the snapshot
// untouched — the live square_source_id column stays authoritative
// and the sweep overrides the replay source from it.
var snap sql.NullString
if err := tx.QueryRow(ctx, `SELECT square_request_snapshot FROM payments WHERE id = $1`, reusePendingID).Scan(&snap); err != nil {
log.Printf("Failed to read square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, err)
}
var storedSnap *string
if snap.Valid && snap.String != "" {
body := []byte(snap.String)
if !IsExplicitDevOrMockEnv() {
if dec, dErr := decryptSnapshot(body); dErr != nil {
log.Printf("Failed to decrypt square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, dErr)
} else {
body = dec
}
}
var req square.CreatePaymentReq
if uErr := json.Unmarshal(body, &req); uErr != nil {
log.Printf("Failed to parse square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, uErr)
} else {
req.SourceID = sourceID
updated, mErr := json.Marshal(req)
if mErr != nil {
log.Printf("Failed to re-marshal square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, mErr)
} else {
stored := updated
if !IsExplicitDevOrMockEnv() {
if enc, eErr := encryptSnapshot(updated); eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, eErr)
} else {
stored = enc
}
}
s := string(stored)
storedSnap = &s
}
}
}
if storedSnap != nil {
if _, srcErr := tx.Exec(ctx, `
UPDATE payments
SET square_source_id = $1,
square_request_snapshot = $2
WHERE id = $3
`, sourceID, *storedSnap, reusePendingID); srcErr != nil {
log.Printf("Failed to update square_source_id/square_request_snapshot on reused gift-card payment %s: %v", reusePendingID, srcErr)
}
} else {
if _, srcErr := tx.Exec(ctx, `UPDATE payments SET square_source_id = $1 WHERE id = $2`, sourceID, reusePendingID); srcErr != nil {
log.Printf("Failed to update square_source_id on reused gift-card payment %s: %v", reusePendingID, srcErr)
}
}
buyPaymentID = reusePendingID
} else {
@@ -1396,7 +1459,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
// Apply VAT to the pending payment
vatCfg, vatErr := GetVATConfig(ctx, tx)
if vatErr == nil && vatCfg.IsVATRegistered && vatCfg.VoucherType == "SPV" {
if vatErr == nil && vatAppliesToVoucher(vatCfg) {
if _, vatExecErr := tx.Exec(ctx, "SELECT apply_vat_to_payment($1, $2)", buyPaymentID, vatCfg.DefaultVATRate); vatExecErr != nil {
log.Printf("Failed to apply VAT to buy gift card payment %s: %v", buyPaymentID, vatExecErr)
}
@@ -1447,6 +1510,13 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
BuyerEmail: buyerEmail,
VerificationToken: verificationToken,
}
// C3: a card-on-file (ccof) charge — paying with an existing saved card or
// saving a new card during this purchase — is customer-initiated: Square
// requires customer_details on stored-credential payments. A one-off cnon:
// nonce charge is not a stored credential and needs none.
if req.CardID != nil || req.SaveCard {
paymentReq.CustomerDetails = &square.CreateCustomerDetails{CustomerInitiated: true}
}
// M1: store the verbatim request JSON so the sweep can replay the charge
// with an IDENTICAL body under the same key — Square compares the whole
@@ -1454,7 +1524,9 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
// IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
log.Printf("Failed to marshal square_request_snapshot for gift-card payment %s: %v", buyPaymentID, mErr)
} else if _, sErr := db.Conn.Exec(ctx, `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(snap), buyPaymentID); sErr != nil {
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for gift-card payment %s: %v", buyPaymentID, eErr)
} else if _, sErr := db.Conn.Exec(ctx, `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(stored), buyPaymentID); sErr != nil {
log.Printf("Failed to store square_request_snapshot for gift-card payment %s: %v", buyPaymentID, sErr)
}
@@ -1514,6 +1586,10 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
if purchaseVoucherType == "" {
purchaseVoucherType = "SPV"
}
// HMRC VAT Notice 700/7: write the EFFECTIVE type (SPV) so
// voucher_type_at_purchase never records 'MPV' and redemption never
// applies deferred VAT a second time.
purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType)
err = issueTx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase)
VALUES ($1, 0, $2, NOW(), $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3)
@@ -1558,6 +1634,10 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
if purchaseVoucherType == "" {
purchaseVoucherType = "SPV"
}
// HMRC VAT Notice 700/7: write the EFFECTIVE type (SPV) so
// voucher_type_at_purchase never records 'MPV' and redemption never
// applies deferred VAT a second time.
purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType)
err = issueTx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase)
VALUES ($1, $1, $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3)
@@ -1622,10 +1702,13 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
// The key must distinguish "same live purchase retried" (dedup) from "new
// purchase that happens to be identical" (new charge). The candidate is the
// base key (seq 0) then the base key with a "-<seq>" suffix (seq >= 1) until a
// slot without a COMPLETED purchase is found. A COMPLETED purchase always
// advances the sequence — two genuine identical no-key purchases (e.g. two
// £20 self cards) are distinct operations and must diverge onto distinct keys
// (the old random-suffix fallback's collapse fix), while a PENDING row never
// slot without a COMPLETED or swept/declined FAILED purchase is found. A
// COMPLETED purchase always advances the sequence — two genuine identical
// no-key purchases (e.g. two £20 self cards) are distinct operations and must
// diverge onto distinct keys (the old random-suffix fallback's collapse fix),
// and a FAILED purchase (swept stale or definitively rejected) occupies its
// slot the same way so the customer's identical repurchase diverges onto a
// fresh key instead of being 409-rejected forever (A10). A PENDING row never
// occupies a slot: a lost-response retry re-derives the base key, the
// idempotency lookup below reuses the pending row, and Square's same-key dedup
// returns the original charge — ONE charge instead of the old double-charge.
@@ -1635,19 +1718,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
func deriveGiftCardIdempotencyKey(ctx context.Context, q db.Querier, userID string, amount int64, recipientType, cardPart string) (string, error) {
baseKey := fmt.Sprintf("gc-%s-%d-%s-%s", userID, amount, recipientType, cardPart)
for seq := 0; ; seq++ {
candidate := baseKey
if seq > 0 {
candidate = fmt.Sprintf("%s-%d", baseKey, seq)
}
if len(candidate) > 45 {
hash := sha256.Sum256([]byte(candidate))
candidate = fmt.Sprintf("gc-%x", hash[:16])
}
var completedID string
candidate := nextIdempotencyCandidate(baseKey, seq)
var occupiedID string
err := q.QueryRow(ctx, `
SELECT id FROM payments
WHERE created_by = $1 AND idempotency_key = $2 AND status = 'completed'
`, userID, candidate).Scan(&completedID)
WHERE created_by = $1 AND idempotency_key = $2 AND status IN ('completed', 'failed')
`, userID, candidate).Scan(&occupiedID)
if errors.Is(err, pgx.ErrNoRows) {
return candidate, nil
}
@@ -2162,13 +2238,7 @@ func cancelGiftCardForUser(ctx context.Context, w http.ResponseWriter, r *http.R
http.Error(w, "A gift-card cancellation is already in progress, try again", http.StatusConflict)
return
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:giftcard-cancel:' || $1))
`, code); err != nil {
log.Printf("Failed to release gift-card cancel lock for %s: %v", code, err)
}
}()
defer releasePaymentLock(pinConn, "crussell:giftcard-cancel:"+code)
tx, err := db.Conn.Begin(ctx)
if err != nil {
+170 -34
View File
@@ -55,6 +55,13 @@ type CreateBookingPaymentRequest struct {
SaveCard bool `json:"save_card"`
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
VerificationToken *string `json:"verification_token,omitempty"`
// ConfirmOverflowTip acknowledges that an overpayment beyond the booking's
// remaining balance will be recorded as a tip (M7). Tips cannot be paid in
// advance, so a pre-start overpayment is rejected with 400
// overflow_tip_confirmation_required unless the client sets this flag; the
// frontend prompts and resends with it. Post-start overpayments are always
// accepted (gratuity for service rendered).
ConfirmOverflowTip bool `json:"confirm_overflow_tip"`
}
type RefundRequest struct {
@@ -669,12 +676,14 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
UpdatedAt: clock.Now(),
CreatedBy: &adminID,
}
if err := tx.QueryRow(r.Context(), `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, user_saved_card_id, square_source_id, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id
`, record.BookingID, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.IdempotencyKey, record.UserSavedCardID, record.SquareSourceID, record.CreatedBy, record.CreatedAt, record.UpdatedAt).Scan(&paymentID); err != nil {
log.Printf("Failed to insert pending saved-card payment: %v", err)
// M4: the service's record function sets every column the inline
// INSERT previously left to defaults (fees, VAT fields, etc.), so
// the pending row is created the same way every other flow creates
// its payment records.
var insertErr error
paymentID, insertErr = service.CreatePaymentRecordTx(r.Context(), tx, record, nil)
if insertErr != nil {
log.Printf("Failed to insert pending saved-card payment: %v", insertErr)
_ = tx.Rollback(r.Context())
http.Error(w, "internal server error", http.StatusInternalServerError)
return
@@ -712,6 +721,10 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
ReferenceID: bookingID,
Note: req.PaymentType,
BuyerEmail: buyerEmail,
// C3: a saved-card (ccof) charge is customer-initiated — Square
// requires customer_details on stored-credential payments, and
// omitting it can fail or silently misclassify the charge.
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
}
// M1: store the verbatim request JSON so the sweep can replay the charge
// with an IDENTICAL body under the same key — Square compares the whole
@@ -723,7 +736,9 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
// booking/tip paths — see the reuse branch above).
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
log.Printf("Failed to marshal square_request_snapshot for saved-card payment %s: %v", paymentID, mErr)
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(snap), paymentID); sErr != nil {
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for saved-card payment %s: %v", paymentID, eErr)
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(stored), paymentID); sErr != nil {
log.Printf("Failed to store square_request_snapshot for saved-card payment %s: %v", paymentID, sErr)
}
@@ -1299,6 +1314,19 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
return
}
// A3: tips have a dedicated endpoint (POST /api/bookings/{id}/tip,
// CreateTipPayment) which enforces the M4 "tips only after the service
// starts" gate. A 'tip' payment_type on the booking payment endpoint would
// bypass that gate — the overflow/tip guard below explicitly skips tip-type
// requests and buildSplitRecords would carve the charge as a deposit or
// balance (or silently overflow into a tip record) — so it is rejected
// outright here, before any charge source resolution or payment record.
if req.PaymentType == "tip" {
log.Printf("Payment rejected: booking %s payment_type 'tip' is not allowed via /payment — tips use the dedicated /tip endpoint", bookingID)
http.Error(w, "Tips can only be added via the dedicated tip endpoint after the booking has started", http.StatusBadRequest)
return
}
if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
@@ -1437,6 +1465,21 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
FROM payments
WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'
`, bookingID, req.IdempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt); err == nil {
// A12: re-validate the matched row's refund state exactly like
// the general dedup path below. A refunded payment's money is
// no longer live, so reporting it as success here would let a
// same-key retry claim a payment that was already returned to
// the customer (money collected for the booking was refunded,
// yet the retry shows paid).
if refunded, rErr := paymentHasLiveRefund(r.Context(), tx, existingID.String); rErr != nil {
log.Printf("Failed to re-validate completed-booking dedup hit %s against refunds: %v", existingID.String, rErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
} else if refunded {
log.Printf("Payment retry rejected: completed-booking payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, req.IdempotencyKey)
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
return
}
if err := json.NewEncoder(w).Encode(PaymentResponse{
ID: existingID.String,
BookingID: existingBookingID.String,
@@ -1582,14 +1625,39 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
}
}
// M4: cap pay-early at 100% — a payment that exceeds the booking's remaining
// balance is rejected instead of silently becoming a tip via buildSplitRecords.
// A tip is gratuity for service already rendered and must be a deliberate
// separate action (the frontend shows a dedicated "tip" button once 100% is
// paid), so an overpayment is always a mistake. Placed AFTER the idempotency
// dedup: a same-key retry of an already-completed payment short-circuits
// above and must not hit this guard (the booking is fully paid by then).
// 'tip'-type requests are excluded — tips are charged via CreateTipPayment.
// A4: compute the campaign credit this payment will receive. Running the
// read-only ComputeEligibleDiscounts here (before the pending insert,
// under the advisory lock) returns exactly the discounts
// applyEligibleCampaignsAtPayment will create for the booking inside the
// post-charge transaction — no completed payment or booking_discounts row
// exists yet, so both runs see the same state. The credit is used below to
// (a) keep the overflow→tip guard honest about what the customer actually
// owes and (b) reduce the amount charged for a deposit payment, which the
// frontend always sends RAW (no client-side discount).
var bookingTotal float64
if err := tx.QueryRow(r.Context(), `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal); err != nil {
log.Printf("Failed to load booking total for discount computation: %v", err)
}
var eligibleDiscountCents int64
for _, d := range ComputeEligibleDiscounts(r.Context(), tx, bookingID, userID, bookingTotal) {
eligibleDiscountCents += int64(math.Round(d.Amount * 100))
}
// M4/M7: cap pay-early at 100%. A payment that exceeds the booking's
// remaining balance overflows into a tip record via buildSplitRecords, but a
// tip is gratuity for service already rendered — an unconfirmed pre-start
// overpayment is therefore rejected instead of silently becoming a tip.
// Post-start overpayments proceed (gratuity is legitimate once the service
// has started), and a pre-start overpayment with ConfirmOverflowTip set
// proceeds after the frontend's explicit confirmation prompt. Placed AFTER
// the idempotency dedup: a same-key retry of an already-completed payment
// short-circuits above and must not hit this guard (the booking is fully
// paid by then). 'tip'-type requests are excluded — tips are charged via
// CreateTipPayment (which enforces its own start-time gate). The overflow
// comparison uses the DISCOUNTED remaining (raw remaining + this payment's
// campaign credit): a payment that exceeds the raw remaining but stays
// within the discounted remaining is covered by the discount — it is NOT an
// overflow into tip territory.
if req.PaymentType != "tip" {
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
if err != nil {
@@ -1597,10 +1665,42 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if req.Amount > remainingCents {
log.Printf("Payment rejected: amount %d exceeds remaining balance %d for booking %s", req.Amount, remainingCents, bookingID)
http.Error(w, "Payment amount exceeds the remaining balance", http.StatusBadRequest)
return
discountedRemainingCents := remainingCents + eligibleDiscountCents
if req.Amount > discountedRemainingCents {
var bookingStartTime time.Time
if sErr := db.Conn.QueryRow(r.Context(), `SELECT start_time FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStartTime); sErr != nil {
log.Printf("Failed to get booking start time: %v", sErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if !req.ConfirmOverflowTip && !bookingStartTime.Before(clock.Now()) {
log.Printf("Overflow requires confirmation: amount %d exceeds discounted remaining %d for booking %s (not started, not confirmed)", req.Amount, discountedRemainingCents, bookingID)
mw.RespondJSON(w, http.StatusBadRequest, map[string]string{
"error": "The extra amount will be recorded as a tip. Confirm to continue.",
"code": "overflow_tip_confirmation_required",
})
return
}
log.Printf("Overflow accepted as tip: amount %d exceeds discounted remaining %d for booking %s (confirmed=%v)", req.Amount, discountedRemainingCents, bookingID, req.ConfirmOverflowTip)
}
}
// A4: the amount actually charged at Square. The frontend's full/balance
// payments already subtract the campaign credit client-side (handlePayFull
// sends amount_due minus the discount preview), so re-subtracting here
// would double-discount those — and the full discounted payment must keep
// the full record amount so bookingIsFullyPaid (real money + discount
// row == booking total) still completes. A deposit payment, however, is
// charged RAW by the frontend (handlePayDeposit sends the deposit amount
// with no discount), so the campaign credit is applied to the deposit
// charge here: the deposit is charged at req.Amount minus the discount and
// the residual balance payment settles the rest, so the total across the
// deposit→balance flow is the discounted price.
chargeAmount := req.Amount
if req.PaymentType == "deposit" && eligibleDiscountCents > 0 {
chargeAmount = req.Amount - eligibleDiscountCents
if chargeAmount <= 0 {
chargeAmount = req.Amount
}
}
@@ -1697,7 +1797,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
}
paymentReq := square.CreatePaymentReq{
Amount: req.Amount,
Amount: chargeAmount,
Currency: "GBP",
SourceID: sourceID,
CustomerID: savedCardCustomerID,
@@ -1706,6 +1806,11 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
Note: req.PaymentType,
BuyerEmail: bookingBuyerEmail,
VerificationToken: verificationToken,
// C3: every online charge here is cardholder-initiated — a saved-card
// (ccof) source MUST carry customer_details for Square's stored-
// credential rules, and a new-card (cnon) nonce is entered by the
// buyer present at the keyboard, so the flag is true either way.
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
}
// M1: store the verbatim request JSON so the sweep can replay the charge
@@ -1717,7 +1822,9 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// sweep's replay away from the original charge (see the reuse branch above).
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
log.Printf("Failed to marshal square_request_snapshot for payment %s: %v", paymentID, mErr)
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(snap), paymentID); sErr != nil {
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for payment %s: %v", paymentID, eErr)
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(stored), paymentID); sErr != nil {
log.Printf("Failed to store square_request_snapshot for payment %s: %v", paymentID, sErr)
}
@@ -1728,7 +1835,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
return
}
paymentAmount := float64(req.Amount) / 100.0
paymentAmount := float64(chargeAmount) / 100.0
// Step 3: Square succeeded — record the completed payment state in a NEW
// transaction (split records, VAT, deposit promotion, campaigns). The
@@ -1780,12 +1887,24 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
return
}
// Apply eligible campaign discounts BEFORE the split records are inserted
// (C1): ComputeEligibleDiscounts refuses to apply NEW discounts once a
// booking has 2+ completed real payments, and the split below would
// otherwise count deposit + balance as exactly those 2 payments — a full
// discounted payment would never get its discount row and the booking
// would never auto-complete. Running the discount first means the guard
// only sees payments that existed before this transaction, so the
// discounted total is applied and bookingIsFullyPaid (which counts
// discount rows) completes the booking. The call is idempotent: discounts
// already recorded for the booking are skipped by the duplicate check.
applyEligibleCampaignsAtPayment(r.Context(), tx2, bookingID, userID)
// Build payment records — may split a single Square charge into
// a deposit portion (up to 50% of booking total) plus a balance
// portion, so the refund system can correctly track deposit vs
// non-deposit money per the deposit protection policy.
bookingInfo, bErr := service.GetBookingPaymentInfo(r.Context(), bookingID)
fees := service.CalculateFees(req.Amount, "online")
fees := service.CalculateFees(chargeAmount, "online")
primaryRecord := PaymentRecord{
BookingID: bookingID,
PaymentType: req.PaymentType,
@@ -1903,11 +2022,6 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
completeActiveBookingFromPayment(r.Context(), tx2, bookingID)
}
// Apply eligible campaign discounts inside the payment transaction, so
// atomicity with the payment inserts is guaranteed. The call is idempotent
// — if discounts were already applied, the duplicate check skips them.
applyEligibleCampaignsAtPayment(r.Context(), tx2, bookingID, userID)
if cErr := tx2.Commit(r.Context()); cErr != nil {
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but DB transaction commit failed: %v — manual reconciliation required",
paymentResult.Status, paymentResult.SquarePayID, cErr)
@@ -1920,7 +2034,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
BookingID: bookingID,
PaymentType: req.PaymentType,
Status: "completed",
Amount: req.Amount,
Amount: chargeAmount,
CardBrand: paymentResult.CardBrand,
CardLast4: paymentResult.CardLast4,
ReceiptURL: paymentResult.ReceiptURL,
@@ -2247,6 +2361,16 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
}
service := NewPaymentService()
// 2FA gating (H4): persisting a card via the account "add card" endpoint
// requires 2FA when the feature is enforced — the same gate the booking
// and tip flows apply to req.SaveCard. Persisting a stored credential is
// exactly what the PSD2 SCA stand-in protects, so the dedicated save-card
// endpoint must not be the un-gated side door.
if !requireTwoFactorForCardAccess(w, r, service, userID) {
return
}
card, err := service.CreatePaymentMethodFromToken(r.Context(), userID, req.CardToken)
if err != nil {
if isDefinitiveCardSaveFailure(err) {
@@ -2271,9 +2395,12 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
// (square.ErrorCode) exactly against the codes this codebase already recognizes
// for card failures (till.go's definitivePaymentDeclineCodes via
// isDefinitiveChargeFailure, plus the card-on-file creation codes SOURCE_USED /
// INVALID_REQUEST_ERROR), replacing the old substring match on "invalid" /
// "expired" in the formatted message so a Square wording change can never
// silently flip the 400↔500 classification. Errors carrying no structured code
// CARD_TOKEN_USED / CARD_TOKEN_EXPIRED / INVALID_CARD), and additionally treats
// any error carrying Square's INVALID_REQUEST_ERROR CATEGORY as definitive —
// real 400 card-save failures (e.g. MISSING_REQUIRED_PARAMETER) arrive with
// that category and a specific code, so checking the category catches them all.
// INVALID_REQUEST_ERROR is a category, NOT a code: it must be matched via
// square.ErrorCategory, never as a code. Errors carrying no structured code
// (transport errors, the dev mock's plain errors, 5xx) are ambiguous and stay
// 500 — retrying with the same inputs might succeed.
func isDefinitiveCardSaveFailure(err error) bool {
@@ -2284,7 +2411,10 @@ func isDefinitiveCardSaveFailure(err error) bool {
return true
}
switch square.ErrorCode(err) {
case "SOURCE_USED", "CARD_TOKEN_USED", "CARD_TOKEN_EXPIRED", "INVALID_CARD", "INVALID_REQUEST_ERROR":
case "SOURCE_USED", "CARD_TOKEN_USED", "CARD_TOKEN_EXPIRED", "INVALID_CARD":
return true
}
if square.ErrorCategory(err) == "INVALID_REQUEST_ERROR" {
return true
}
return false
@@ -3630,6 +3760,10 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
Note: "tip",
BuyerEmail: buyerEmail,
VerificationToken: verificationToken,
// C3: the tip charge is cardholder-initiated whether it uses a saved
// card (ccof — customer_details required) or a freshly entered card
// (cnon — buyer present), so the flag is true either way.
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
}
// M1: store the verbatim request JSON so the sweep can replay the charge
@@ -3641,7 +3775,9 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
// sweep's replay away from the original charge (see the reuse branch above).
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
log.Printf("Failed to marshal square_request_snapshot for tip payment %s: %v", paymentID, mErr)
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(snap), paymentID); sErr != nil {
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for tip payment %s: %v", paymentID, eErr)
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(stored), paymentID); sErr != nil {
log.Printf("Failed to store square_request_snapshot for tip payment %s: %v", paymentID, sErr)
}
@@ -0,0 +1,51 @@
package payments
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
// maxIdempotencyKeyLength caps idempotency keys at Square's /v2/payments limit
// (45 chars). The same key is replayed to CreatePayment, so the stricter
// 45-char cap applies even where a destination (e.g. CreateCheckout) allows 64.
// Client-supplied keys are validated against it ("omitempty,max=45") and
// server-derived keys are truncated to it via truncateIdempotencyKey.
const maxIdempotencyKeyLength = 45
// truncateIdempotencyKey applies the deterministic >45-char sha256 truncation
// shared by the derive* idempotency-key helpers: a candidate longer than
// maxIdempotencyKeyLength is hashed with SHA-256 and returned as
// "<prefix>-<hex of the first 16 hash bytes>", which stays within Square's
// 45-char /v2/payments limit. The hash is deterministic, so identical
// candidates always truncate to the same key — a lost-response retry re-derives
// the same truncated key and Square dedups the charge. Candidates at or under
// the limit are returned verbatim.
func truncateIdempotencyKey(prefix, candidate string) string {
if len(candidate) <= maxIdempotencyKeyLength {
return candidate
}
sum := sha256.Sum256([]byte(candidate))
return prefix + "-" + hex.EncodeToString(sum[:16])
}
// nextIdempotencyCandidate returns the idempotency-key candidate for slot
// sequence seq: the base key itself at seq 0, or "base-seq" at seq >= 1, then
// truncated via truncateIdempotencyKey so the final key stays inside Square's
// 45-char /v2/payments limit. The truncation prefix is derived from the base
// key ("gc-..." -> "gc", "till-..." -> "till") so the truncated form keeps the
// caller's namespace prefix. The slot-scan callers (scanTillIdempotencyKeySlot,
// deriveGiftCardIdempotencyKey) use this under their advisory lock so the
// scan-and-insert sequence is stable across retries.
func nextIdempotencyCandidate(base string, seq int) string {
candidate := base
if seq > 0 {
candidate = fmt.Sprintf("%s-%d", base, seq)
}
prefix := base
if i := strings.IndexByte(base, '-'); i > 0 {
prefix = base[:i]
}
return truncateIdempotencyKey(prefix, candidate)
}
@@ -85,6 +85,10 @@ func TestTipPayment_AcceptedAfterStart(t *testing.T) {
// M4-2: Cap pay-early at 100% — reject overpayment instead of silent tip
// =============================================================================
// TestBookingPayment_OverflowRejected_NoSilentTip verifies the M4/M7 overflow
// gate: a pre-start 'full' payment that exceeds the booking total is rejected
// with 400 overflow_tip_confirmation_required (the tip conversion needs the
// client's explicit confirmation) and creates no payment record.
func TestBookingPayment_OverflowRejected_NoSilentTip(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
@@ -105,14 +109,47 @@ func TestBookingPayment_OverflowRejected_NoSilentTip(t *testing.T) {
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
assert.Contains(t, w.Body.String(), "remaining balance")
assert.Contains(t, w.Body.String(), "The extra amount will be recorded as a tip")
assert.Contains(t, w.Body.String(), "overflow_tip_confirmation_required")
// No payment records may be created (the rejection happens before any
// pending record insert or Square charge), and no tip may be silently carved.
var count int
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&count)
require.NoError(t, err)
assert.Equal(t, 0, count, "overpayment must not create any payment record")
assert.Equal(t, 0, count, "unconfirmed overpayment must not create any payment record")
}
// TestBookingPayment_Overflow_Confirmed_RecordsTip pins the M7 confirmed path:
// the same pre-start overpayment WITH confirm_overflow_tip=true proceeds and
// the overflow beyond the booking total is recorded as a tip record (not
// silently dropped or double-counted).
func TestBookingPayment_Overflow_Confirmed_RecordsTip(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
// £60 on a £50 booking with deposit room: deposit £25 + balance £25 + £10
// tip overflow.
cardToken := "cnon:overflow-confirmed-card"
req := CreateBookingPaymentRequest{
Amount: 6000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "overflow-confirmed-" + bookingID,
ConfirmOverflowTip: true,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
var tipCount int
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed'`, bookingID).Scan(&tipCount)
require.NoError(t, err)
assert.Equal(t, 1, tipCount, "a confirmed overpayment must be recorded as a tip record")
}
func TestBookingPayment_FullRemainingBalance_Accepted(t *testing.T) {
@@ -6,6 +6,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -313,6 +314,95 @@ func TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges(t *testing.
require.Equal(t, 2, cardCount)
}
// TestBuyGiftCard_NoClientKey_FailedSlot_AdvancesToFreshKey locks the A10 fix:
// a no-key purchase whose payment row was swept/declined to 'failed' must NOT
// permanently block the identical repurchase. The slot scan advances past BOTH
// COMPLETED and FAILED rows (mirroring the till's scanTillIdempotencyKeySlot),
// so the repurchase derives a FRESH key and charges again instead of
// 409-rejecting forever on the failed row.
func TestBuyGiftCard_NoClientKey_FailedSlot_AdvancesToFreshKey(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
// The deterministic fallback key a no-key £20 self purchase would derive.
failedKey := fmt.Sprintf("gc-%s-2000-self-new", userID)
_, err = tx.Exec(ctx, `
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at)
VALUES ('full', 'online_square', 'failed', 20.00, $1, $2, NOW(), NOW())
`, failedKey, userID)
require.NoError(t, err)
// The identical repurchase must SUCCEED on a fresh key — not 409 forever.
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code != http.StatusCreated {
t.Fatalf("expected the repurchase after a failed slot to succeed, got %d: %s", code, body)
}
// Two distinct keys: the failed slot key + the fresh advance key.
var keyCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount))
require.Equal(t, 2, keyCount, "the repurchase must diverge onto a fresh key, not reuse the failed slot")
}
// TestBuyGiftCard_ForeignIdempotencyKey_NotReused locks the A6 fix: a
// client-supplied idempotency key that matches ANOTHER user's payment row must
// never be returned (completed), reused (pending), or rejected on (failed) —
// cross-user hijack. The purchase proceeds as a fresh request on a fresh key.
func TestBuyGiftCard_ForeignIdempotencyKey_NotReused(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
victimID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
attackerID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(attackerID, "verified_email")
// The victim's COMPLETED payment under a deterministic/guessable key.
victimKey := fmt.Sprintf("gc-%s-2000-self-new", victimID)
_, err = tx.Exec(ctx, `
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at)
VALUES ('full', 'online_square', 'completed', 20.00, $1, $2, NOW(), NOW())
`, victimKey, victimID)
require.NoError(t, err)
// The attacker supplies the victim's key: must NOT get the victim's
// completed payment back (which would be a false success leaking the
// victim's row) — it must proceed as a fresh charge.
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": 2000,
"recipient_type": "self",
"new_card_token": "cnon:card-nonce-ok",
"idempotency_key": victimKey,
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
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/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "a foreign-key purchase must proceed as a fresh charge, body: %s", w.Body.String())
// A gift card was issued to the ATTACKER, not the victim.
var cardCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", attackerID).Scan(&cardCount))
require.Equal(t, 1, cardCount, "the attacker must receive their own gift card")
// The victim's payment row is untouched and the attacker got their own row.
var victimPayCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", victimKey).Scan(&victimPayCount))
require.Equal(t, 1, victimPayCount, "the victim's payment row must not be reused or duplicated")
var attackerPayCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1 AND status = 'completed'", attackerID).Scan(&attackerPayCount))
require.Equal(t, 1, attackerPayCount, "the attacker must have exactly one completed payment of their own")
}
// =============================================================================
// H4 — a COMPLETED provisional terminal checkout must be recorded, not just
// released
@@ -82,23 +82,28 @@ func (c *recordingCustomerClient) customerCalls() []string {
}
// definitiveChargeClient simulates a Square charge rejection that can never
// succeed (declined) — a definitive failure.
// succeed (declined) — a definitive failure. createErr carries the structured
// CARD_DECLINED error the real client produces (see the construction sites), so
// chargeFailureStatus classifies it as 402 and isDefinitiveChargeFailure claws
// the funded card back.
type definitiveChargeClient struct {
square.SquareClient
createErr error
}
func (c *definitiveChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
return nil, fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/CARD_DECLINED] card declined")
return nil, c.createErr
}
// ambiguousChargeClient simulates a transport-level charge failure where Square
// may or may not have processed the payment — an ambiguous failure.
type ambiguousChargeClient struct {
square.SquareClient
createErr error
}
func (c *ambiguousChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
return nil, fmt.Errorf("network error: connection reset by peer")
return nil, c.createErr
}
// ---------------------------------------------------------------------------
@@ -172,7 +177,7 @@ func TestCreateTillSale_DefinitiveFailure_ClawsBackCreatedGiftCard(t *testing.T)
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()}
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
@@ -236,7 +241,7 @@ func TestCreateTillSale_DefinitiveFailure_ClawsBackTopUp(t *testing.T) {
`, adminID).Scan(&gcID))
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()}
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
@@ -289,7 +294,7 @@ func TestCreateTillSale_DefinitiveFailure_ClawsBackRedeemedCard(t *testing.T) {
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()}
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
@@ -328,7 +333,7 @@ func TestCreateTillSale_AmbiguousFailure_LeavesCardFundedPending(t *testing.T) {
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
SquareClient = &ambiguousChargeClient{SquareClient: square.NewDevClient()}
SquareClient = &ambiguousChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareAPIError(t, http.StatusInternalServerError)}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
@@ -351,7 +356,7 @@ func TestCreateTillSale_AmbiguousFailure_LeavesCardFundedPending(t *testing.T) {
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusPaymentRequired, w.Code)
require.Equal(t, http.StatusServiceUnavailable, w.Code, "an ambiguous charge failure must classify as 503 (not a definitive 402)")
// Ambiguous failure — the sale stays pending for the sweep, NOT failed.
var status string
@@ -497,7 +497,7 @@ func TestCreateTillSale_PendingRetry_DefinitiveFailure_ClawsBack(t *testing.T) {
require.NoError(t, err)
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()}
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
@@ -4578,3 +4578,66 @@ func TestBuildSplitRecords_TipOverflow_SeparateTipRecord(t *testing.T) {
t.Error("tip split must share square_payment_id")
}
}
// TestBookingPayment_FullDiscountedAmount_AppliesDiscountAndCompletes is the
// C1 money-bug regression: a user paying the FULL discounted amount on a
// booking with an active time-based campaign must have the campaign discount
// applied (booking_discounts row + discount payment record + campaign
// redemption) and the booking must auto-complete. Previously the deposit+
// balance split was inserted BEFORE applyEligibleCampaignsAtPayment ran, so
// the split's two just-inserted completed records tripped the
// existingPayment>=2 guard in ComputeEligibleDiscounts and the discount was
// never applied — the booking stayed unpaid on paper and never completed.
func TestBookingPayment_FullDiscountedAmount_AppliesDiscountAndCompletes(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Fixture booking total is £50 (one test service). A 10% time-based
// campaign makes the discounted total £45.
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
now := clock.Now()
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10, 'active', $2, $3, 0)
RETURNING id
`, "Summer Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)
require.NoError(t, err)
cardToken := "cnon:discounted-full"
req := CreateBookingPaymentRequest{
Amount: 4500, // £45 = £50 - 10% discount
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "discounted-full-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
// The campaign discount must have been applied for this booking.
var discountCount int
require.NoError(t, tx.QueryRow(ctx,
`SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND source_id = $2`,
bookingID, campaignID).Scan(&discountCount))
assert.Equal(t, 1, discountCount, "the campaign discount must be applied when the full discounted amount is paid")
// The discount payment record and the campaign redemption counter follow.
var discountPaymentCount int
require.NoError(t, tx.QueryRow(ctx,
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&discountPaymentCount))
assert.Equal(t, 1, discountPaymentCount, "the discount payment record must exist")
var redeemed int
require.NoError(t, tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed))
assert.Equal(t, 1, redeemed, "the campaign must be redeemed exactly once")
// The booking must auto-complete: £25 deposit + £20 balance (real money)
// plus the £5 discount covers the £50 total.
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status))
assert.Equal(t, "completed", status, "a booking paid to its discounted total must auto-complete")
}
+381 -78
View File
@@ -12,6 +12,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"crussell/clock"
@@ -21,6 +22,163 @@ import (
"github.com/jackc/pgx/v5"
)
// Refunds are exempt from buyer verification. Square's RefundPayment endpoint
// does not support 3DS/SCA verification tokens (verification is a charge-time
// concept — PSD2 SCA applies to the original payment capture, never to the
// money flowing back out), so no refund path carries a verification token and
// none ever will. The practical already-refunded signal is Square's
// REFUND_AMOUNT_INVALID error (PAYMENT_ALREADY_REFUNDED is no longer
// documented): on a genuinely-refunded payment that code is NOT a decline —
// the handler reconciles via an EXACT-amount COMPLETED-refund check
// (reconcileRefundAtSquareExact, see the A11 disambiguation in
// processChargeGroup / processManualPaymentGroup) and resolves the refund row
// to 'completed' instead of failing it, so the over-refund guard never
// re-issues money Square already returned.
//
// stalePendingRefundAge is the age guard for pending refunds: Square's
// idempotency-key retention is finite (~24h), so a pending refund older than
// this must be RECONCILED against Square (ListPaymentRefunds) before any
// re-issue — re-issuing with a key Square no longer retains would be treated
// as a NEW refund (double refund). A pending row older than
// stalePendingRefundAge that Square shows no COMPLETED refund for is marked
// 'failed' and surfaced for manual arrangement instead.
const stalePendingRefundAge = 23 * time.Hour
// maxManualRefundAttempts caps retry attempts for a refund stuck on a
// decline/ambiguous outcome before it is resolved terminally. Both the manual
// sweep (processManualPaymentGroup / resolveManualRefundAtCap) and the
// cancellation charge-group sweeps share the same cap: rows are retried while
// refund_attempts < maxManualRefundAttempts, and at the cap a refund is
// reconciled at Square FIRST (never marked failed while the money state is
// unknown — that would let the over-refund guard exclude money that actually
// left the business), then resolved to 'completed' or 'failed' + admin
// notification. The SQL literals below are formatted with this constant so
// the DB filter and the Go cap can never drift apart.
const maxManualRefundAttempts = 3
// maxConsecutiveReconcileFailures is the number of consecutive cap-time
// reconcile failures (each causing a re-arm under the attempt cap) before a
// refund row is surfaced in the admin notification centre. A reconcile failure
// is an UNKNOWN money state — the row is never marked 'failed' on it — but
// silently re-arming forever would oscillate the row between the cap and
// cap-1 indefinitely with zero admin visibility (A5b/A5c). After this many
// consecutive failures a deduped 'critical_payment_log' admin notification is
// inserted so the owner learns the reconcile is hard-failing.
const maxConsecutiveReconcileFailures = 5
// manualReconcileFailures counts consecutive cap-time reconcile failures per
// refund row (keyed by refunds.id). resolveManualRefundAtCap and the
// charge-group cap path re-arm a row under the attempt cap on a reconcile
// error so the next sweep re-picks it; without a counter that re-arm loops
// forever with no notification. The counter is in-memory (no schema change —
// the schema is single-source and pre-launch, no ALTERs): it counts
// CONSECUTIVE failures, is reset whenever a reconcile succeeds (the row is
// resolved completed/failed), and after maxConsecutiveReconcileFailures
// failures triggers a deduped critical_payment_log admin notification. A
// process restart merely resets the counter, deferring the notification by a
// few sweeps — never suppressing it (the row stays pending and keeps being
// re-reconciled, so the notification eventually fires).
var (
manualReconcileFailureMu sync.Mutex
manualReconcileFailures = make(map[string]int)
)
// trackReconcileFailureReArm records one more consecutive cap-time reconcile
// failure for each refund row and, once a row crosses
// maxConsecutiveReconcileFailures, surfaces a deduped 'critical_payment_log'
// admin notification for the affected booking(s) — the admin MUST learn a
// reconcile is hard-failing instead of the row silently oscillating under the
// attempt cap forever (A5b/A5c).
func trackReconcileFailureReArm(ctx context.Context, ids []string) {
manualReconcileFailureMu.Lock()
notify := false
for _, id := range ids {
manualReconcileFailures[id]++
if manualReconcileFailures[id] == maxConsecutiveReconcileFailures {
notify = true
}
}
manualReconcileFailureMu.Unlock()
if !notify {
return
}
notifyCriticalReconcileFailure(ctx, ids)
}
// resetReconcileFailureCount clears a refund row's consecutive-reconcile-
// failure counter. Called whenever a reconcile SUCCEEDS (the row is resolved
// to 'completed' or definitively 'failed'), so the counter reflects
// consecutive failures only — a success in between breaks the streak.
func resetReconcileFailureCount(ids ...string) {
manualReconcileFailureMu.Lock()
for _, id := range ids {
delete(manualReconcileFailures, id)
}
manualReconcileFailureMu.Unlock()
}
// notifyCriticalReconcileFailure inserts ONE 'critical_payment_log' admin
// notification per affected booking (deduped by insertCriticalPaymentNotification)
// so a hard-failing reconcile surfaces in the admin notification centre.
// Rows without a booking (gift-card purchases) collapse into one booking-less
// notification — the money event is surfaced, not lost.
func notifyCriticalReconcileFailure(ctx context.Context, ids []string) {
rows, err := db.Conn.Query(ctx, `
SELECT DISTINCT booking_id FROM refunds
WHERE id = ANY($1) AND booking_id IS NOT NULL
`, ids)
if err != nil {
log.Printf("Failed to query bookings for critical reconcile-failure notification: %v", err)
insertCriticalPaymentNotification(ctx, nil, nil)
return
}
var bookingIDs []string
for rows.Next() {
var b string
if err := rows.Scan(&b); err == nil {
bookingIDs = append(bookingIDs, b)
}
}
rows.Close()
if len(bookingIDs) == 0 {
insertCriticalPaymentNotification(ctx, nil, nil)
return
}
for _, b := range bookingIDs {
bid := b
insertCriticalPaymentNotification(ctx, &bid, nil)
}
}
// reconcileRefundAtSquareExact checks Square for a COMPLETED refund matching
// the EXACT payment+amount, WITHOUT an age bound. Same exact-match semantics
// as reconcileRefundAtSquare (payment_id AND status COMPLETED AND amount), minus
// the begin_time filter: the REFUND_AMOUNT_INVALID disambiguation (A11) must
// not miss a pre-existing refund that predates our refund row. Only an
// exact-amount COMPLETED refund proves the money for THIS amount already moved
// — a smaller partial refund does NOT, and attributing it would mark the row
// completed when only part of the amount was refunded.
//
// Tri-state return matches reconcileRefundAtSquare:
//
// (id, nil) — exact COMPLETED refund found
// (nil, nil) — genuinely no exact match
// (nil, err) — reconcile failed (network/API error)
func reconcileRefundAtSquareExact(ctx context.Context, chargeID string, amountCents int64) (*string, error) {
refunds, err := SquareClient.ListPaymentRefunds(ctx, chargeID, time.Time{})
if err != nil {
log.Printf("Failed to reconcile charge %s against Square: %v", chargeID, err)
return nil, err
}
for i := range refunds {
r := &refunds[i]
if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == amountCents {
return &r.ID, nil
}
}
return nil, nil
}
type RefundCalculationResult struct {
TotalPrePaid float64 `json:"total_pre_paid"`
ProtectedDeposit float64 `json:"protected_deposit"`
@@ -51,6 +209,13 @@ type paymentRow struct {
// The "protected deposit" is defined as min(totalPrePaid, subtotal * 0.50).
// This means up to 50% of the subtotal is always treated as a deposit for
// refund purposes, regardless of whether deposit_required was set on the booking.
//
// These tiers are the DEFAULT retention for a cancellation (customer calls to
// cancel, or the admin cancels on the customer's behalf without forgiving
// fees). The admin "forgive fees" path (forceFullRefund) overrides the tiers
// entirely so the business keeps nothing — see ProcessCancellationRefundTx for
// the two intents (excusable cancellation vs business-initiated cancellation)
// that share that override.
func CalculateRefundForCancellation(
subtotal float64,
totalPrePaid float64,
@@ -143,9 +308,25 @@ func lockCancellationPayments(ctx context.Context, tx pgx.Tx, payments []payment
// ProcessCancellationRefundTx is like ProcessCancellationRefund but uses an
// externally-provided transaction. The caller owns the transaction lifecycle
// (commit/rollback). Pass a non-nil pgx.Tx to share an existing transaction.
// forceFullRefund overrides the notice-tier calculation so the ENTIRE net
// pre-paid amount is refunded (admin "forgive fees" path) regardless of how
// close to the appointment the cancellation happens.
//
// The admin "forgive fees" checkbox (forceFullRefund) serves TWO distinct
// purposes, and both route here identically — it overrides the notice-tier
// calculation so the ENTIRE net pre-paid amount is refunded regardless of how
// close to the appointment the cancellation happens:
//
// - (A) Genuinely excusable cancellation: the customer has a legitimate
// excuse (medical, emergency, technical fault, etc.) and the business
// chooses to waive its notice-period fees as a goodwill gesture.
// - (B) Business-initiated cancellation: the salon had to cancel the
// appointment and chooses NOT to keep the money (the deposit/notice
// retention would be unfair when the cancellation is the business's
// doing).
//
// These two intents are deliberately NOT distinguished in the refund
// calculation — both mean "the business keeps nothing". When the admin cancels
// on a customer's behalf or a customer calls up to cancel without a forgivable
// excuse, the notice tiers below apply (forceFullRefund=false); the checkbox
// is the explicit opt-out from that retention.
func ProcessCancellationRefundTx(
ctx context.Context,
tx pgx.Tx,
@@ -238,10 +419,14 @@ func ProcessCancellationRefundTx(
// and a cancellation refund cannot be recorded after the manual guard ran
// without the two serializing. Square no longer documents
// PAYMENT_ALREADY_REFUNDED; the realistic already-refunded response is
// REFUND_AMOUNT_INVALID, which the client maps to ErrRefundDeclined
// (definitive) — the charge-group/manual sweep handlers fail those rows and
// surface them via admin_notification rather than silently blocking the
// amount in the guard.
// REFUND_AMOUNT_INVALID. The square client reconciles that code against
// Square's refund list (PaymentWasRefunded) and maps an already-refunded
// payment to ErrRefundAlreadyProcessed; the charge-group/manual sweep
// handlers additionally reconcile REFUND_AMOUNT_INVALID via an exact-amount
// COMPLETED-refund check (reconcileRefundAtSquareExact, defense-in-depth —
// see the A11 disambiguation) and resolve those rows to 'completed' — never
// 'failed' + admin_notification, which would let the over-refund guard
// re-issue money Square already returned.
priorRefunds := make(map[string]float64)
prRows, prErr := tx.Query(ctx, `
SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds
@@ -551,17 +736,17 @@ func ProcessPendingSquareRefunds(ctx context.Context, bookingID string, reason s
// notification centre for in-person arrangement. Must run OUTSIDE any
// GROUP BY — Postgres lumps NULLs together, so these rows can't be
// handled in the charge grouping below.
rows, err := db.Conn.Query(ctx, `
rows, err := db.Conn.Query(ctx, fmt.Sprintf(`
UPDATE refunds r SET status = 'failed'
FROM payments p
WHERE p.id = r.payment_id
AND r.booking_id = $1
AND r.status = 'pending' AND r.refund_attempts < 3
AND r.status = 'pending' AND r.refund_attempts < %d
AND p.payment_method IN ('online_square', 'in_person_card')
AND p.square_payment_id IS NULL
AND r.origin = 'cancellation'
RETURNING r.id
`, bookingID)
`, maxManualRefundAttempts), bookingID)
if err != nil {
log.Printf("Failed to mark Square-less card refunds failed for booking %s: %v", bookingID, err)
} else {
@@ -600,16 +785,16 @@ func SweepPendingSquareRefunds(ctx context.Context) (int, error) {
// for in-person arrangement. Must run OUTSIDE any GROUP BY — Postgres
// lumps NULLs together, so these rows can't be handled in the charge
// grouping below.
rows, err := db.Conn.Query(ctx, `
rows, err := db.Conn.Query(ctx, fmt.Sprintf(`
UPDATE refunds r SET status = 'failed'
FROM payments p
WHERE p.id = r.payment_id
AND r.status = 'pending' AND r.refund_attempts < 3
AND r.status = 'pending' AND r.refund_attempts < %d
AND p.payment_method IN ('online_square', 'in_person_card')
AND p.square_payment_id IS NULL
AND r.origin = 'cancellation'
RETURNING r.id
`)
`, maxManualRefundAttempts))
if err != nil {
log.Printf("Failed to mark Square-less card refunds failed: %v", err)
} else {
@@ -667,14 +852,14 @@ type pendingChargeRow struct {
// have at least one eligible pending cancellation refund row. extraWhere is an
// optional extra SQL predicate bound by args (e.g. "r.booking_id = $1").
func queryChargesWithPendingRefunds(ctx context.Context, extraWhere string, args ...any) []string {
q := `
q := fmt.Sprintf(`
SELECT DISTINCT p.square_payment_id
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE r.status = 'pending' AND r.refund_attempts < 3
WHERE r.status = 'pending' AND r.refund_attempts < %d
AND p.square_payment_id IS NOT NULL
AND p.payment_method IN ('online_square', 'in_person_card')
AND r.origin = 'cancellation'`
AND r.origin = 'cancellation'`, maxManualRefundAttempts)
if extraWhere != "" {
q += " AND " + extraWhere
}
@@ -702,15 +887,15 @@ func queryChargesWithPendingRefunds(ctx context.Context, extraWhere string, args
// fetchPendingChargeRows returns all eligible pending cancellation refund rows
// for a single Square charge, ordered by refund id.
func fetchPendingChargeRows(ctx context.Context, chargeID string) []pendingChargeRow {
rows, err := db.Conn.Query(ctx, `
rows, err := db.Conn.Query(ctx, fmt.Sprintf(`
SELECT r.id, p.id, r.amount, r.created_at
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE p.square_payment_id = $1
AND r.status = 'pending' AND r.refund_attempts < 3
AND r.status = 'pending' AND r.refund_attempts < %d
AND r.origin = 'cancellation'
ORDER BY r.id
`, chargeID)
`, maxManualRefundAttempts), chargeID)
if err != nil {
log.Printf("Failed to query pending refunds for charge %s: %v", chargeID, err)
return nil
@@ -731,6 +916,20 @@ func fetchPendingChargeRows(ctx context.Context, chargeID string) []pendingCharg
return out
}
// isRefundAmountInvalid reports whether err carries Square's
// REFUND_AMOUNT_INVALID code — structurally (the real HTTP client wraps the
// code in a squareAPIError) or by message (the dev mock embeds the code in its
// simulated error).
func isRefundAmountInvalid(err error) bool {
if err == nil {
return false
}
if square.ErrorCode(err) == "REFUND_AMOUNT_INVALID" {
return true
}
return strings.Contains(err.Error(), "REFUND_AMOUNT_INVALID")
}
// reconcileRefundAtSquare checks Square for a COMPLETED refund matching the
// exact charge-level amount before the age guard / attempt cap marks rows
// 'failed'. Tri-state return:
@@ -788,16 +987,16 @@ func insertRefundFailedNotifications(ctx context.Context, refundIDs []string) {
}
}
// pendingRowsAtAttemptCap returns the refund ids still pending at the 3-attempt
// cap — the candidates for terminal 'failed' resolution.
// pendingRowsAtAttemptCap returns the refund ids still pending at the
// maxManualRefundAttempts cap — the candidates for terminal 'failed' resolution.
func pendingRowsAtAttemptCap(ctx context.Context, ids []string) []string {
if len(ids) == 0 {
return nil
}
rows, err := db.Conn.Query(ctx, `
rows, err := db.Conn.Query(ctx, fmt.Sprintf(`
SELECT id FROM refunds
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= 3
`, ids)
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d
`, maxManualRefundAttempts), ids)
if err != nil {
log.Printf("Failed to query refunds at attempt cap: %v", err)
return nil
@@ -883,10 +1082,10 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
for _, r := range rows {
ids = append(ids, r.ID)
}
pendingRows, err := db.Conn.Query(ctx, `
pendingRows, err := db.Conn.Query(ctx, fmt.Sprintf(`
SELECT id, amount, created_at FROM refunds
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts < 3
`, ids)
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts < %d
`, maxManualRefundAttempts), ids)
if err != nil {
log.Printf("Failed to re-read pending refunds under lock (charge %s): %v", chargeID, err)
return 0, nil
@@ -906,12 +1105,12 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
}
// Age guard: Square's idempotency-key retention is finite (~24h). If the
// oldest pending row predates 23 hours, re-issuing with the same charge key
// risks Square treating it as a NEW refund → double refund. Reconcile FIRST:
// money may already have moved at Square (response loss), and failing the
// rows without checking would let the over-refund guard exclude money that
// actually left the business. Only when Square shows no exact COMPLETED
// refund do we mark failed and surface for manual review.
// oldest pending row predates stalePendingRefundAge, re-issuing with the
// same charge key risks Square treating it as a NEW refund → double refund.
// Reconcile FIRST: money may already have moved at Square (response loss),
// and failing the rows without checking would let the over-refund guard
// exclude money that actually left the business. Only when Square shows no
// exact COMPLETED refund do we mark failed and surface for manual review.
oldest := pending[0].CreatedAt
for _, pr := range pending[1:] {
if pr.CreatedAt.Before(oldest) {
@@ -922,7 +1121,7 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
for _, pr := range pending {
totalCents += int64(math.Round(pr.Amount * 100))
}
if clock.Now().Sub(oldest) > 23*time.Hour {
if clock.Now().Sub(oldest) > stalePendingRefundAge {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalCents, oldest)
switch {
case rcErr != nil:
@@ -951,7 +1150,7 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
// channel. Verify the Square dashboard first.
log.Printf("Card refunds for charge %s are older than 23h and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", chargeID)
log.Printf("Card refunds for charge %s are older than stalePendingRefundAge (23h) and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", chargeID)
return len(pending), nil
}
}
@@ -975,14 +1174,48 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
// double-refunding the customer (C6). Deduping on the charge key returns
// the original refund instead; the new row resolves against it and any
// residual gap is a known, admin-visible shortfall rather than lost
// money. The 23h age-guard reconcile above still protects the cross-sweep
// case where Square's finite (~24h) key retention may have lapsed.
// money. The stalePendingRefundAge age-guard reconcile above still
// protects the cross-sweep case where Square's finite (~24h) key
// retention may have lapsed.
sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: chargeID,
Amount: totalCents,
IdempotencyKey: chargeAggKey(chargeID),
Reason: reason,
})
// REFUND_AMOUNT_INVALID is Square's ambiguous signal for BOTH a genuinely
// invalid refund amount AND an already-refunded payment (Square no longer
// documents PAYMENT_ALREADY_REFUNDED). Disambiguate with the EXACT amount
// BEFORE the classification switch so the reconciliation applies whether
// the client classified the code as a definitive decline or as
// already-processed: only an exact-amount COMPLETED refund at Square proves
// the money for THIS amount already moved (A11). A smaller partial refund
// does NOT — marking the rows completed would claim the full amount was
// refunded when only part of it was.
if isRefundAmountInvalid(sqErr) {
sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, chargeID, totalCents)
switch {
case rcErr != nil:
// Reconcile failed — unknown money state. Keep the client's own
// classification below (the switch on sqErr) rather than making a
// money decision on partial data.
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed'
WHERE id = ANY($1) AND status = 'pending'
`, idsOf(pending)); upErr != nil {
log.Printf("Failed to resolve refunds completed after REFUND_AMOUNT_INVALID on already-refunded charge %s: %v", chargeID, upErr)
}
log.Printf("Charge %s already refunded at Square (REFUND_AMOUNT_INVALID + exact-amount COMPLETED refund %s) — marked %d refund row(s) completed, no admin notification", chargeID, *sqRefundID, len(pending))
return len(pending), nil
default:
// No exact-amount COMPLETED refund exists — REFUND_AMOUNT_INVALID
// here is a genuine decline/invalid amount (the attempt would have
// over-refunded a partially-refunded payment), NOT an
// already-refunded signal. Route through the decline branch.
sqErr = square.ErrRefundDeclined
}
}
switch {
case sqErr == nil:
// Resolve by Square's status: COMPLETED resolves the group; PENDING
@@ -1020,8 +1253,8 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
return len(pending), nil
case errors.Is(sqErr, square.ErrRefundDeclined):
// Definitive decline — money will never move. Bump attempts; at >=3
// mark failed and surface for manual arrangement.
// Definitive decline — money will never move. Bump attempts; at
// maxManualRefundAttempts mark failed and surface for manual arrangement.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET refund_attempts = refund_attempts + 1
WHERE id = ANY($1) AND status = 'pending'
@@ -1029,11 +1262,11 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
log.Printf("Failed to increment refund attempts (charge %s): %v", chargeID, upErr)
}
if capIDs := pendingRowsAtAttemptCap(ctx, idsOf(pending)); len(capIDs) > 0 {
if _, upErr := db.Conn.Exec(ctx, `
if _, upErr := db.Conn.Exec(ctx, fmt.Sprintf(`
UPDATE refunds SET status = 'failed'
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= 3
`, capIDs); upErr != nil {
log.Printf("Failed to mark refunds failed after 3 attempts (charge %s): %v", chargeID, upErr)
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d
`, maxManualRefundAttempts), capIDs); upErr != nil {
log.Printf("Failed to mark refunds failed after %d attempts (charge %s): %v", maxManualRefundAttempts, chargeID, upErr)
}
insertRefundFailedNotifications(ctx, capIDs)
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
@@ -1046,7 +1279,7 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
default:
// Ambiguous — Square may or may not have processed. Retried by the
// sweep, capped at 3 attempts.
// sweep, capped at maxManualRefundAttempts attempts.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET refund_attempts = refund_attempts + 1
WHERE id = ANY($1) AND status = 'pending'
@@ -1063,9 +1296,26 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
// Reconcile failed — unknown whether Square refunded. Leave
// rows pending for the next sweep; NEVER mark failed on an
// unknown state (that would let the over-refund guard exclude
// moved money).
log.Printf("Reconcile failed for ambiguous charge %s (%v) — leaving %d refund row(s) pending for the next sweep", chargeID, rcErr, len(capIDs))
// moved money). But leaving the rows AT the cap strands them:
// the sweep only re-picks rows with refund_attempts <
// maxManualRefundAttempts, so capped rows are never
// re-reconciled and never admin-notified — silently stuck
// money (A5c). Re-arm the capped rows under the cap (mirroring
// resolveManualRefundAtCap) so the next sweep re-picks them,
// and track consecutive reconcile failures: after
// maxConsecutiveReconcileFailures consecutive failures a
// deduped 'critical_payment_log' admin notification surfaces
// the hard-failing reconcile.
if _, upErr := db.Conn.Exec(ctx, fmt.Sprintf(`
UPDATE refunds SET refund_attempts = %d
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d
`, maxManualRefundAttempts-1, maxManualRefundAttempts), capIDs); upErr != nil {
log.Printf("Failed to re-arm capped refunds under the attempt cap after reconcile error (charge %s): %v", chargeID, upErr)
}
trackReconcileFailureReArm(ctx, capIDs)
log.Printf("Reconcile failed for ambiguous charge %s (%v) — re-armed %d refund row(s) under the attempt cap for the next sweep; never marked failed on an unknown state", chargeID, rcErr, len(capIDs))
case sqRefundID != nil:
resetReconcileFailureCount(capIDs...)
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = ANY($2) AND status = 'pending'
@@ -1074,11 +1324,12 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
}
log.Printf("Ambiguous card refunds for charge %s reconciled at Square — COMPLETED refund %s found, marked completed", chargeID, *sqRefundID)
default:
if _, upErr := db.Conn.Exec(ctx, `
resetReconcileFailureCount(capIDs...)
if _, upErr := db.Conn.Exec(ctx, fmt.Sprintf(`
UPDATE refunds SET status = 'failed'
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= 3
`, capIDs); upErr != nil {
log.Printf("Failed to mark refunds failed after 3 attempts (charge %s): %v", chargeID, upErr)
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d
`, maxManualRefundAttempts), capIDs); upErr != nil {
log.Printf("Failed to mark refunds failed after %d attempts (charge %s): %v", maxManualRefundAttempts, chargeID, upErr)
}
insertRefundFailedNotifications(ctx, capIDs)
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
@@ -1180,16 +1431,16 @@ func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
// and no row is swept by both passes. Must run OUTSIDE any GROUP BY —
// Postgres lumps NULLs together, so these rows can't be handled in the
// per-payment grouping below.
rows, err := db.Conn.Query(ctx, `
rows, err := db.Conn.Query(ctx, fmt.Sprintf(`
UPDATE refunds r SET status = 'failed'
FROM payments p
WHERE p.id = r.payment_id
AND r.status = 'pending' AND r.refund_attempts < 3
AND r.status = 'pending' AND r.refund_attempts < %d
AND p.payment_method IN ('online_square', 'in_person_card')
AND p.square_payment_id IS NULL
AND r.origin = 'manual'
RETURNING r.id
`)
`, maxManualRefundAttempts))
if err != nil {
log.Printf("Failed to mark Square-less manual refunds failed: %v", err)
} else {
@@ -1212,16 +1463,16 @@ func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
// (b) Manual refunds WITH a Square reference — the rows below are the only
// ones the retry/reconcile logic can act on.
rows, err = db.Conn.Query(ctx, `
rows, err = db.Conn.Query(ctx, fmt.Sprintf(`
SELECT r.id, r.payment_id, p.booking_id, r.amount, r.idempotency_key, r.reason,
p.square_payment_id, r.square_refund_id, r.created_at
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE r.status = 'pending' AND r.origin = 'manual'
AND r.refund_attempts < 3
AND r.refund_attempts < %d
AND p.square_payment_id IS NOT NULL
ORDER BY r.payment_id, r.id
`)
`, maxManualRefundAttempts))
if err != nil {
log.Printf("Failed to query manual pending refunds for retry: %v", err)
return 0, nil
@@ -1341,14 +1592,14 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
}
// Re-read under the lock — only rows still pending and under the attempt
// cap are eligible (a concurrent manual refund may have resolved some).
prRows, err := db.Conn.Query(ctx, `
prRows, err := db.Conn.Query(ctx, fmt.Sprintf(`
SELECT r.id, p.booking_id, r.amount, r.idempotency_key, r.reason, r.created_at,
r.payment_id, p.square_payment_id, r.square_refund_id
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE r.id = ANY($1) AND r.status = 'pending' AND r.refund_attempts < 3
WHERE r.id = ANY($1) AND r.status = 'pending' AND r.refund_attempts < %d
ORDER BY r.id
`, ids)
`, maxManualRefundAttempts), ids)
if err != nil {
return 0, err
}
@@ -1385,7 +1636,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
oldest = pr.CreatedAt
}
}
if clock.Now().Sub(oldest) > 23*time.Hour {
if clock.Now().Sub(oldest) > stalePendingRefundAge {
processedAged := 0
for i := range pending {
pr := &pending[i]
@@ -1416,7 +1667,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
// channel.
log.Printf("Manual refund %s is older than 23h and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID)
log.Printf("Manual refund %s is older than stalePendingRefundAge (23h) and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID)
}
}
return processedAged, nil
@@ -1470,7 +1721,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
// recorded it → failed + admin notification; a reconcile error is an
// UNKNOWN state → leave pending (never mark failed on an unknown state,
// that would let the over-refund guard exclude money that may have
// moved). Mirrors the 23h age-guard branch above.
// moved). Mirrors the stalePendingRefundAge age-guard branch above.
if pr.SquareRefundID != "" {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
switch {
@@ -1507,8 +1758,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
if keyErr != nil {
// The key could not be persisted — Square must not be called with an
// empty/unknown key. Leave the row pending for the next sweep (never
// mark failed on an unknown state); the 23h age guard above will
// eventually reconcile it.
// mark failed on an unknown state); the stalePendingRefundAge age
// guard above will eventually reconcile it.
log.Printf("Failed to ensure refund key for manual refund %s before re-issue: %v", pr.ID, keyErr)
continue
}
@@ -1518,6 +1769,39 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
IdempotencyKey: idemKey,
Reason: pr.Reason,
})
// REFUND_AMOUNT_INVALID is Square's ambiguous already-refunded-or-
// invalid-amount signal. Disambiguate with the EXACT amount (A11): only
// an exact-amount COMPLETED refund at Square proves THIS amount already
// moved — a smaller partial refund does NOT, and completing the row
// would claim the full amount was refunded when only part of it was.
// Mirrors the processChargeGroup disambiguation so the reconciliation
// applies whether the client classified the code as a definitive
// decline or as already-processed.
if isRefundAmountInvalid(sqErr) {
sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, pr.SquarePaymentID, amountCents)
switch {
case rcErr != nil:
// Reconcile failed — unknown money state. Keep the client's
// own classification below (the switch on sqErr) rather than
// making a money decision on partial data.
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed'
WHERE id = $1 AND status = 'pending'
`, pr.ID); upErr != nil {
log.Printf("Failed to resolve manual refund %s completed after REFUND_AMOUNT_INVALID on already-refunded payment: %v", pr.ID, upErr)
}
log.Printf("Manual refund %s: payment already refunded at Square (REFUND_AMOUNT_INVALID + exact-amount COMPLETED refund %s) — marked completed, no admin notification", pr.ID, *sqRefundID)
processed++
continue
default:
// No exact-amount COMPLETED refund exists — REFUND_AMOUNT_INVALID
// is a genuine decline/invalid amount here (the attempt would
// have over-refunded a partially-refunded payment), NOT an
// already-refunded signal. Route through the decline branch.
sqErr = square.ErrRefundDeclined
}
}
switch {
case sqErr == nil:
if _, upErr := db.Conn.Exec(ctx, `
@@ -1544,20 +1828,20 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
`, pr.ID); upErr != nil {
log.Printf("Failed to increment attempts for manual refund %s: %v", pr.ID, upErr)
}
if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= 3 {
if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= maxManualRefundAttempts {
resolveManualRefundAtCap(ctx, pr, amountCents)
}
default:
// Ambiguous — Square may or may not have processed. Retried by the
// sweep, capped at 3 attempts.
// sweep, capped at maxManualRefundAttempts.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET refund_attempts = refund_attempts + 1
WHERE id = $1
`, pr.ID); upErr != nil {
log.Printf("Failed to increment attempts for manual refund %s: %v", pr.ID, upErr)
}
if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= 3 {
if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= maxManualRefundAttempts {
resolveManualRefundAtCap(ctx, pr, amountCents)
}
}
@@ -1566,36 +1850,55 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
}
// resolveManualRefundAtCap reconciles a manual refund row that just hit the
// 3-attempt cap: money may have moved at Square despite decline/ambiguous
// responses, so reconcile FIRST — an exact COMPLETED refund resolves the row to
// completed; otherwise mark failed and notify the admin.
// maxManualRefundAttempts cap: money may have moved at Square despite
// decline/ambiguous responses, so reconcile FIRST — an exact COMPLETED refund
// resolves the row to completed; otherwise mark failed and notify the admin.
// The row is never re-issued here (reconcile is a read), so the cap cannot
// cause a double refund.
func resolveManualRefundAtCap(ctx context.Context, pr *manualPendingRow, amountCents int64) {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Leave the row
// pending for the next sweep; NEVER mark failed on an unknown state
// (that would let the over-refund guard exclude moved money).
log.Printf("Reconcile failed for manual refund %s at attempt cap (%v) — leaving pending for the next sweep", pr.ID, rcErr)
// Reconcile failed — unknown whether Square refunded. NEVER mark failed
// on an unknown state (that would let the over-refund guard exclude
// moved money). Re-arm the row under the cap so the next sweep re-picks
// it, and track consecutive reconcile failures: after
// maxConsecutiveReconcileFailures consecutive failures a deduped
// 'critical_payment_log' admin notification surfaces the hard-failing
// reconcile — never silently forever (A5b). Re-issue stays safe: the
// row's idempotency key is persisted (ensureRefundKey), so Square
// dedups a same-key retry to the original refund — and once the row
// crosses stalePendingRefundAge the sweep's age guard reconciles it
// instead of re-issuing.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET refund_attempts = $1
WHERE id = $2 AND status = 'pending' AND refund_attempts >= $3
`, maxManualRefundAttempts-1, pr.ID, maxManualRefundAttempts); upErr != nil {
log.Printf("Failed to re-arm manual refund %s under the attempt cap after reconcile error: %v", pr.ID, upErr)
}
trackReconcileFailureReArm(ctx, []string{pr.ID})
log.Printf("Reconcile failed for manual refund %s at attempt cap (%v) — re-armed under the cap for the next sweep; never marked failed on an unknown state", pr.ID, rcErr)
case sqRefundID != nil:
resetReconcileFailureCount(pr.ID)
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = $2
WHERE id = $2 AND status = 'pending'
`, *sqRefundID, pr.ID); upErr != nil {
log.Printf("Failed to mark manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
}
default:
resetReconcileFailureCount(pr.ID)
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = $1
WHERE id = $1 AND status = 'pending'
`, pr.ID); upErr != nil {
log.Printf("Failed to mark manual refund %s failed after 3 attempts: %v", pr.ID, upErr)
log.Printf("Failed to mark manual refund %s failed after %d attempts: %v", pr.ID, maxManualRefundAttempts, upErr)
}
insertRefundFailedNotifications(ctx, []string{pr.ID})
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
// channel.
log.Printf("Manual refund %s reached 3 attempts with no COMPLETED refund found at Square — marked 'failed' and admin notified; TODO email user+admin, VERIFY Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID)
log.Printf("Manual refund %s reached %d attempts with no COMPLETED refund found at Square — marked 'failed' and admin notified; TODO email user+admin, VERIFY Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID, maxManualRefundAttempts)
}
}
+653 -4
View File
@@ -2267,9 +2267,14 @@ func TestProcessPendingSquareRefunds_Declined_ThreeAttempts_Failed(t *testing.T)
// verifies the tri-state reconcile under the ambiguous path (plain transport
// error): the row is retried up to the 3-attempt cap, and on the run that hits
// the cap the reconcile against Square ALSO fails (same transport failure) — an
// unknown state. The row MUST stay 'pending' (NOT 'failed', which would let the
// unknown money state. The row MUST stay 'pending' (NOT 'failed', which would let the
// over-refund guard exclude money that may have moved) and NO admin_notification
// is inserted; the next sweep retries the reconcile.
// is inserted. Since the A5c fix the capped rows are re-armed under the cap on a
// reconcile error (mirroring resolveManualRefundAtCap) so the next sweep
// re-picks them — the row ends the third run at maxManualRefundAttempts-1, not
// stranded at the cap — and the consecutive-failure counter is still below
// maxConsecutiveReconcileFailures, so no critical_payment_log notification
// fires yet.
func TestProcessPendingSquareRefunds_Ambiguous_ThreeAttempts_StaysPendingOnReconcileError(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
@@ -2332,8 +2337,11 @@ func TestProcessPendingSquareRefunds_Ambiguous_ThreeAttempts_StaysPendingOnRecon
if status != "pending" {
t.Errorf("expected status 'pending' after 3 ambiguous runs with a failing reconcile, got %q", status)
}
if attempts != 3 {
t.Errorf("expected refund_attempts 3 after three ambiguous runs, got %d", attempts)
// A5c: the cap-time reconcile failure re-arms the capped row under the cap
// (instead of stranding it at the cap where the sweep would never re-pick
// it), so the third run ends at maxManualRefundAttempts-1, not at the cap.
if attempts != maxManualRefundAttempts-1 {
t.Errorf("expected refund_attempts re-armed to %d after three ambiguous runs with a failing reconcile, got %d", maxManualRefundAttempts-1, attempts)
}
// The terminal failure must NOT fire: the reconcile returned a network
@@ -2348,6 +2356,18 @@ func TestProcessPendingSquareRefunds_Ambiguous_ThreeAttempts_StaysPendingOnRecon
if notifCount != 0 {
t.Errorf("expected NO admin_notification with reason 'refund_failed' (reconcile error leaves rows pending), got %d", notifCount)
}
// The consecutive-reconcile-failure counter (A5b/A5c) is still below
// maxConsecutiveReconcileFailures after this single cap-time re-arm, so no
// critical_payment_log notification fires either.
var critCount int
err = db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'critical_payment_log'`, bookingID).Scan(&critCount)
if err != nil {
t.Fatalf("failed to query critical_payment_log notifications: %v", err)
}
if critCount != 0 {
t.Errorf("expected NO critical_payment_log notification after one reconcile-failure re-arm, got %d", critCount)
}
}
// TestProcessPendingSquareRefunds_PartialManualThenCancel_IssuesResidual
@@ -3752,3 +3772,632 @@ func TestSweepManualRetry_NullKey_SingleSquareRefund(t *testing.T) {
t.Errorf("expected refund status 'completed', got %q", status)
}
}
// =============================================================================
// H2 — REFUND_AMOUNT_INVALID reconciliation (refunds.go)
// =============================================================================
// TestSweepManualRefund_RefundAmountInvalid_AlreadyRefunded_Completed covers
// the H2 reconciliation on the manual-sweep path: when the sweep's re-issue
// attempt hits REFUND_AMOUNT_INVALID on a payment Square HAS already refunded,
// the pending row resolves to 'completed' (never 'failed', no admin
// notification). The mock's RefundPayment reconciles internally: the payment is
// in its ledger with a recorded refund, so the over-refund attempt is answered
// ErrRefundAlreadyProcessed and the handler marks the row completed.
func TestSweepManualRefund_RefundAmountInvalid_AlreadyRefunded_Completed(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Create the charge through the mock so the sweep's refund attempt hits a
// payment KNOWN to its ledger (the mock only reconciles over-refunds for
// payments it holds).
mock := square.NewDevClient().(*square.MockClient)
charge, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:h2-already-refunded",
IdempotencyKey: "h2-already-refunded-charge",
})
if err != nil {
t.Fatalf("failed to seed mock payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", charge.ID, paymentID); err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', NOW())
RETURNING id
`, paymentID, bookingID, paymentID+"-h2-refund-5000").Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert pending manual refund: %v", err)
}
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)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
// Record a COMPLETED refund at Square BEFORE the sweep so the payment is
// already refunded; the sweep's re-issue attempt then over-refunds.
if _, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
PaymentID: charge.ID,
Amount: 5000,
IdempotencyKey: "seed-h2-already-refunded",
Reason: "customer request",
}); err != nil {
t.Fatalf("failed to seed Square refund: %v", err)
}
origClient := SquareClient
SquareClient = mock
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}})
if err != nil {
t.Fatalf("processManualPaymentGroup failed: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 manual refund processed, got %d", n)
}
var status string
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status); err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected refund 'completed' after REFUND_AMOUNT_INVALID on an already-refunded payment, got %q", status)
}
if n := mock.RefundKeyCount(); n != 1 {
t.Errorf("expected exactly 1 distinct Square refund (the pre-seeded one; the sweep must not re-issue), got %d", n)
}
var notifCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount); err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin_notification for an already-refunded payment resolved to completed, got %d", notifCount)
}
}
// TestSweepManualRefund_RefundAmountInvalid_NotRefunded_DeclinePath covers the
// other H2 branch on the manual-sweep path: REFUND_AMOUNT_INVALID where the
// payment was NOT refunded is a genuine decline, not an already-refunded
// outcome — the row falls through to the decline path (refund_attempts
// incremented, still pending, no completion).
func TestSweepManualRefund_RefundAmountInvalid_NotRefunded_DeclinePath(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
chargeID := "sqp_h2_not_refunded"
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID); err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'customer request', $3, 'manual', NOW())
RETURNING id
`, paymentID, bookingID, paymentID+"-h2-refund-2500").Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert pending manual refund: %v", err)
}
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)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
mock.FailRefundCode = "REFUND_AMOUNT_INVALID"
SquareClient = mock
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}})
if err != nil {
t.Fatalf("processManualPaymentGroup failed: %v", err)
}
if n != 0 {
t.Fatalf("expected 0 refunds processed on the decline path, got %d", n)
}
var status string
var attempts int
if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "pending" {
t.Errorf("expected status 'pending' (REFUND_AMOUNT_INVALID on a NOT-refunded payment is a decline, not a completion), got %q", status)
}
if attempts != 1 {
t.Errorf("expected refund_attempts 1 after the decline path, got %d", attempts)
}
if n := mock.RefundKeyCount(); n != 0 {
t.Errorf("expected NO Square refund recorded on the decline path, got %d", n)
}
var notifCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount); err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin_notification while the row is still pending, got %d", notifCount)
}
}
// TestProcessPendingSquareRefunds_RefundAmountInvalid_AlreadyRefunded_Completed
// covers the H2 reconciliation on the aggregated (charge-group) path: the
// cancellation refund row for an already-refunded charge resolves to
// 'completed' — never 'failed', no admin notification — so the amount unblocks
// the over-refund guard without double-refunding.
func TestProcessPendingSquareRefunds_RefundAmountInvalid_AlreadyRefunded_Completed(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
mock := square.NewDevClient().(*square.MockClient)
charge, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 2500,
Currency: "GBP",
SourceID: "cnon:h2-agg-refunded",
IdempotencyKey: "h2-agg-refunded-charge",
})
if err != nil {
t.Fatalf("failed to seed mock payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", charge.ID, paymentID); err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', NOW())
RETURNING id
`, paymentID, bookingID, paymentID+"-h2-square-2500").Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert pending cancellation refund: %v", err)
}
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)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
// The charge is already fully refunded at Square before the sweep.
if _, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
PaymentID: charge.ID,
Amount: 2500,
IdempotencyKey: "seed-h2-agg-refunded",
Reason: "client_cancelled",
}); err != nil {
t.Fatalf("failed to seed Square refund: %v", err)
}
origClient := SquareClient
SquareClient = mock
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE status = 'pending' AND id <> $1`, refundID); err != nil {
t.Fatalf("failed to clean leftover pending refunds: %v", err)
}
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
var status string
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status); err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected refund 'completed' after REFUND_AMOUNT_INVALID on an already-refunded charge, got %q", status)
}
if n := mock.RefundKeyCount(); n != 1 {
t.Errorf("expected exactly 1 distinct Square refund (the pre-seeded one), got %d", n)
}
var notifCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount); err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin_notification for an already-refunded charge resolved to completed, got %d", notifCount)
}
}
// =============================================================================
// resolveManualRefundAtCap re-arm (refunds.go)
// =============================================================================
// TestSweepManualRefund_ReconcileError_AtCap_ReArmed covers the re-arm fix: when
// a manual refund reaches the attempt cap but the cap-time reconcile against
// Square FAILS (unknown money state), the row must NOT be stranded at the cap
// (the sweep only re-picks rows with refund_attempts < maxManualRefundAttempts).
// resolveManualRefundAtCap re-arms the row to maxManualRefundAttempts-1 so the
// next sweep re-picks it — still pending, still under the cap.
func TestSweepManualRefund_ReconcileError_AtCap_ReArmed(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_h2_rearm' WHERE id = $1", paymentID); err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, refund_attempts, created_at)
VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', $4, NOW())
RETURNING id
`, paymentID, bookingID, paymentID+"-h2-rearm-refund", maxManualRefundAttempts-1).Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert manual refund at the attempt cap minus one: %v", err)
}
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)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
// RefundPayment AND the reconcile (ListPaymentRefunds) both fail with
// plain transport errors → every run ends in an UNKNOWN money state.
origClient := SquareClient
SquareClient = &ambiguousRefundClient{}
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
for i := 0; i < 2; i++ {
if _, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}}); err != nil {
t.Fatalf("processManualPaymentGroup run %d failed: %v", i+1, err)
}
var status string
var attempts int
if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil {
t.Fatalf("failed to query refund after run %d: %v", i+1, err)
}
if status != "pending" {
t.Fatalf("expected status 'pending' after run %d (reconcile error = unknown state), got %q", i+1, status)
}
if attempts != maxManualRefundAttempts-1 {
t.Errorf("expected refund_attempts re-armed to %d after run %d (not stranded at the cap %d), got %d",
maxManualRefundAttempts-1, i+1, maxManualRefundAttempts, attempts)
}
}
// The re-arm must not have fired the terminal failure notification.
var notifCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount); err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin_notification (unknown money state must never be marked failed), got %d", notifCount)
}
}
// TestSweepManualRefund_ReconcileError_AtCap_NotifiesAfterNReArms locks the
// A5b fix: a manual refund whose cap-time reconcile keeps FAILING is re-armed
// under the cap on every sweep run (so the row is never stranded) BUT after
// maxConsecutiveReconcileFailures consecutive failures a deduped
// 'critical_payment_log' admin notification surfaces the hard-failing reconcile
// — the audit requirement that an admin is notified after repeated reconcile
// failures, never silently forever. The row stays 'pending' (an unknown money
// state is never marked failed) and keeps being re-armed, so the notification
// (deduped) is the durable admin-visible signal.
func TestSweepManualRefund_ReconcileError_AtCap_NotifiesAfterNReArms(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_a5b_notify' WHERE id = $1", paymentID); err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, refund_attempts, created_at)
VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', $4, NOW())
RETURNING id
`, paymentID, bookingID, paymentID+"-a5b-notify-refund", maxManualRefundAttempts-1).Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert manual refund at the attempt cap minus one: %v", err)
}
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)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
resetReconcileFailureCount(refundID)
})
// RefundPayment AND the reconcile (ListPaymentRefunds) both fail with
// plain transport errors → every run ends in an UNKNOWN money state.
origClient := SquareClient
SquareClient = &ambiguousRefundClient{}
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
for i := 0; i < maxConsecutiveReconcileFailures; i++ {
if _, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}}); err != nil {
t.Fatalf("processManualPaymentGroup run %d failed: %v", i+1, err)
}
var status string
var attempts int
if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil {
t.Fatalf("failed to query refund after run %d: %v", i+1, err)
}
if status != "pending" {
t.Fatalf("expected status 'pending' after run %d (reconcile error = unknown state), got %q", i+1, status)
}
if attempts != maxManualRefundAttempts-1 {
t.Errorf("expected refund_attempts re-armed to %d after run %d, got %d",
maxManualRefundAttempts-1, i+1, attempts)
}
}
// After N consecutive failures the admin MUST have been notified via a
// deduped 'critical_payment_log' notification — the A5b requirement.
var critCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'critical_payment_log'`, bookingID).Scan(&critCount); err != nil {
t.Fatalf("failed to query critical_payment_log notifications: %v", err)
}
if critCount < 1 {
t.Errorf("expected at least 1 critical_payment_log admin notification after %d consecutive reconcile failures, got %d",
maxConsecutiveReconcileFailures, critCount)
}
// The row must still be pending (never failed on an unknown money state)
// and no 'refund_failed' notification may have fired.
var status string
var attempts int
if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil {
t.Fatalf("failed to query refund after notification: %v", err)
}
if status != "pending" {
t.Errorf("expected status 'pending' (unknown state is never marked failed), got %q", status)
}
if attempts != maxManualRefundAttempts-1 {
t.Errorf("expected refund_attempts %d (still re-armed for the next sweep), got %d", maxManualRefundAttempts-1, attempts)
}
var failedCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&failedCount); err != nil {
t.Fatalf("failed to query refund_failed notifications: %v", err)
}
if failedCount != 0 {
t.Errorf("expected NO 'refund_failed' notification (row was never marked failed), got %d", failedCount)
}
}
// TestProcessChargeGroup_ReconcileError_AtCap_ReArmsAndNotifies locks the A5c
// fix on the cancellation (charge-group) path: when the cap-time reconcile
// fails the capped refund rows are re-armed under the cap (mirroring
// resolveManualRefundAtCap) so the sweep re-picks them instead of stranding
// them pending at the cap forever, and after maxConsecutiveReconcileFailures
// consecutive failures a deduped 'critical_payment_log' admin notification
// surfaces the hard-failing reconcile.
func TestProcessChargeGroup_ReconcileError_AtCap_ReArmsAndNotifies(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
chargeID := "sqp_a5c_notify"
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID); err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, refund_attempts, created_at)
VALUES ($1, $2, 50, 'pending', 'client_cancelled', $3, 'cancellation', $4, NOW())
RETURNING id
`, paymentID, bookingID, paymentID+"-a5c-square-5000", maxManualRefundAttempts-1).Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert cancellation refund at the attempt cap minus one: %v", err)
}
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)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
resetReconcileFailureCount(refundID)
})
origClient := SquareClient
SquareClient = &ambiguousRefundClient{}
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
for i := 0; i < maxConsecutiveReconcileFailures; i++ {
if _, err := processChargeGroup(freshCtx, chargeID, fetchPendingChargeRows(freshCtx, chargeID), "client_cancelled"); err != nil {
t.Fatalf("processChargeGroup run %d failed: %v", i+1, err)
}
var status string
var attempts int
if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil {
t.Fatalf("failed to query refund after run %d: %v", i+1, err)
}
if status != "pending" {
t.Fatalf("expected status 'pending' after run %d (reconcile error = unknown state), got %q", i+1, status)
}
if attempts != maxManualRefundAttempts-1 {
t.Errorf("expected refund_attempts re-armed to %d after run %d (A5c: not stranded at the cap), got %d",
maxManualRefundAttempts-1, i+1, attempts)
}
}
var critCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'critical_payment_log'`, bookingID).Scan(&critCount); err != nil {
t.Fatalf("failed to query critical_payment_log notifications: %v", err)
}
if critCount < 1 {
t.Errorf("expected at least 1 critical_payment_log admin notification after %d consecutive reconcile failures, got %d",
maxConsecutiveReconcileFailures, critCount)
}
}
+151 -2
View File
@@ -275,6 +275,14 @@ func sweepStaleRows(ctx context.Context, table string, cutoff time.Time) (resolv
}
if failStaleRow(ctx, table, r.ID) {
resolved++
// A5a: blind-failing a row with NO square_payment_id leaves the
// charge outcome unknown (the money may have landed at Square with a
// lost response), so surface it in the admin notification centre.
// Rows that reach this line WITH a square_payment_id were PROVED
// never-charged by the by-id reconcile and need no alert.
if r.SquarePaymentID == "" {
notifyStaleRowCritical(ctx, r)
}
}
}
return resolved, completed, nil
@@ -319,9 +327,13 @@ func sweepKeyedStaleRows(ctx context.Context, table string, cutoff time.Time) (r
// expired key as "never charged". Blind-fail + WARN exactly as the
// legacy sweep did; a till sale's funded gift card is NOT clawed
// back (the charge outcome is unknown, the money may have landed).
// A5a: this blind-fail can be hiding a real charge (the lost
// response the stored key was meant to reconcile), so the admin
// notification centre must surface it too.
if failStaleRow(ctx, table, r.ID) {
resolved++
unverifiable++
notifyStaleRowCritical(ctx, r)
}
log.Printf("Stale pending %s row %s has a stored idempotency key but is already past Square's key retention window — marked failed without a replay reconcile (may have been charged with a lost response)", table, r.ID)
continue
@@ -403,11 +415,15 @@ func fetchStaleRows(ctx context.Context, table string, cutoff time.Time, keyedOn
WHERE ts.status = 'pending' AND ts.created_at < $1`+methodFilter+keyedPredicate+`
`, cutoff)
} else {
// table is an internal constant ("payments"), never user input, but the
// identifier is routed through pgx.Identifier.Sanitize — the same
// treatment failStaleRow / rescueStaleRowCompleted give the table name —
// so no raw, unquoted table name is ever concatenated into the statement.
rows, err = db.Conn.Query(ctx, `
SELECT id, COALESCE(square_payment_id, ''), COALESCE(idempotency_key, ''),
COALESCE(square_source_id, ''), COALESCE(square_request_snapshot, ''),
created_at, amount, booking_id, created_by
FROM `+table+`
FROM `+pgx.Identifier{table}.Sanitize()+`
WHERE status = 'pending' AND created_at < $1`+keyedPredicate+`
`, cutoff)
}
@@ -627,6 +643,70 @@ func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool {
return true
}
// replayRescueClockSkew is the margin by which a replayed payment's CreatedAt
// may lag the pending row's CreatedAt and still be the ORIGINAL charge under a
// retained idempotency key. Square creates the payment at the same instant the
// app creates the pending row (the same transaction), so a replayed payment
// created AFTER the row by more than this margin cannot be the original — it is
// a NEW charge Square made with an expired key (finding A1).
const replayRescueClockSkew = time.Hour
// isSavedCardSource reports whether a Square source id is a card-on-file
// (saved-card) reference. Only a ccof: source stays valid for recharging long
// after the charge attempt that stored it: a cnon: nonce is single-use, so an
// expired-key replay of a cnon: source is always rejected (proof the charge
// never happened), while a replay of a still-valid ccof: source can land a NEW
// charge under the expired key.
func isSavedCardSource(source string) bool {
return strings.HasPrefix(source, "ccof:")
}
// parseReplayedCreatedAt parses a PaymentResult's ISO 8601 CreatedAt into the
// instant the replayed payment was created at Square. Real Square always
// returns created_at on a payment, so an empty or unparseable value is a
// client/response anomaly.
func parseReplayedCreatedAt(pr *square.PaymentResult) (time.Time, bool) {
if pr == nil || pr.CreatedAt == "" {
return time.Time{}, false
}
created, err := time.Parse(time.RFC3339, pr.CreatedAt)
if err != nil {
return time.Time{}, false
}
return created, true
}
// replayRevealsNewCharge reports whether a COMPLETED keyed replay returned a
// NEW charge rather than the ORIGINAL payment under a retained idempotency key
// (finding A1). A retained-key dedup returns the original payment, created at
// the same instant the pending row was created; a NEW charge made by an
// expired-key replay (Square's ~24h key retention is UNVERIFIED —
// square_http_client.go:626) against the still-valid ccof: source is created
// ~22h later. Refusal is money-safe: a replayed payment that cannot be proven
// to be the original is never rescued (the row stays pending, a CRITICAL log is
// raised and an admin notification inserted), so a hidden second charge can
// never masquerade as the original one.
//
// The check runs ONLY against real Square timestamps: it is gated off in an
// explicit dev/mock env because the dev mock returns payments whose CreatedAt
// is the mock's "now" at seed/replay time, uncorrelated with the aged
// created_at the test rows carry (the keyed-reconcile tests age rows 23h while
// seeding the Square payment at test time, and a retained-key dedup returns
// that seeded payment). The gate mirrors the snapshot-decryption gate, which
// also runs only in a non-dev/mock env.
func replayRevealsNewCharge(r staleRow, pr *square.PaymentResult) (newCharge bool, created time.Time, createdOK bool) {
if IsExplicitDevOrMockEnv() {
return false, time.Time{}, false
}
created, createdOK = parseReplayedCreatedAt(pr)
if !createdOK || r.CreatedAt.IsZero() {
// Cannot prove the replayed payment is the original charge — refuse to
// rescue rather than hide a possible second charge.
return true, created, createdOK
}
return created.After(r.CreatedAt.Add(replayRescueClockSkew)), created, true
}
// reconcileStalePaymentByKey asks Square for the authoritative status of the
// charge made under a stale pending row's idempotency key and returns the
// tri-state result. The replay sends an IDENTICAL body to the original charge:
@@ -692,6 +772,21 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
}
snapshot = fallback
}
// PROD-mode snapshot decryption: since the AES-GCM snapshot work, the stored
// square_request_snapshot is ENCRYPTED in a non-mock deployment
// (SQUARE_ENVIRONMENT production/sandbox — the same gate the 2FA
// enforcement uses, IsExplicitDevOrMockEnv); the dev mock stores plaintext.
// Never replay a corrupt snapshot: a decryption failure is a serious error
// that leaves the row pending with a CRITICAL log for manual reconciliation
// instead of replaying garbage (which could return a misleading answer).
if !fallbackBody && !IsExplicitDevOrMockEnv() {
dec, err := decryptSnapshot(snapshot)
if err != nil {
log.Printf("CRITICAL: stale pending %s reconcile by key: failed to decrypt the stored request snapshot for row %s (%v) — leaving pending — MANUAL RECONCILIATION REQUIRED", table, r.ID, err)
return staleReconcileLeavePending, ""
}
snapshot = dec
}
// The stored snapshot embeds the source_id of the ORIGINAL charge, but a
// pending row REUSED by a same-key retry has its square_source_id column
// refreshed to the retry's source while the snapshot JSON stays stale (the
@@ -730,6 +825,21 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
pr, err := SquareClient.ReplayPaymentByKey(ctx, snapshot)
if err != nil {
if errors.Is(err, square.ErrReplayKeyNotRetained) {
// A1: a rejection of a replayed charge against a SAVED-CARD (ccof:)
// source is NOT proof the original charge never happened — the same
// key-retention race that lets the replay land a NEW charge can
// leave that charge even when the probe returns a rejection. Blindly
// failing (and clawing back a till sale's funded gift card) on a
// ccof source could reverse money a replay-created charge already
// took. Leave the row pending and alert ops. A cnon-source
// (spent-nonce) rejection REMAINS definitive proof of no charge (a
// single-use nonce cannot be recharged), keeping the proven-failed
// path and its clawback.
if isSavedCardSource(r.SquareSourceID) {
log.Printf("CRITICAL: stale pending %s reconcile by key: Square rejected the identical-body replay (no payment under the stored key) but the row's source is a still-valid saved card (ccof:) — the replay may have landed a NEW charge under an expired idempotency key — leaving row %s PENDING without failing/clawing back — MANUAL RECONCILIATION REQUIRED: verify at Square whether a charge exists before re-issuing", table, r.ID)
notifyStaleRowCritical(ctx, r)
return staleReconcileLeavePending, ""
}
log.Printf("Stale pending %s reconcile by key: Square has no payment under the stored idempotency key (identical-body replay rejected) — marking failed; the charge provably never happened", table)
return staleReconcileDefinitivelyFailed, ""
}
@@ -758,6 +868,23 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
// must match Square's documented state machine.
switch pr.Status {
case "COMPLETED":
// A1: a replayed COMPLETED payment created long AFTER the pending row
// is a NEW charge Square made against the still-valid source with an
// expired idempotency key (Square's ~24h key retention is UNVERIFIED —
// square_http_client.go:626), NOT the original charge a retained key
// returns. Rescuing the row with the new payment id would hide the
// second charge behind the original. Leave the row pending and alert
// ops so both charges can be reconciled at Square and the duplicate
// refunded.
if newCharge, created, createdOK := replayRevealsNewCharge(r, pr); newCharge {
lag := "unknown"
if createdOK {
lag = created.Sub(r.CreatedAt).Round(time.Minute).String()
}
log.Printf("CRITICAL: stale pending %s reconcile by key: the replayed COMPLETED payment %s was created after the pending row %s (lag %s) — a NEW charge under an expired idempotency key (likely a second charge against a still-valid saved card), NOT the original charge — leaving the row PENDING without rescue — MANUAL RECONCILIATION REQUIRED: check Square for both charges and refund the duplicate", table, pr.ID, r.ID, lag)
notifyStaleRowCritical(ctx, r)
return staleReconcileLeavePending, ""
}
return staleReconcileCompleted, pr.ID
case "CANCELED", "FAILED":
log.Printf("Stale pending %s is %q at Square (replay by key) — marking failed", table, pr.Status)
@@ -809,6 +936,25 @@ func insertCriticalPaymentNotification(ctx context.Context, bookingID, userID *s
}
}
// notifyStaleRowCritical inserts a critical-payment admin notification for a
// stale row via the shared insertCriticalPaymentNotification helper — the
// notification's deterministic dedup key is the user attribution (the NOT
// EXISTS guard keeps ONE notification per user instead of one per sweep run).
// Payments rows are attributed by the payer (created_by); till_sales rows by
// the funded gift card's redeemed-to user when there is one. booking_id is
// deliberately not used: the sweep runs after the charge process and a
// booking-attributed notification would pin the booking row through the
// admin_notifications FK (no cascade) for as long as the notification survives.
func notifyStaleRowCritical(ctx context.Context, r staleRow) {
var userID *string
if r.CreatedBy != nil {
userID = r.CreatedBy
} else {
userID = r.RedeemToUserID
}
insertCriticalPaymentNotification(ctx, nil, userID)
}
// staleReconcileResult is the tri-state outcome of reconciling one stale
// pending row against Square. Only a definitively-resolved outcome touches the
// row: an ambiguous answer (transport error / 5xx) leaves it pending so a
@@ -1241,8 +1387,11 @@ func markTerminalCheckoutRowFailed(ctx context.Context, r staleTerminalCheckoutR
table = "terminal_checkouts"
where = "checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')"
}
// table is an internal constant ("till_sales"/"terminal_checkouts"), never
// user input, but the identifier is routed through pgx.Identifier.Sanitize
// so no raw, unquoted table name is ever concatenated into the statement.
tag, err := db.Conn.Exec(ctx, `
UPDATE `+table+` SET status = 'failed', updated_at = NOW()
UPDATE `+pgx.Identifier{table}.Sanitize()+` SET status = 'failed', updated_at = NOW()
WHERE `+where, r.RowID)
if err != nil {
log.Printf("Failed to mark %s %s failed: %v", r.Kind, r.RowID, err)
+315
View File
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
@@ -707,6 +708,320 @@ func TestSweepStalePendingPayments_KeyedSourceMismatch_LeavesPending(t *testing.
}
}
// TestSweepStalePendingPayments_KeyedReplayNewCharge_LeavesPending locks the A1
// money-safety cross-check: a replayed COMPLETED payment created long AFTER the
// pending row is a NEW charge Square made with an EXPIRED idempotency key
// against the still-valid ccof source (the ~24h key retention is unverified),
// NOT the original charge a retained key returns. Rescuing the row with the new
// payment id would hide the second charge behind the original — the row must
// stay PENDING, a CRITICAL notification must be raised, and no square_payment_id
// may be written. The cross-check runs only in a non-dev/mock env, so this test
// flips SQUARE_ENVIRONMENT to production (sequential, like the 2FA tests).
func TestSweepStalePendingPayments_KeyedReplayNewCharge_LeavesPending(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create stale pending payment: %v", err)
}
// 23h old: past the 22h keyed cutoff (so the keyed pass picks it up) but
// still inside Square's ~24h retention window (so the replay runs). NO
// stored snapshot → the minimal fallback body is rebuilt, which skips the
// production snapshot-decryption gate. created_by carries the payer so the
// admin notification is attributable and assertable.
const key = "key-expired-replay-new-charge"
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
// The replayed COMPLETED payment is created at sweep time (~23h after the
// row) — the expired-key replay landed a NEW charge on the saved card.
// The cross-check runs only in a non-dev/mock env, so the env is flipped to
// production for the sweep (sequential, like the 2FA tests). The dev mock
// is constructed BEFORE the flip (NewDevClient refuses production without
// SQUARE_ALLOW_REAL_API); the mock itself never re-reads the env.
origClient := SquareClient
mock := square.NewDevClient()
t.Setenv("SQUARE_ENVIRONMENT", "production")
SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
Status: "COMPLETED",
ID: "pay_expired_key_new_charge",
SquarePayID: "pay_expired_key_new_charge",
CreatedAt: clock.Now().Format(time.RFC3339),
}}
defer func() { SquareClient = origClient }()
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)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
freshCtx := context.Background()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
var sqPayID *string
if err := db.Conn.QueryRow(freshCtx, "SELECT status, square_payment_id FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if status != "pending" {
t.Errorf("expected the new-charge replay to leave the row pending (never rescue with the second charge's id), got %q", status)
}
if sqPayID != nil {
t.Errorf("expected NO square_payment_id written on a new-charge replay, got %q", *sqPayID)
}
var notifCount int
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(&notifCount); err != nil {
t.Fatalf("failed to count admin notifications: %v", err)
}
if notifCount < 1 {
t.Errorf("expected a critical-payment admin notification for the suspected second charge, got %d", notifCount)
}
}
// TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues locks the
// A1 cross-check control: a replayed COMPLETED payment created at the SAME
// instant as the pending row (the retained-key dedup returning the original
// charge) is rescued to 'completed' — the cross-check must never block a
// legitimate lost-response rescue. Production env so the cross-check runs.
func TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create stale pending payment: %v", err)
}
const key = "key-retained-original"
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
// The replayed payment is the ORIGINAL — created at the same instant as the
// pending row (~23h ago), as a retained-key dedup returns. The env is
// flipped to production for the sweep so the A1 cross-check runs; the dev
// mock is constructed BEFORE the flip (NewDevClient refuses production
// without SQUARE_ALLOW_REAL_API).
origClient := SquareClient
mock := square.NewDevClient()
t.Setenv("SQUARE_ENVIRONMENT", "production")
SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
Status: "COMPLETED",
ID: "pay_original_under_key",
SquarePayID: "pay_original_under_key",
CreatedAt: clock.Now().Add(-23 * time.Hour).Format(time.RFC3339),
}}
defer func() { SquareClient = origClient }()
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)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
freshCtx := context.Background()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status, sqPayID string
if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if status != "completed" {
t.Errorf("expected the original-payment replay rescued to 'completed', got %q", status)
}
if sqPayID != "pay_original_under_key" {
t.Errorf("expected square_payment_id %s written back on the rescue, got %q", "pay_original_under_key", sqPayID)
}
}
// TestSweepStalePendingPayments_KeyedCCOFRejected_LeavesPendingNoClawback locks
// the A1 ccof-blind-fail rule: a replay rejection (ErrReplayKeyNotRetained)
// against a SAVED-CARD (ccof:) source is NOT proof the original charge never
// happened — the same key-retention race that lets the replay land a NEW charge
// can leave that charge even when the probe is rejected. A till sale with a
// funded gift card must be left PENDING (never failed) and the gift card must
// NOT be clawed back. A cnon-source (spent-nonce) rejection remains definitive
// and keeps the proven-failed clawback (locked by
// TestSweepStalePendingPayments_KeyedTillLostResponse_ProvenFailed_Clawbacks).
func TestSweepStalePendingPayments_KeyedCCOFRejected_LeavesPendingNoClawback(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
pool := context.Background()
saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "", true)
// 23h old: past the 22h keyed cutoff, still inside the retention window so
// the keyed replay runs (not the past-retention blind-fail). ccof source.
if _, err := tx.Exec(ctx, "UPDATE till_sales SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = 'key-ccof-rejected', square_source_id = 'ccof:test-saved-card' WHERE id = $1", saleID); err != nil {
t.Fatalf("failed to age the till sale: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil {
t.Fatalf("failed to age the gift card: %v", err)
}
origClient := SquareClient
SquareClient = &staleReplayClient{SquareClient: square.NewDevClient(), err: square.ErrReplayKeyNotRetained}
defer func() { SquareClient = origClient }()
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 setup tx: %v", err)
}
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "pending" {
t.Errorf("expected the ccof-source replay rejection to leave the till sale pending (never blindly failed), got %q", status)
}
// The funded card must be untouched — the blind rejection could mean a
// replay-created charge landed, so the funding must stay put.
var cardCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if cardCount != 1 {
t.Errorf("expected the ccof rejection to leave the funded gift card in place (no clawback), got %d cards", cardCount)
}
}
// TestSweepStalePendingPayments_KeyedBlindFail_Notifies locks A5a: a keyed
// pending row already past Square's key retention window is blind-failed (the
// charge outcome is unknown — the lost response may have landed at Square), so
// a critical-payment admin notification must be raised alongside the failed
// mark. The notification is deduped per payer.
func TestSweepStalePendingPayments_KeyedBlindFail_Notifies(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create stale pending payment: %v", err)
}
// 25h old: past both the 22h keyed cutoff and Square's 24h retention window,
// so the keyed pass blind-fails it without a replay.
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', idempotency_key = 'key-blindfail-notify', square_source_id = 'cnon:test-card', created_by = $1 WHERE id = $2", userID, staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
origClient := SquareClient
SquareClient = square.NewDevClient()
defer func() { SquareClient = origClient }()
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)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
freshCtx := context.Background()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if status != "failed" {
t.Errorf("expected the past-retention keyed row blind-failed, got %q", status)
}
var notifCount int
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(&notifCount); err != nil {
t.Fatalf("failed to count admin notifications: %v", err)
}
if notifCount < 1 {
t.Errorf("expected a critical-payment admin notification for the keyed blind-fail, got %d", notifCount)
}
}
// TestSweepStalePendingPayments_KeyedTillLostResponse_ProvenFailed_Clawbacks
// locks the keyed clawback: a stale pending till sale with a stored idempotency
// key whose charge Square PROVES never happened (no payment under the key) is
+81 -33
View File
@@ -25,13 +25,13 @@ import (
)
type TillSaleRequest struct {
ItemType string `json:"item_type" validate:"required"`
Action string `json:"action" validate:"required"`
Amount float64 `json:"amount" validate:"required,gt=0"`
GiftCardID *string `json:"gift_card_id,omitempty"`
PaymentMethod string `json:"payment_method" validate:"required"`
UserSavedCardID *string `json:"user_saved_card_id,omitempty"`
UserID *string `json:"user_id,omitempty"`
ItemType string `json:"item_type" validate:"required"`
Action string `json:"action" validate:"required"`
Amount float64 `json:"amount" validate:"required,gt=0"`
GiftCardID *string `json:"gift_card_id,omitempty"`
PaymentMethod string `json:"payment_method" validate:"required"`
UserSavedCardID *string `json:"user_saved_card_id,omitempty"`
UserID *string `json:"user_id,omitempty"`
// IdempotencyKey is optional; an empty key is replaced with a DETERMINISTIC
// fallback derived from the canonical request fields
// (deriveTillIdempotencyKey) so a lost-response retry re-derives the SAME
@@ -39,7 +39,7 @@ type TillSaleRequest struct {
// Square charge. Limit 45: this key feeds CreatePayment (Square's /v2/
// payments cap) as well as CreateCheckout, which allows 64 — the stricter
// 45 applies because the same key is replayed to /v2/payments.
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
CardToken string `json:"card_token,omitempty"`
RedeemToUserID *string `json:"redeem_to_user_id,omitempty"`
VerificationToken *string `json:"verification_token,omitempty"`
@@ -129,6 +129,14 @@ func declineCodeListContains(s string) bool {
// / user / saved card / redeem targets when present), truncated to 16 bytes so
// the key stays within Square's 45-char /v2/payments limit.
//
// The hash is applied UNCONDITIONALLY (never the verbatim candidate): a row
// created pre-deploy stores the hashed form (CHAR(12) candidates like
// "till:create:...:2500" are ~30 chars and used to derive the hashed key), and
// a lost-response retry must re-derive the SAME key to hit the dedup SELECT
// and slot scan — the length-conditional truncateIdempotencyKey form would
// switch short candidates to a raw key that misses the pre-deploy row and
// mints a SECOND Square charge.
//
// A lost-response retry re-derives the SAME base key, so the idempotency lookup
// in CreateTillSale reuses the pending till_sale row and Square dedups on the
// key — ONE charge and ONE gift-card funding instead of the old uniqueChargeKey
@@ -164,23 +172,20 @@ func deriveTillIdempotencyKey(req TillSaleRequest, adminID string) string {
sb.WriteString(":redeem:")
sb.WriteString(*req.RedeemToUserID)
}
// Always-hashed, NEVER truncateIdempotencyKey: a pre-deploy row stores the
// hashed key, so a raw-key re-derivation would miss the dedup and double-
// charge. The slot-scan candidates (tillIdempotencyKeyCandidate) keep their
// own conditional truncation, matching the pre-batch code.
sum := sha256.Sum256([]byte(sb.String()))
return "till-" + hex.EncodeToString(sum[:16])
}
// tillIdempotencyKeyCandidate appends the slot-sequence suffix to a derived
// base key, hashing back under Square's 45-char /v2/payments limit when the
// verbatim form would overflow (the hash stays deterministic).
// verbatim form would overflow (the hash stays deterministic). Shared
// implementation: nextIdempotencyCandidate (idempotency_helpers.go).
func tillIdempotencyKeyCandidate(baseKey string, seq int) string {
if seq == 0 {
return baseKey
}
candidate := fmt.Sprintf("%s-%d", baseKey, seq)
if len(candidate) > 45 {
sum := sha256.Sum256([]byte(candidate))
return "till-" + hex.EncodeToString(sum[:16])
}
return candidate
return nextIdempotencyCandidate(baseKey, seq)
}
// scanTillIdempotencyKeySlot resolves the FINAL deterministic idempotency key
@@ -235,8 +240,22 @@ func refreshTillSnapshotSource(ctx context.Context, tx pgx.Tx, tillSaleID, newSo
if !snap.Valid || snap.String == "" {
return
}
// The stored snapshot is AES-256-GCM-encrypted at rest in non-mock
// deployments (encryptSnapshot's "enc:v1:" marker) and plaintext in
// dev/mock — decrypt it first so the JSON mutation below operates on the
// request body, then re-encrypt on the way out so the stored form stays
// consistent with the other snapshot writes.
body := []byte(snap.String)
if !IsExplicitDevOrMockEnv() {
dec, dErr := decryptSnapshot(body)
if dErr != nil {
log.Printf("Failed to decrypt square_request_snapshot for reused till sale %s: %v", tillSaleID, dErr)
return
}
body = dec
}
var req square.CreatePaymentReq
if err := json.Unmarshal([]byte(snap.String), &req); err != nil {
if err := json.Unmarshal(body, &req); err != nil {
log.Printf("Failed to parse square_request_snapshot for reused till sale %s: %v", tillSaleID, err)
return
}
@@ -246,7 +265,16 @@ func refreshTillSnapshotSource(ctx context.Context, tx pgx.Tx, tillSaleID, newSo
log.Printf("Failed to re-marshal square_request_snapshot for reused till sale %s: %v", tillSaleID, err)
return
}
if _, err := tx.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(updated), tillSaleID); err != nil {
stored := updated
if !IsExplicitDevOrMockEnv() {
enc, eErr := encryptSnapshot(updated)
if eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for reused till sale %s: %v", tillSaleID, eErr)
return
}
stored = enc
}
if _, err := tx.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(stored), tillSaleID); err != nil {
log.Printf("Failed to refresh square_request_snapshot for reused till sale %s: %v", tillSaleID, err)
}
}
@@ -263,11 +291,6 @@ func revertGiftCardFunding(ctx context.Context, action, giftCardID string, amoun
return RevertGiftCardFunding(ctx, action, giftCardID, amount, redeemToUserID, tillSaleID)
}
// maxTillGiftCardAmountPence caps till gift-card creates/topups at £250
// (owner decision; tighter than the £10,000 general till cap). Local constant
// — do not import from giftcard_limits.go (may not exist yet).
const maxTillGiftCardAmountPence = 25_000
func CreateTillSale(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Defense-in-depth admin check (S-1) — a till sale moves money (charges a
@@ -308,10 +331,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// derived (math.Round(req.Amount * 100)).
amountPence := int64(math.Round(req.Amount * 100))
// Gift-card creates/topups are additionally capped at £250 per transaction
// (owner decision). Both the create and topup branches fund the card from
// req.Amount and flow through this single validation point, so one guard
// covers both.
if req.ItemType == "gift_card" && amountPence > maxTillGiftCardAmountPence {
// (owner decision — the shared maxAdminGiftCardTransactionPence from
// giftcard_limits.go, the single source of the £250 cap). Both the create
// and topup branches fund the card from req.Amount and flow through this
// single validation point, so one guard covers both.
if req.ItemType == "gift_card" && amountPence > maxAdminGiftCardTransactionPence {
http.Error(w, "Gift card amount exceeds maximum (£250)", http.StatusBadRequest)
return
}
@@ -591,6 +615,12 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
if purchaseVoucherType == "" {
purchaseVoucherType = "SPV"
}
// HMRC VAT Notice 700/7: salon-only gift cards are SPV by
// definition, so the EFFECTIVE type is written even when the
// stored setting is 'MPV' — recording raw 'MPV' here would make
// the redemption path defer VAT a second time (VAT is already
// collected at sale via the GetVATConfig SPV override).
purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType)
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase)
VALUES ($1, $1, $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3)
@@ -1024,7 +1054,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
if req.PaymentMethod != "on_the_house" && (saleStatus == "completed" || needsSquarePayment) {
vatCfg, vatErr := GetVATConfig(ctx, tx)
if vatErr == nil && vatCfg.IsVATRegistered && vatCfg.VoucherType == "SPV" {
if vatErr == nil && vatAppliesToVoucher(vatCfg) {
if _, vatExecErr := tx.Exec(ctx, "SELECT apply_vat_to_till_sale($1, $2)", tillSaleID, vatCfg.DefaultVATRate); vatExecErr != nil {
log.Printf("Failed to apply VAT to till sale %s: %v", tillSaleID, vatExecErr)
}
@@ -1074,14 +1104,26 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card " + req.Action,
BuyerEmail: buyerEmail,
// C3: a saved-card (ccof) charge is customer-initiated — Square
// requires customer_details on stored-credential payments, and
// omitting it can fail/quietly strip the charge.
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
// Forward the 3DS/SCA verification token when the request
// carries one (the online_square branch already did; the
// saved-card branch did not).
VerificationToken: verificationToken,
}
// M1: store the verbatim request JSON so the sweep can replay the
// charge with an IDENTICAL body under the same key — Square
// compares the whole request on key reuse, and a reconstructed body
// returns IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
// The snapshot holds PII (buyer email + ccof token), so it is
// encrypted at rest via encryptSnapshot (plaintext in dev/mock).
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
log.Printf("Failed to marshal square_request_snapshot for till sale %s: %v", tillSaleID, mErr)
} else if _, sErr := db.Conn.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(snap), tillSaleID); sErr != nil {
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for till sale %s: %v", tillSaleID, eErr)
} else if _, sErr := db.Conn.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(stored), tillSaleID); sErr != nil {
log.Printf("Failed to store square_request_snapshot for till sale %s: %v", tillSaleID, sErr)
}
@@ -1116,7 +1158,9 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// returns IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
log.Printf("Failed to marshal square_request_snapshot for till sale %s: %v", tillSaleID, mErr)
} else if _, sErr := db.Conn.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(snap), tillSaleID); sErr != nil {
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for till sale %s: %v", tillSaleID, eErr)
} else if _, sErr := db.Conn.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(stored), tillSaleID); sErr != nil {
log.Printf("Failed to store square_request_snapshot for till sale %s: %v", tillSaleID, sErr)
}
@@ -1142,7 +1186,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
log.Printf("CRITICAL: till sale %s charge definitively failed (%v) but gift-card clawback also failed: %v — MANUAL RECONCILIATION REQUIRED: gift card %s may still be funded", tillSaleID, squareErr, revErr, giftCardID)
}
}
http.Error(w, "Payment failed", http.StatusPaymentRequired)
// 402 only for definitive declines; ambiguous transport/5xx must be
// 503 so the pending sale stays resumable on a same-key retry
// (M3). The clawback decision above stays keyed on
// isDefinitiveChargeFailure, unchanged.
http.Error(w, "Payment failed", chargeFailureStatus(squareErr))
return
}
+41 -3
View File
@@ -28,9 +28,49 @@ func GetVATConfig(ctx context.Context, q db.Querier) (*VATConfig, error) {
if err != nil {
return nil, err
}
// HMRC VAT Notice 700/7: a salon-only gift card is a single-purpose
// voucher (SPV) by definition — MPV is legally unavailable for this
// business. A stored "MPV" would suppress VAT on gift-card purchases AND
// on gift-card-funded service payments (an output tax leak), so it is
// overridden to SPV on this READ path so VAT applies to gift-card
// purchases regardless of the stored setting.
if cfg.VoucherType == "MPV" {
log.Printf("VAT config override: voucher_type is 'MPV', which is unavailable for this salon-only gift-card business — treating it as 'SPV' for VAT application (HMRC VAT Notice 700/7)")
cfg.VoucherType = "SPV"
}
return &cfg, nil
}
// vatAppliesToVoucher reports whether VAT should be applied to a gift-card
// (voucher) sale or purchase. A gift card is a single-purpose voucher (SPV)
// and VAT applies to SPV sales whenever the business is VAT registered; it is
// not applied when the business is not registered or the config is absent.
// GetVATConfig already normalises "MPV" to "SPV", so this returns true unless
// the business is explicitly not VAT registered.
func vatAppliesToVoucher(cfg *VATConfig) bool {
if cfg == nil {
return false
}
return cfg.IsVATRegistered && cfg.VoucherType == "SPV"
}
// effectiveVoucherTypeForPurchase returns the voucher type that MUST be
// recorded on a gift card at purchase time. HMRC VAT Notice 700/7: a salon-only
// gift card is a single-purpose voucher (SPV) by definition — MPV is legally
// unavailable for this business, so a stored 'MPV' is overridden to 'SPV'
// everywhere (GetVATConfig does the same on the read path). Writing the
// EFFECTIVE type into voucher_type_at_purchase matters: the redemption path
// (handlers.go) defers VAT to redemption only for cards whose stored
// voucher_type_at_purchase is 'MPV' — recording raw 'MPV' while VAT was
// already collected at sale (via the GetVATConfig SPV override) would apply
// VAT a SECOND time at redemption.
func effectiveVoucherTypeForPurchase(raw string) string {
if raw == "MPV" {
return "SPV"
}
return raw
}
// ApplyVATToBookingPayment reads VAT config and calls apply_vat_to_payment
// on a booking payment record. The q parameter is used for both reading the
// VAT config and for the defensive payment-method check, ensuring all reads
@@ -67,12 +107,10 @@ func ApplyVATToTillSale(ctx context.Context, q db.Querier, saleID string) {
log.Printf("Failed to read VAT config for till sale %s: %v", saleID, err)
return
}
if !vatCfg.IsVATRegistered || vatCfg.VoucherType != "SPV" {
if !vatAppliesToVoucher(vatCfg) {
return
}
if _, execErr := q.Exec(ctx, "SELECT apply_vat_to_till_sale($1, $2)", saleID, vatCfg.DefaultVATRate); execErr != nil {
log.Printf("Failed to apply VAT to till sale %s: %v", saleID, execErr)
}
}
+76 -69
View File
@@ -91,7 +91,11 @@ func TestSPV_VATAppliedAtTillSale(t *testing.T) {
}
}
func TestMPV_NoVATAtTillSale(t *testing.T) {
// TestMPV_TreatedAsSPV_AppliesVATAtTillSale verifies that a till sale under
// voucher_type=MPV still gets VAT: per HMRC VAT Notice 700/7 a salon-only gift
// card is an SPV by definition, so MPV is overridden to SPV for VAT
// application (M6).
func TestMPV_TreatedAsSPV_AppliesVATAtTillSale(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`)
@@ -142,14 +146,14 @@ func TestMPV_NoVATAtTillSale(t *testing.T) {
t.Fatalf("failed to query till_sales: %v", err)
}
if isVATApplicable {
t.Error("expected is_vat_applicable to be FALSE for MPV till sale")
if !isVATApplicable {
t.Error("expected is_vat_applicable to be TRUE for MPV (treated as SPV) till sale")
}
if vatAmount.Valid {
t.Errorf("expected vat_amount to be NULL for MPV till sale, got %.2f", vatAmount.Float64)
if !vatAmount.Valid || vatAmount.Float64 != 8.33 {
t.Errorf("expected vat_amount 8.33 for MPV (treated as SPV) till sale, got %v", vatAmount)
}
if netAmount.Valid {
t.Errorf("expected net_amount to be NULL for MPV till sale, got %.2f", netAmount.Float64)
if !netAmount.Valid || netAmount.Float64 != 41.67 {
t.Errorf("expected net_amount 41.67 for MPV (treated as SPV) till sale, got %v", netAmount)
}
}
@@ -669,8 +673,8 @@ func TestGetVATConfig(t *testing.T) {
if cfg.DefaultVATRate != 5.00 {
t.Errorf("expected DefaultVATRate 5.00, got %.2f", cfg.DefaultVATRate)
}
if cfg.VoucherType != "MPV" {
t.Errorf("expected VoucherType MPV, got %s", cfg.VoucherType)
if cfg.VoucherType != "SPV" {
t.Errorf("expected VoucherType SPV (MPV is overridden to SPV for VAT application), got %s", cfg.VoucherType)
}
// Test with FALSE
@@ -1433,7 +1437,7 @@ func TestSPV_FullLifecycle_BuyAndRedeem(t *testing.T) {
}
}
func TestMPV_FullLifecycle_BuyAndRedeem(t *testing.T) {
func TestEffectiveSPV_MPVConfig_FullLifecycle_BuyAndRedeem(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`)
@@ -1448,7 +1452,9 @@ func TestMPV_FullLifecycle_BuyAndRedeem(t *testing.T) {
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
// Phase 1: Buy gift card via till sale (cash) — MPV, so NO VAT at sale
// Phase 1: Buy gift card via till sale (cash) — configured MPV is treated
// as SPV for VAT application (HMRC VAT Notice 700/7), so VAT IS applied at
// sale (and the card is stored with the effective voucher type 'SPV')
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
@@ -1473,15 +1479,15 @@ func TestMPV_FullLifecycle_BuyAndRedeem(t *testing.T) {
var tsResp TillSaleResponse
json.NewDecoder(w.Body).Decode(&tsResp)
// Verify till_sale has NO VAT (MPV)
// Verify till_sale HAS VAT (MPV treated as SPV)
var tsVAT sql.NullFloat64
var tsVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&tsVATApplicable, &tsVAT)
if tsVATApplicable {
t.Error("MPV: expected NO till_sale VAT")
if !tsVATApplicable {
t.Error("MPV (treated as SPV): expected till_sale VAT")
}
if tsVAT.Valid {
t.Errorf("MPV: expected NULL vat_amount at sale, got %.2f", tsVAT.Float64)
if !tsVAT.Valid || tsVAT.Float64 != 16.67 {
t.Errorf("MPV (treated as SPV): expected vat_amount 16.67 at sale, got %v", tsVAT)
}
// Get the gift card ID
@@ -1491,7 +1497,9 @@ func TestMPV_FullLifecycle_BuyAndRedeem(t *testing.T) {
t.Fatalf("failed to get gift card ID: %v", err)
}
// Phase 2: Create a booking and redeem the gift card — MPV so VAT at redemption
// Phase 2: Create a booking and redeem the gift card — the card is stored
// with the effective voucher type 'SPV', so VAT was already charged at
// purchase; NO VAT is applied at redemption.
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
@@ -1523,25 +1531,20 @@ func TestMPV_FullLifecycle_BuyAndRedeem(t *testing.T) {
t.Fatalf("redemption: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
// Verify VAT IS applied at redemption (MPV)
// Verify NO VAT is applied at redemption — the card was stored as SPV
// (effective type), so VAT was already charged at purchase
var payVAT sql.NullFloat64
var payNet sql.NullFloat64
var payVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT, &payNet)
if !payVATApplicable {
t.Error("MPV redemption: expected VAT at redemption")
if payVATApplicable {
t.Error("SPV redemption: expected NO VAT at redemption (VAT already charged at purchase)")
}
if !payVAT.Valid {
t.Fatal("MPV redemption: expected vat_amount to be set")
if payVAT.Valid {
t.Errorf("SPV redemption: expected NULL vat_amount, got %.2f", payVAT.Float64)
}
if payVAT.Float64 != 8.33 {
t.Errorf("MPV redemption: expected vat 8.33, got %.2f", payVAT.Float64)
}
if !payNet.Valid {
t.Fatal("MPV redemption: expected net_amount to be set")
}
if payNet.Float64 != 41.67 {
t.Errorf("MPV redemption: expected net 41.67, got %.2f", payNet.Float64)
if payNet.Valid {
t.Errorf("SPV redemption: expected NULL net_amount, got %.2f", payNet.Float64)
}
}
@@ -1821,9 +1824,9 @@ func TestVAT_GiftCardCRUD_DoesNotInterfere(t *testing.T) {
// ─── Additional tests ──────────────────────────────────────────────────────────
// TestMPV_Topup_NoVAT verifies a gift card topup with voucher_type=MPV does not
// apply VAT at sale.
func TestMPV_Topup_NoVAT(t *testing.T) {
// TestMPV_Topup_AppliesVAT verifies a gift card topup with voucher_type=MPV
// still applies VAT (MPV is overridden to SPV for VAT application, M6).
func TestMPV_Topup_AppliesVAT(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`)
@@ -1847,7 +1850,7 @@ func TestMPV_Topup_NoVAT(t *testing.T) {
t.Fatalf("failed to create gift card: %v", err)
}
// Top up via till sale (cash) — MPV, so no VAT
// Top up via till sale (cash) — MPV treated as SPV, so VAT applies
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
@@ -1873,15 +1876,15 @@ func TestMPV_Topup_NoVAT(t *testing.T) {
var tsResp TillSaleResponse
json.NewDecoder(w.Body).Decode(&tsResp)
// Verify NO VAT on MPV topup
// Verify VAT IS applied on MPV (treated as SPV) topup
var vatAmount sql.NullFloat64
var isVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&isVATApplicable, &vatAmount)
if isVATApplicable {
t.Error("expected no VAT on MPV topup")
if !isVATApplicable {
t.Error("expected VAT on MPV (treated as SPV) topup")
}
if vatAmount.Valid {
t.Errorf("expected NULL vat_amount on MPV topup, got %.2f", vatAmount.Float64)
if !vatAmount.Valid || vatAmount.Float64 != 4.17 {
t.Errorf("expected vat_amount 4.17 on MPV (treated as SPV) topup, got %v", vatAmount)
}
// Verify gift card balance still increased
@@ -2154,8 +2157,8 @@ func TestVoucherToggle_SPVPurchase_MPVRedeem(t *testing.T) {
}
}
// TestApplyVATToTillSale_MPV verifies ApplyVATToTillSale has no effect when
// voucher_type is MPV.
// TestApplyVATToTillSale_MPV verifies ApplyVATToTillSale applies VAT even when
// voucher_type is MPV (MPV is overridden to SPV for VAT application, M6).
func TestApplyVATToTillSale_MPV(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
@@ -2188,21 +2191,23 @@ func TestApplyVATToTillSale_MPV(t *testing.T) {
t.Fatalf("failed to query till_sale: %v", err)
}
if isVATApplicable {
t.Error("expected is_vat_applicable to be FALSE for MPV")
if !isVATApplicable {
t.Error("expected is_vat_applicable to be TRUE for MPV (treated as SPV)")
}
if vatAmount.Valid {
t.Errorf("expected vat_amount to be NULL for MPV, got %.2f", vatAmount.Float64)
if !vatAmount.Valid || vatAmount.Float64 != 8.33 {
t.Errorf("expected vat_amount 8.33 for MPV (treated as SPV), got %v", vatAmount)
}
}
// ─── Exhaustive voucher_type toggle + legacy tests ─────────────────────────
// TestVoucherToggle_MPVPurchase_SPVRedeem verifies that a gift card bought
// as MPV (no VAT at sale, deferred to redemption) and then redeemed after
// switching to SPV still gets VAT at redemption — because the stored
// voucher_type_at_purchase is MPV, overriding the current business_settings.
func TestVoucherToggle_MPVPurchase_SPVRedeem(t *testing.T) {
// TestVoucherToggle_MPVPurchase_SingleVATAtSale verifies that a gift card
// bought under voucher_type=MPV is recorded with the effective voucher type
// 'SPV' (HMRC VAT Notice 700/7 — MPV is unavailable for a salon-only gift-card
// business) and gets VAT at sale only. Because the card is stored as SPV,
// redeeming it later — even after toggling business_settings back to SPV —
// applies NO VAT at redemption.
func TestVoucherToggle_MPVPurchase_SingleVATAtSale(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`)
@@ -2217,7 +2222,7 @@ func TestVoucherToggle_MPVPurchase_SPVRedeem(t *testing.T) {
_, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
token := jwt.GenerateTestToken(adminID, "admin")
// Phase 1: Buy gift card as MPV — no VAT at sale
// Phase 1: Buy gift card under MPV — MPV treated as SPV, so VAT at sale
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
@@ -2245,8 +2250,11 @@ func TestVoucherToggle_MPVPurchase_SPVRedeem(t *testing.T) {
var tsVAT sql.NullFloat64
var tsVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&tsVATApplicable, &tsVAT)
if tsVATApplicable {
t.Error("MPV purchase: expected NO VAT at sale")
if !tsVATApplicable {
t.Error("MPV purchase (treated as SPV): expected VAT at sale")
}
if !tsVAT.Valid || tsVAT.Float64 != 16.67 {
t.Errorf("MPV purchase (treated as SPV): expected vat_amount 16.67 at sale, got %v", tsVAT)
}
var cardID string
@@ -2256,17 +2264,19 @@ func TestVoucherToggle_MPVPurchase_SPVRedeem(t *testing.T) {
t.Fatalf("failed to get card ID: %v", err)
}
tx.QueryRow(ctx, "SELECT voucher_type_at_purchase FROM gift_cards WHERE id = $1", cardID).Scan(&storedVTP)
if !storedVTP.Valid || storedVTP.String != "MPV" {
t.Errorf("expected voucher_type_at_purchase 'MPV', got %v", storedVTP)
if !storedVTP.Valid || storedVTP.String != "SPV" {
t.Errorf("expected voucher_type_at_purchase 'SPV' (MPV overridden to the effective SPV), got %v", storedVTP)
}
// Phase 2: Toggle to SPV — should NOT affect already-purchased cards
// Phase 2: Toggle to SPV — the card already stores the effective SPV type,
// so redemption is unaffected by the toggle
_, err = tx.Exec(ctx, `UPDATE business_settings SET voucher_type = 'SPV'`)
if err != nil {
t.Fatalf("failed to toggle to SPV: %v", err)
}
// Phase 3: Redeem — stored voucher_type is MPV, so VAT IS applied at redemption
// Phase 3: Redeem — the card is stored as SPV, so VAT was already charged
// at purchase and NO VAT is applied at redemption
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
@@ -2301,14 +2311,11 @@ func TestVoucherToggle_MPVPurchase_SPVRedeem(t *testing.T) {
var payVAT sql.NullFloat64
var payVATApplicable bool
tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT)
if !payVATApplicable {
t.Error("expected VAT at redemption (card stored MPV)")
if payVATApplicable {
t.Error("expected NO VAT at redemption (card stored as SPV; VAT charged at purchase)")
}
if !payVAT.Valid {
t.Fatal("expected vat_amount to be set at MPV-stored redemption")
}
if payVAT.Float64 != 8.33 {
t.Errorf("expected vat 8.33, got %.2f", payVAT.Float64)
if payVAT.Valid {
t.Errorf("expected NULL vat_amount at SPV-stored redemption, got %.2f", payVAT.Float64)
}
}
@@ -2761,8 +2768,8 @@ func TestGetVATConfig_WithTxQuerier(t *testing.T) {
if cfg.DefaultVATRate != 5.00 {
t.Errorf("expected DefaultVATRate 5.00, got %.2f", cfg.DefaultVATRate)
}
if cfg.VoucherType != "MPV" {
t.Errorf("expected VoucherType MPV, got %s", cfg.VoucherType)
if cfg.VoucherType != "SPV" {
t.Errorf("expected VoucherType SPV (MPV is overridden to SPV for VAT application), got %s", cfg.VoucherType)
}
}
@@ -3287,7 +3294,7 @@ func TestVAT_RoundingConsistency(t *testing.T) {
for _, tt := range edgeCases {
t.Run(tt.name, func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
ctx, tx := testutils.SetupTestTx(t)
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
if err != nil {
@@ -3383,9 +3390,9 @@ func TestVAT_RoundingConsistency(t *testing.T) {
// ─── T8: SPV vs MPV Lifecycle Tests ─────────────────────────────────────────
// NOTE: SPV and MPV lifecycle tests already exist above:
// - TestSPV_FullLifecycle_BuyAndRedeem (line ~1334)
// - TestMPV_FullLifecycle_BuyAndRedeem (line ~1446)
// - TestEffectiveSPV_MPVConfig_FullLifecycle_BuyAndRedeem (line ~1446)
// - TestVoucherToggle_SPVPurchase_MPVRedeem (line ~2059)
// - TestVoucherToggle_MPVPurchase_SPVRedeem (line ~2220)
// - TestVoucherToggle_MPVPurchase_SingleVATAtSale (line ~2220)
// These comprehensively cover the full lifecycle of both voucher types
// including purchase, redemption, and toggle scenarios.
+171 -43
View File
@@ -20,6 +20,8 @@ import (
"crussell/db"
"crussell/handlers/payments"
"github.com/jackc/pgx/v5"
)
type SquareWebhookEvent struct {
@@ -95,13 +97,17 @@ var errWebhookParseFailure = errors.New("webhook payload parse failure")
// isSquareMoneyFamily reports whether an event type belongs to a money-state
// family (payment.*, refund.*, dispute.*, terminal.*, plus money-adjacent
// cash_drawer.*, gift_card.* and transaction.*). Unknown events in these
// families MUST NOT be 200-acked — see the default-branch split in
// HandleSquareWebhook.
// cash_drawer.*, gift_card.*, transaction.* and invoice.*). invoice.* is
// deliberately included even though this app does not use Square Invoices: an
// invoice event is money-adjacent (Invoice carries amounts and payment state),
// so if Square Invoices are ever used the funds must surface as unknown-money
// and stay retried rather than being acked 200 + dedup'd permanently (M11).
// Unknown events in these families MUST NOT be 200-acked — see the
// default-branch split in HandleSquareWebhook.
func isSquareMoneyFamily(eventType string) bool {
for _, prefix := range []string{
"payment.", "refund.", "dispute.", "terminal.",
"cash_drawer.", "gift_card.", "transaction.",
"cash_drawer.", "gift_card.", "transaction.", "invoice.",
} {
if strings.HasPrefix(eventType, prefix) {
return true
@@ -112,13 +118,13 @@ func isSquareMoneyFamily(eventType string) bool {
// isSquareNonMoneyFamily reports whether an event type belongs to a known
// non-money family this app will never process (customer.*, card.*, order.*,
// invoice.*, booking.* and the other Square families below, none of which
// carry money state this app tracks). These are deliberately acked 200 WITH a
// dedup row so Square stops retrying them — see the default-branch split in
// booking.* and the other Square families below, none of which carry money
// state this app tracks). These are deliberately acked 200 WITH a dedup row so
// Square stops retrying them — see the default-branch split in
// HandleSquareWebhook.
func isSquareNonMoneyFamily(eventType string) bool {
for _, prefix := range []string{
"customer.", "card.", "order.", "invoice.", "booking.",
"customer.", "card.", "order.", "booking.",
"appointment.", "availability.", "loyalty.", "merchant.",
"location.", "labor.", "inventory.", "site.", "device.",
"team_member.", "subscription.", "webhook.",
@@ -160,11 +166,14 @@ func webhookDBContext() (context.Context, context.CancelFunc) {
// An absent header is allowed through: real Square deliveries always send it,
// so a missing header in an enforced deployment is a non-Square client (which
// already failed signature verification) or a local mock/dev poster — both
// safely handled downstream. Rejection is 403 (non-retryable for Square):
// a square-environment mismatch is a PERMANENT configuration error that a
// retry could never resolve, so a 5xx would make Square retry forever; a 4xx
// stops the retry loop and forces the operator to fix the subscription or the
// environment setting.
// safely handled downstream. Rejection is 403. NOTE: Square retries ANY
// non-2xx response (4xx included) with exponential backoff for up to ~24h, so
// the 403 does not by itself stop the retry loop — but a square-environment
// mismatch is a PERMANENT configuration error that no retry can resolve, the
// handler is intentionally fail-closed (every replay is rejected identically
// before any state change), and the DB-level dedup (square_webhook_events +
// ON CONFLICT) makes the replayed deliveries replay-safe. The 403 forces the
// operator to fix the subscription or the environment setting.
func squareEnvironmentMismatch(headerEnv string) bool {
headerEnv = strings.ToLower(strings.TrimSpace(headerEnv))
if headerEnv == "" {
@@ -188,10 +197,10 @@ func squareEnvironmentMismatch(headerEnv string) bool {
// signed, well-formed event is deduplicated by event_id before dispatch and
// acknowledged 200. Unknown event types are split by family: money-state
// families (payment.*, refund.*, dispute.*, terminal.* and money-adjacent
// prefixes) and truly unknown prefixes get 501 (Square retries, no dedup row),
// while known non-money families (customer.*, card.*, order.*, invoice.*,
// booking.*, ...) are acked 200 WITH the dedup row so the subscription is
// never flooded into suspension.
// prefixes including invoice.*) and truly unknown prefixes get 501 (Square
// retries, no dedup row), while known non-money families (customer.*, card.*,
// order.*, booking.*, ...) are acked 200 WITH the dedup row so the subscription
// is never flooded into suspension.
func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 512*1024)
body, err := io.ReadAll(r.Body)
@@ -235,9 +244,12 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
// Fail-closed environment check: a sandbox subscription mis-pointed at the
// production URL + key would otherwise process sandbox events against
// production state. 403 (non-retryable for Square) is correct because a
// square-environment mismatch is a permanent config error — a 5xx would
// make Square retry a condition no retry can fix. See
// production state. 403 is correct because a square-environment mismatch is
// a permanent config error no retry can fix — note that Square retries ANY
// non-2xx (4xx included) for up to ~24h, so the 403 does not stop the retry
// loop by itself; the handler rejects every replay identically (fail-closed,
// before any state change) and the DB-level dedup (square_webhook_events +
// ON CONFLICT) makes the replayed deliveries replay-safe. See
// squareEnvironmentMismatch for the exact enforcement conditions.
if squareEnvironmentMismatch(r.Header.Get("square-environment")) {
log.Printf("[SQUARE-WEBHOOK] Rejecting event: square-environment header %q does not match configured SQUARE_ENVIRONMENT %q (403)",
@@ -256,9 +268,13 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
// An empty event_id cannot be deduplicated. Square always sends event_id,
// so this is defensive — but once handlers mutate state, a duplicate
// empty-ID event would double-apply. Reject with 400 (fail-safe, no
// dispatch, no dedup row): Square's retry policy retries on 5xx/timeouts
// but treats 4xx as non-retryable, so the malformed event is dropped
// without side effects.
// dispatch, no dedup row). NOTE: Square retries ANY non-2xx response (4xx
// included) with exponential backoff for up to ~24h, so the 400 does NOT by
// itself stop the retry loop — but the handler is fail-closed: every replay
// is rejected identically BEFORE any state change or dedup insert, so no
// side effect can ever be applied, and the DB-level dedup
// (square_webhook_events + ON CONFLICT) keeps the repeated deliveries
// replay-safe.
if event.EventID == "" {
log.Printf("[SQUARE-WEBHOOK] Rejecting event with empty event_id (400)")
http.Error(w, "Invalid event", http.StatusBadRequest)
@@ -325,14 +341,17 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
// kills money-event delivery (payment.created/updated).
//
// • MONEY-STATE families (any payment.*, refund.*, dispute.*,
// terminal.*, cash_drawer.*, gift_card.*, transaction.* not
// explicitly handled above, plus future money-adjacent prefixes):
// keep 501 so Square retries. A money event this app does not yet
// handle is NEVER a safe 200-ack (the caller would commit the dedup
// row and the event would be dropped forever); no dedup row is
// terminal.*, cash_drawer.*, gift_card.*, transaction.*, invoice.*
// not explicitly handled above, plus future money-adjacent
// prefixes): keep 501 so Square retries. A money event this app does
// not yet handle is NEVER a safe 200-ack (the caller would commit the
// dedup row and the event would be dropped forever); no dedup row is
// written and a later retry re-dispatches idempotently if code
// support for the type lands before the retry window closes.
// • KNOWN NON-MONEY families (customer.*, card.*, order.*, invoice.*,
// invoice.* is treated as money-adjacent even though Square Invoices
// are unused today: an invoice carries amounts and payment state, so
// acked invoice funds would be invisible to the app (M11).
// • KNOWN NON-MONEY families (customer.*, card.*, order.*,
// booking.* — plus appointment.*, availability.*, loyalty.*,
// merchant.*, location.*, labor.*, inventory.*, site.*, device.*,
// team_member.*, subscription.*, webhook.* — and anything else
@@ -347,12 +366,23 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
switch {
case isSquareMoneyFamily(event.Type):
log.Printf("[SQUARE-WEBHOOK] CRITICAL: unhandled money-family Square event type %q (event_id=%s) — not acknowledged; returning 501 so Square retries", event.Type, event.EventID)
// A19: raise the operator-facing admin notification BEFORE the 501.
// Without it the event is retried by Square for ~24h and then
// silently dropped with only a log line. The notification is a
// SEPARATE table — no square_webhook_events dedup row is written
// here, so Square keeps retrying the event exactly as before.
notifCtx, notifCancel := webhookDBContext()
insertUnknownEventNotification(notifCtx, event.Type, event.EventID)
notifCancel()
http.Error(w, "unhandled webhook event type", http.StatusNotImplemented)
return
case isSquareNonMoneyFamily(event.Type):
log.Printf("[SQUARE-WEBHOOK] WARNING: acknowledged unhandled non-money event %q (event_id=%s) — committed dedup row; Square stops retrying", event.Type, event.EventID)
default:
log.Printf("[SQUARE-WEBHOOK] CRITICAL: unhandled Square event type %q (event_id=%s) — not acknowledged; returning 501 so Square retries", event.Type, event.EventID)
notifCtx, notifCancel := webhookDBContext()
insertUnknownEventNotification(notifCtx, event.Type, event.EventID)
notifCancel()
http.Error(w, "unhandled webhook event type", http.StatusNotImplemented)
return
}
@@ -505,12 +535,19 @@ func squarePaymentStatusToLocal(status string) (string, bool) {
}
// squareRefundStatusToLocal maps Square's refund status to the local
// payment_status enum. PENDING is non-terminal.
// payment_status enum. Square's PaymentRefund states are PENDING, APPROVED,
// COMPLETED, CANCELED, FAILED and REJECTED (developer.squareup.com/reference/
// square/objects/PaymentRefund). COMPLETED/FAILED/REJECTED are TERMINAL —
// REJECTED (Square declined the refund) is a definitive failure and must be
// surfaced as local 'failed' instead of leaving the row pending until the slow
// sweep; PENDING and APPROVED are NON-terminal (the refund may still complete
// or be rejected) and map to a zero local status so the caller leaves the row
// untouched.
func squareRefundStatusToLocal(status string) (string, bool) {
switch status {
case "COMPLETED":
return "completed", true
case "FAILED":
case "FAILED", "REJECTED":
return "failed", true
default:
return "", false
@@ -646,6 +683,78 @@ func insertCriticalPaymentNotification(ctx context.Context, bookingID, disputeID
}
}
// unknownEventNotificationID derives the deterministic admin_notifications id
// for an unhandled money-family/truly-unknown webhook event's
// critical_payment_log notification: 'U' + 11 lowercase hex chars of a SHA-256
// over 'unknown-event-<event_id>'. Mirrors disputeNotificationID (which uses an
// uppercase 'D' prefix): generate_*_id (init-script.sql) only ever emits 12
// lowercase hex chars, so the uppercase 'U' prefix guarantees no collision with
// a DB-generated id. The id is stable per event_id, giving
// ON CONFLICT (id) DO NOTHING per-event idempotency across redeliveries.
func unknownEventNotificationID(eventID string) string {
sum := sha256.Sum256([]byte("unknown-event-" + eventID))
return "U" + hex.EncodeToString(sum[:])[:11]
}
// insertUnknownEventNotification raises a critical_payment_log admin
// notification for a money-family/truly-unknown webhook event the handler
// refuses to ack (501). Without it the event is retried by Square for ~24h and
// then silently dropped with only a log line. The notification is deduped by
// its deterministic id (unknownEventNotificationID) and lives in a SEPARATE
// table — no square_webhook_events dedup row is written on the 501 path, so
// Square keeps retrying the event exactly as before. Best-effort: an insert
// failure is logged, never a dispatch error (the 501 is already the response).
// The caller supplies a bounded context (webhookDBContext).
func insertUnknownEventNotification(ctx context.Context, eventType, eventID string) {
id := unknownEventNotificationID(eventID)
tag, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (id, reason, booking_id, created_at)
VALUES ($1, 'critical_payment_log'::admin_notification_reason, NULL, NOW())
ON CONFLICT (id) DO NOTHING
`, id)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert critical_payment_log admin notification for unhandled event %q (event_id=%s): %v", eventType, eventID, err)
return
}
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] Inserted critical_payment_log admin notification for unhandled event type %q (event_id=%s)", eventType, eventID)
}
}
// insertRefundFailedNotification surfaces a webhook-demoted FAILED refund in
// the admin notification centre, replicating payments.insertRefundFailedNotifications
// (handlers/payments/refunds.go) — the same 'refund_failed' row and the same
// per-booking dedup. The payments helper is unexported (different package) and
// the sweep-path notification it backs is permanently lost once THIS webhook
// demotes pending→failed (the sweep only processes 'pending' rows), so the
// webhook must raise the notification itself. Dedup: one row per
// (reason='refund_failed', booking_id), matching the sweep's guard so a later
// sweep run can never duplicate it. Best-effort: a failed insert is logged,
// never a dispatch error. The caller supplies a bounded context
// (webhookDBContext).
func insertRefundFailedNotification(ctx context.Context, refundID string) {
if refundID == "" {
return
}
tag, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (reason, booking_id, created_at)
SELECT DISTINCT 'refund_failed'::admin_notification_reason, booking_id, NOW()
FROM refunds
WHERE id = ANY($1) AND status = 'failed'
AND NOT EXISTS (
SELECT 1 FROM admin_notifications an
WHERE an.reason = 'refund_failed' AND an.booking_id = refunds.booking_id
)
`, []string{refundID})
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert refund_failed admin notification for refund %s: %v", refundID, err)
return
}
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] Inserted refund_failed admin notification (refund_id=%s)", refundID)
}
}
// markPaymentFailed flips a payment to 'failed' after a lost dispute — the
// money was charged back, so the row must not read as collected. 'refunded'
// rows are left alone (the money was returned by refund, not charged back).
@@ -865,20 +974,39 @@ func handleRefundUpdated(data json.RawMessage) error {
// refunds, so this only tightens it. FAILED only demotes a 'pending' row:
// demoting 'completed' would let the guard exclude money that already moved
// (the exact risk refunds.go documents for failed refunds).
var upd string
switch localStatus {
case "completed":
upd = `UPDATE refunds SET status = 'completed' WHERE square_refund_id = $1 AND status <> 'completed'`
tag, err := db.Conn.Exec(ctx, `UPDATE refunds SET status = 'completed' WHERE square_refund_id = $1 AND status <> 'completed'`, refund.ID)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to update refund %s to status %s: %v", refund.ID, localStatus, err)
return err
}
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s → local status %s", refund.ID, localStatus)
}
return nil
case "failed":
upd = `UPDATE refunds SET status = 'failed' WHERE square_refund_id = $1 AND status = 'pending'`
}
tag, err := db.Conn.Exec(ctx, upd, refund.ID)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to update refund %s to status %s: %v", refund.ID, localStatus, err)
return err
}
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s → local status %s", refund.ID, localStatus)
// A5d: this webhook demotes a 'pending' row to 'failed' BEFORE the sweep
// ever sees it — the sweep only processes 'pending' rows, so its
// failed-refund admin notification (insertRefundFailedNotifications in
// refunds.go) would be permanently lost. Raise the same 'refund_failed'
// notification here, guarded to the actually-demoted row.
var rowID string
err := db.Conn.QueryRow(ctx,
`UPDATE refunds SET status = 'failed' WHERE square_refund_id = $1 AND status = 'pending' RETURNING id`,
refund.ID).Scan(&rowID)
if errors.Is(err, pgx.ErrNoRows) {
// No pending row matched — the refund is already resolved; a late
// FAILED/REJECTED replay must not demote or notify anything.
return nil
}
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to update refund %s to status %s: %v", refund.ID, localStatus, err)
return err
}
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s → local status failed (row %s)", refund.ID, rowID)
insertRefundFailedNotification(ctx, rowID)
return nil
}
return nil
}
@@ -965,7 +1093,7 @@ func handleDisputeCreated(data json.RawMessage) error {
}
_ = tag
insertCriticalPaymentNotification(ctx, bookingID, "")
log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s created (amount %s, reason %q) for square payment %s — admin notified", dispute.ID, amount, dispute.Reason, squarePaymentID)
log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s created (amount %s, reason %q) for square payment %s — admin notified", dispute.ID, amount, truncateDisputeReason(dispute.Reason), squarePaymentID)
return nil
}
@@ -91,6 +91,16 @@ func countCriticalNotifications(t *testing.T) int {
return n
}
func countRefundFailedNotifications(t *testing.T) int {
t.Helper()
var n int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refund_failed'").Scan(&n); err != nil {
t.Fatalf("failed to count refund_failed notifications: %v", err)
}
return n
}
// deliverWebhook signs and dispatches a Square event through the full handler.
func deliverWebhook(t *testing.T, event SquareWebhookEvent) *httptest.ResponseRecorder {
t.Helper()
@@ -946,6 +956,96 @@ func TestWebhook_RefundUpdated_FailedStatus(t *testing.T) {
}
}
// TestWebhook_RefundUpdated_RejectedStatus locks the REJECTED mapping: Square
// rejects a refund (REJECTED is a terminal state — Square declined to process
// it), so the local refund must be marked 'failed' and surfaced immediately
// instead of staying pending until the slow sweep notices.
func TestWebhook_RefundUpdated_RejectedStatus(t *testing.T) {
const (
squarePaymentID = "sqp_refund_pay_reject"
squareRefundID = "sqr_updated_rejected"
)
payID := createWebhookTestPayment(t, squarePaymentID, "completed")
refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending")
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_updated_rejected_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
"object": {
"refund": {
"id": "` + squareRefundID + `",
"status": "REJECTED",
"payment_id": "` + squarePaymentID + `"
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getRefundStatus(t, refundID); got != "failed" {
t.Errorf("expected refund status 'failed' on REJECTED, got %q", got)
}
}
// TestWebhook_RefundUpdated_Failed_RaisesAdminNotification locks the A5d fix:
// a FAILED (or REJECTED) refund.updated event demotes the pending refund row
// BEFORE the sweep ever sees it, so the sweep-path refund_failed admin
// notification would be permanently lost — the webhook path must raise the
// notification itself, and only once.
func TestWebhook_RefundUpdated_Failed_RaisesAdminNotification(t *testing.T) {
const (
squarePaymentID = "sqp_refund_pay_notify"
squareRefundID = "sqr_updated_failed_notify"
)
payID := createWebhookTestPayment(t, squarePaymentID, "completed")
refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending")
countBefore := countRefundFailedNotifications(t)
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_updated_failed_notify_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
"object": {
"refund": {
"id": "` + squareRefundID + `",
"status": "FAILED",
"payment_id": "` + squarePaymentID + `"
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getRefundStatus(t, refundID); got != "failed" {
t.Fatalf("expected refund status 'failed', got %q", got)
}
if n := countRefundFailedNotifications(t) - countBefore; n != 1 {
t.Errorf("expected exactly 1 refund_failed admin notification after webhook demotion, got %d", n)
}
// Re-delivery of the SAME event must not add a second notification (the
// handler's event_id dedup drops the replay before dispatch).
w2 := deliverWebhook(t, event)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200 on re-delivery, got %d: %s", w2.Code, w2.Body.String())
}
if n := countRefundFailedNotifications(t) - countBefore; n != 1 {
t.Errorf("expected the notification count to stay at 1 after re-delivery, got %d", n)
}
}
func TestWebhook_RefundUpdated_NonTerminal_LeavesPending(t *testing.T) {
const (
squarePaymentID = "sqp_refund_pay_pending"
+17 -3
View File
@@ -272,6 +272,7 @@ func TestHandleSquareWebhook_UnknownEventType(t *testing.T) {
}
body, _ := json.Marshal(event)
sig := webhookTestEnv(t, body)
notifBefore := countCriticalNotifications(t)
w := makeWebhookRequest(body, sig, context.Background())
if w.Code != http.StatusNotImplemented {
t.Errorf("expected 501 Not Implemented for unknown event type, got %d. body: %s", w.Code, w.Body.String())
@@ -284,21 +285,28 @@ func TestHandleSquareWebhook_UnknownEventType(t *testing.T) {
if n := countWebhookEvents(t, event.EventID); n != 0 {
t.Errorf("expected no dedup row for an unhandled event type (Square must retry), got %d", n)
}
// A19: the event must still surface in the admin notification centre —
// without it, after Square's ~24h retry window the event is silently gone.
// The notification is a SEPARATE table, so the no-dedup-row assertion above
// still holds.
if n := countCriticalNotifications(t) - notifBefore; n != 1 {
t.Errorf("expected exactly 1 critical_payment_log admin notification for the unknown event (A19), got %d", n)
}
}
// TestHandleSquareWebhook_KnownNonMoneyEvent_Acknowledged verifies a signed
// event from a KNOWN NON-MONEY family this app will never process
// (e.g. invoice.created) is deliberately acknowledged 200 WITH a committed
// (e.g. customer.created) is deliberately acknowledged 200 WITH a committed
// dedup row so Square stops retrying it. These events carry no money state the
// app tracks, so acking loses nothing — and retrying them would fill the
// subscription's retry queue until Square suspends it, silently killing
// money-event delivery (payment.created/updated). The ack is logged at WARN.
func TestHandleSquareWebhook_KnownNonMoneyEvent_Acknowledged(t *testing.T) {
event := SquareWebhookEvent{
Type: "invoice.created",
Type: "customer.created",
EventID: "evt_nonmoney_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{"id":"inv_1"}`),
Data: json.RawMessage(`{"id":"cust_1"}`),
}
body, _ := json.Marshal(event)
sig := webhookTestEnv(t, body)
@@ -341,6 +349,7 @@ func TestHandleSquareWebhook_UnhandledMoneyEvent_NotAcknowledged(t *testing.T) {
}
body, _ := json.Marshal(event)
sig := webhookTestEnv(t, body)
notifBefore := countCriticalNotifications(t)
w := makeWebhookRequest(body, sig, context.Background())
if w.Code != http.StatusNotImplemented {
t.Errorf("expected 501 Not Implemented for unhandled money-family event, got %d. body: %s", w.Code, w.Body.String())
@@ -353,6 +362,11 @@ func TestHandleSquareWebhook_UnhandledMoneyEvent_NotAcknowledged(t *testing.T) {
if n := countWebhookEvents(t, event.EventID); n != 0 {
t.Errorf("expected no dedup row for an unhandled money-family event (Square must retry), got %d", n)
}
// A19: the unhandled money event must still surface in the admin
// notification centre before the retry window closes.
if n := countCriticalNotifications(t) - notifBefore; n != 1 {
t.Errorf("expected exactly 1 critical_payment_log admin notification for the unhandled money-family event (A19), got %d", n)
}
}
func TestHandleSquareWebhook_InvalidJSON(t *testing.T) {
+299
View File
@@ -4,10 +4,16 @@ package jobs
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"sync"
"testing"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/square"
"crussell/testutils/testdb"
)
@@ -323,3 +329,296 @@ func TestScanCriticalPaymentLogs_RefundBelowCapNotNotified(t *testing.T) {
t.Errorf("expected 0 notifications for a refund below the attempt cap, got %d", n)
}
}
// ============================================================
// RetryPendingSquareErasures — GDPR Square outbox job (batch-1 fix)
// ============================================================
// recordingErasureClient embeds the dev mock and records every Square erasure
// call, with opt-in failure injection, so tests can assert exactly what the
// retry-square-erasures job calls (and doesn't call) at Square.
type recordingErasureClient struct {
square.SquareClient
mu sync.Mutex
deletedCards []string
deletedCustomers []string
failCards bool
failCustomers bool
}
func (c *recordingErasureClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
c.mu.Lock()
c.deletedCards = append(c.deletedCards, cardID)
c.mu.Unlock()
if c.failCards {
return fmt.Errorf("square: simulated card erasure failure")
}
return c.SquareClient.DeleteCardOnFile(ctx, cardID)
}
func (c *recordingErasureClient) DeleteCustomer(ctx context.Context, customerID string) error {
c.mu.Lock()
c.deletedCustomers = append(c.deletedCustomers, customerID)
c.mu.Unlock()
if c.failCustomers {
return fmt.Errorf("square: simulated customer erasure failure")
}
return c.SquareClient.DeleteCustomer(ctx, customerID)
}
func (c *recordingErasureClient) cardDeletes() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.deletedCards...)
}
func (c *recordingErasureClient) customerDeletes() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.deletedCustomers...)
}
// newErasureTestClient builds a recording client over the dev mock, forcing
// SQUARE_ENVIRONMENT=mock so a developer's production env var can never panic
// NewDevClient mid-test.
func newErasureTestClient(t *testing.T) *recordingErasureClient {
t.Helper()
t.Setenv("SQUARE_ENVIRONMENT", "mock")
return &recordingErasureClient{SquareClient: square.NewDevClient()}
}
// seedErasureOutboxRow inserts a soft-deleted user_saved_cards row that the
// retry-square-erasures job treats as a pending Square erasure outbox entry
// (deleted_at set + last_4 = 'XXXX' + at least one Square reference). Cleanup
// removes the row and any critical notifications the job raised.
func seedErasureOutboxRow(t *testing.T, squareCardID, squareCustomerID *string) string {
t.Helper()
ctx := context.Background()
var cardID, customerID any
if squareCardID != nil {
cardID = *squareCardID
}
if squareCustomerID != nil {
customerID = *squareCustomerID
}
var id string
if err := db.Conn.QueryRow(ctx, `
INSERT INTO user_saved_cards (square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, deleted_at)
VALUES ($1, $2, 'VISA', 'XXXX', 12, 2030, NOW())
RETURNING id
`, cardID, customerID).Scan(&id); err != nil {
t.Fatalf("failed to seed erasure outbox row: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(ctx, "DELETE FROM user_saved_cards WHERE id = $1", id)
_, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'")
})
return id
}
// querySquareCardID returns the square_card_id of a row, or nil when NULL.
func querySquareCardID(ctx context.Context, t *testing.T, rowID string) *string {
t.Helper()
var id *string
if err := db.Conn.QueryRow(ctx, "SELECT square_card_id FROM user_saved_cards WHERE id = $1", rowID).Scan(&id); err != nil {
t.Fatalf("failed to query square_card_id for row %s: %v", rowID, err)
}
return id
}
func querySquareCustomerID(ctx context.Context, t *testing.T, rowID string) *string {
t.Helper()
var id *string
if err := db.Conn.QueryRow(ctx, "SELECT square_customer_id FROM user_saved_cards WHERE id = $1", rowID).Scan(&id); err != nil {
t.Fatalf("failed to query square_customer_id for row %s: %v", rowID, err)
}
return id
}
// erasureNotificationID mirrors handlers/user's deterministic notification id
// scheme so the test can assert the exact alert row the job raised.
func erasureNotificationID(key string) string {
sum := sha256.Sum256([]byte("square-erasure-failure:" + key))
return "S" + hex.EncodeToString(sum[:])[:11]
}
// TestRetryPendingSquareErasures_DrainsCardOutboxRow verifies a pending card
// erasure is retried at Square and, on success, the outbox row is drained
// (square_card_id NULLed) and reported in the drained count.
func TestRetryPendingSquareErasures_DrainsCardOutboxRow(t *testing.T) {
ctx := context.Background()
client := newErasureTestClient(t)
card, err := client.CreateCardOnFile(ctx, "user-delete-me", "cnon:test-card", "cus_mock_seed")
if err != nil {
t.Fatalf("failed to seed mock card: %v", err)
}
cardID := card.CardID
rowID := seedErasureOutboxRow(t, &cardID, nil)
orig := payments.SquareClient
payments.SquareClient = client
t.Cleanup(func() { payments.SquareClient = orig })
n, err := RetryPendingSquareErasures(ctx)
if err != nil {
t.Fatalf("RetryPendingSquareErasures failed: %v", err)
}
if n != 1 {
t.Errorf("expected 1 drained row, got %d", n)
}
if got := client.cardDeletes(); len(got) != 1 || got[0] != cardID {
t.Errorf("expected exactly 1 card deletion call for %q, got %v", cardID, got)
}
if id := querySquareCardID(ctx, t, rowID); id != nil {
t.Errorf("expected square_card_id to be NULL after drain, got %q", *id)
}
}
// TestRetryPendingSquareErasures_DrainsCustomerOutboxRow verifies the same
// drain for a customer-only outbox row.
func TestRetryPendingSquareErasures_DrainsCustomerOutboxRow(t *testing.T) {
ctx := context.Background()
client := newErasureTestClient(t)
cust, err := client.CreateCustomer(ctx, "Erasure Test", "erasure-test@example.com")
if err != nil {
t.Fatalf("failed to seed mock customer: %v", err)
}
customerID := cust.ID
rowID := seedErasureOutboxRow(t, nil, &customerID)
orig := payments.SquareClient
payments.SquareClient = client
t.Cleanup(func() { payments.SquareClient = orig })
n, err := RetryPendingSquareErasures(ctx)
if err != nil {
t.Fatalf("RetryPendingSquareErasures failed: %v", err)
}
if n != 1 {
t.Errorf("expected 1 drained row, got %d", n)
}
if got := client.customerDeletes(); len(got) != 1 || got[0] != customerID {
t.Errorf("expected exactly 1 customer deletion call for %q, got %v", customerID, got)
}
if id := querySquareCustomerID(ctx, t, rowID); id != nil {
t.Errorf("expected square_customer_id to be NULL after drain, got %q", *id)
}
}
// TestRetryPendingSquareErasures_KeepsFailedCardRowForRetry verifies a failed
// Square deletion leaves the outbox row armed for the next run and raises a
// deduped critical notification (row-scoped key).
func TestRetryPendingSquareErasures_KeepsFailedCardRowForRetry(t *testing.T) {
ctx := context.Background()
client := newErasureTestClient(t)
client.failCards = true
cardID := "ccof:mock_missing_card"
rowID := seedErasureOutboxRow(t, &cardID, nil)
orig := payments.SquareClient
payments.SquareClient = client
t.Cleanup(func() { payments.SquareClient = orig })
n, err := RetryPendingSquareErasures(ctx)
if err != nil {
t.Fatalf("RetryPendingSquareErasures failed: %v", err)
}
if n != 0 {
t.Errorf("expected 0 drained rows on failure, got %d", n)
}
if id := querySquareCardID(ctx, t, rowID); id == nil || *id != cardID {
t.Errorf("expected square_card_id %q to be retained for retry, got %v", cardID, id)
}
var gotID string
if err := db.Conn.QueryRow(ctx, "SELECT id FROM admin_notifications WHERE reason = 'critical_payment_log'").Scan(&gotID); err != nil {
t.Fatalf("expected a critical_payment_log notification to be raised: %v", err)
}
if want := erasureNotificationID("row:" + rowID); gotID != want {
t.Errorf("expected notification id %q, got %q", want, gotID)
}
}
// TestRetryPendingSquareErasures_NoOpWithoutSquareClient verifies the job is a
// no-op when no Square client is configured: no external call, no drain, no
// error.
func TestRetryPendingSquareErasures_NoOpWithoutSquareClient(t *testing.T) {
ctx := context.Background()
cardID := "ccof:mock_card"
rowID := seedErasureOutboxRow(t, &cardID, nil)
orig := payments.SquareClient
payments.SquareClient = nil
t.Cleanup(func() { payments.SquareClient = orig })
n, err := RetryPendingSquareErasures(ctx)
if err != nil {
t.Fatalf("RetryPendingSquareErasures failed: %v", err)
}
if n != 0 {
t.Errorf("expected 0 drained rows without a Square client, got %d", n)
}
if id := querySquareCardID(ctx, t, rowID); id == nil || *id != cardID {
t.Errorf("expected outbox row to be untouched, got square_card_id %v", id)
}
}
// TestRetryPendingSquareErasures_KeepsSharedCustomerReferencedByActiveCard
// verifies a shared Square customer is NOT deleted (and its outbox row is
// drained as deliberately-skipped) while any active card of another account
// still references it.
func TestRetryPendingSquareErasures_KeepsSharedCustomerReferencedByActiveCard(t *testing.T) {
ctx := context.Background()
client := newErasureTestClient(t)
cust, err := client.CreateCustomer(ctx, "Shared User", "shared-erasure@example.com")
if err != nil {
t.Fatalf("failed to seed mock customer: %v", err)
}
customerID := cust.ID
outboxRowID := seedErasureOutboxRow(t, nil, &customerID)
var activeUserID string
if err := db.Conn.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, phone, date_of_birth)
VALUES ('Active', 'User', '+447700900127', '1990-01-01')
RETURNING id`).Scan(&activeUserID); err != nil {
t.Fatalf("failed to seed active user: %v", err)
}
var activeRowID string
if err := db.Conn.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year)
VALUES ($1, 'ccof:mock_active', $2, 'VISA', '4242', 12, 2030)
RETURNING id`, activeUserID, customerID).Scan(&activeRowID); err != nil {
t.Fatalf("failed to seed active card row: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(ctx, "DELETE FROM user_saved_cards WHERE id = $1", activeRowID)
_, _ = db.Conn.Exec(ctx, "DELETE FROM users WHERE id = $1", activeUserID)
})
orig := payments.SquareClient
payments.SquareClient = client
t.Cleanup(func() { payments.SquareClient = orig })
n, err := RetryPendingSquareErasures(ctx)
if err != nil {
t.Fatalf("RetryPendingSquareErasures failed: %v", err)
}
if n != 1 {
t.Errorf("expected 1 drained outbox row, got %d", n)
}
if got := client.customerDeletes(); len(got) != 0 {
t.Errorf("expected NO DeleteCustomer call for a still-referenced customer, got %v", got)
}
if id := querySquareCustomerID(ctx, t, outboxRowID); id != nil {
t.Errorf("expected outbox row square_customer_id to be drained, got %q", *id)
}
if id := querySquareCustomerID(ctx, t, activeRowID); id == nil || *id != customerID {
t.Errorf("expected active row to keep its customer reference, got %v", id)
}
}
+7
View File
@@ -55,10 +55,17 @@ func (p *ProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
return refundPaymentHTTP(ctx, req)
}
func (p *ProdClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
return paymentWasRefundedWithClient(ctx, paymentID, newHTTPClient())
}
func (p *ProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
return createCardOnFileHTTP(ctx, userID, cardToken, customerID)
}
// GetCardsOnFile has ZERO production callers (grep across the repo confirms
// the only users are this package's tests) and is kept on the SquareClient
// interface solely so the dev mock's List Cards parity tests can exercise it.
func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
return getCardsOnFileHTTP(ctx, userID)
}
+165 -23
View File
@@ -22,18 +22,21 @@ package square
//
// FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail,
// FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout,
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus) that let dev/tests
// drive Square failure modes that are otherwise only reachable against the real
// API. FailAfterCommit simulates the exact "charged but response lost → same-key
// retry" prod scenario: CreatePayment COMMITS the charge (retaining the key
// and source in the ledgers exactly like a successful charge) and THEN returns
// a 5xx-style error to the caller. A subsequent CreatePayment with the SAME
// key + SAME source dedups to the committed payment, proving no double charge.
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus,
// SimulateVerificationRequired) that let dev/tests drive Square failure modes
// that are otherwise only reachable against the real API. FailAfterCommit
// simulates the exact "charged but response lost → same-key retry" prod
// scenario: CreatePayment COMMITS the charge (retaining the key and source in
// the ledgers exactly like a successful charge) and THEN returns a 5xx-style
// error to the caller. A subsequent CreatePayment with the SAME key + SAME
// source dedups to the committed payment, proving no double charge.
// SimulateSourceUsed simulates Square's SOURCE_USED rejection of a card source
// (cnon: nonce) reused after a previous save. ForcePaymentStatus forces
// CreatePayment's payment status while returning nil error — the "Square
// returned 200 with a non-terminal payment" prod scenario, so a status-blind
// handler (records 'completed' on nil error alone) is caught in dev.
// SimulateVerificationRequired mirrors Square's SCA enforcement on
// customer-initiated new-card charges (see the field doc).
//
// REAL-API SAFETY GUARD. A `//go:build dev` build must never silently route to
// the real PRODUCTION Square API on an env-string match alone — a typo'd or
@@ -114,18 +117,24 @@ type MockClient struct {
// committed payment — never a second charge — exercising the retry path
// devs hit in prod when Square processes a charge but the response is lost.
FailAfterCommit bool
// SimulateSourceUsed makes CreateCardOnFile enforce Square's SOURCE_USED
// rejection: a card source (cnon: nonce) already used to create a card on
// this mock instance is rejected with the same structured 400 SOURCE_USED
// error real Square's CreateCard API returns (SOURCE_USED — NOT the
// CreatePayment code CARD_TOKEN_USED). Off by default — dev/test flows
// reuse plain "cnon:test-card"-style tokens across requests, so enforcement
// is enabled only in tests that exercise the reused-source rejection.
// UsedSources() reports the sources consumed so far.
// SimulateSourceUsed enforces Square's single-use source simulation on both
// endpoints: CreateCardOnFile rejects a card source (cnon: nonce) already
// used to create a card with the structured 400 SOURCE_USED error real
// Square's CreateCard API returns, and CreatePayment rejects a cnon nonce
// already used to create a payment or card with 400 CARD_TOKEN_USED. Off by
// default — the handler integration suite shares ONE mock instance across
// parallel tests (testmain_test.go) and reuses "cnon:test-card"-style
// tokens across requests, so enforcement is enabled only in tests that
// exercise the reused-source rejection. UsedSources() reports the sources
// consumed so far.
SimulateSourceUsed bool
// usedSources records card sources consumed by CreateCardOnFile while
// SimulateSourceUsed is enabled (Square consumes a cnon: nonce on card
// creation, so reusing it is rejected with SOURCE_USED).
// creation, so reusing it is rejected with SOURCE_USED). CreatePayment's
// single-use nonce simulation shares the same map: with the toggle on, a
// cnon consumed by either endpoint is rejected on reuse (CARD_TOKEN_USED
// from CreatePayment, SOURCE_USED from CreateCardOnFile) — exactly like
// real Square, which consumes a nonce regardless of which endpoint used it.
usedSources map[string]bool
// ForcePaymentStatus forces CreatePayment's payment status instead of the
// default "COMPLETED" (or "APPROVED" for autocomplete=false). When set,
@@ -137,6 +146,16 @@ type MockClient struct {
// see square_http_client.go), so only the handler's own status check can
// catch a FAILED/CANCELED/PENDING/APPROVED payment.
ForcePaymentStatus string
// SimulateVerificationRequired mirrors Square's SCA enforcement on
// customer-initiated new-card charges: when true, CreatePayment with a
// cnon: (new-card nonce) source that carries no VerificationToken is
// rejected with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED —
// the buyer must complete 3DS/SCA verification and re-tokenize, NOT retry
// the same request (the code is in definitivePaymentCodes). A present
// verification token (e.g. a verify_mock_... token) satisfies the gate and
// the charge succeeds. Off by default — existing dev/test flows charge
// plain "cnon:test-card"-style tokens without verification tokens.
SimulateVerificationRequired bool
}
type devProdClient struct{}
@@ -169,6 +188,9 @@ func (d *devProdClient) CancelCheckout(ctx context.Context, checkoutID string) e
func (d *devProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
return refundPaymentHTTP(ctx, req)
}
func (d *devProdClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
return PaymentWasRefunded(ctx, paymentID)
}
func (d *devProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
return createCardOnFileHTTP(ctx, userID, cardToken, customerID)
}
@@ -338,6 +360,47 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
}
}
// Mirror Square's SCA enforcement (opt-in toggle, off by default): a
// new-card (cnon:) charge without a 3DS/SCA verification token is rejected
// with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the buyer
// must re-verify and re-tokenize, NOT retry the same request (the code is
// in definitivePaymentCodes). A present verification token (e.g.
// verify_mock_...) satisfies the gate exactly as production accepts a
// Square-issued verification_token on the CreatePayment body.
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") && req.VerificationToken == "" {
return nil, &squareAPIError{
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
StatusCode: http.StatusBadRequest,
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
}
}
// Mirror Square's single-use card nonces: when SimulateSourceUsed is set,
// a cnon: nonce can only be charged once on this mock instance. Square
// consumes a nonce when it is used to create a payment, so reusing it
// under a DIFFERENT idempotency key is rejected with CARD_TOKEN_USED (the
// CreatePayment code for a used source) — a same-key retry already deduped
// above and never reaches here. The consumption is OFF by default: the
// handler integration suite shares ONE mock instance across parallel tests
// (testmain_test.go assigns a single square.NewDevClient() to the package
// global) and reuses "cnon:test-card"-style tokens across tests, so
// default-on consumption would break those tests. Tests that need the
// single-use simulation flip the toggle on.
if strings.HasPrefix(req.SourceID, "cnon:") && m.SimulateSourceUsed {
if m.usedSources[req.SourceID] {
return nil, &squareAPIError{
Code: "CARD_TOKEN_USED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "The card nonce can no longer be used because it has been used to create a payment",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: card nonce %s has already been used to create a payment", tokenPrefix(req.SourceID)),
}
}
m.usedSources[req.SourceID] = true
}
now := clock.Now().UTC()
status := "COMPLETED"
@@ -712,15 +775,62 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
return nil, fmt.Errorf("square: refund amount must be positive (amount_money is required)")
}
if _, ok := m.payments[req.PaymentID]; !ok {
// Payment not in mock map — this happens when integration tests
// create payments via DB fixture with a square_payment_id, bypassing
// the mock. Process the refund without full payment data.
log.Printf("[SQUARE-MOCK] RefundPayment: payment %s not in mock map — proceeding without full payment data", req.PaymentID)
// Reject refunds for a mock-artifact payment ID that was never created.
// The mock mints payment IDs as "pay_mock_<n>"; a refund targeting such an
// ID that is NOT in the ledger is a provable bug (that charge never went
// through this mock) and real Square answers 404 NOT_FOUND. Non-"pay_mock_"
// IDs (e.g. the DB-fixture square_payment_id values handler tests seed
// refunds against) are payments that exist outside the mock's ledger —
// exactly as they would at real Square — so they take the lenient path.
if strings.HasPrefix(req.PaymentID, "pay_mock_") {
if _, known := m.payments[req.PaymentID]; !known {
log.Printf("[SQUARE-MOCK] RefundPayment REJECTED: payment %s not found (NOT_FOUND)", req.PaymentID)
return nil, &squareAPIError{
Code: "NOT_FOUND",
Category: "INVALID_REQUEST_ERROR",
Detail: "The payment_id in the refund request does not exist",
StatusCode: http.StatusNotFound,
err: fmt.Errorf("square: no payment %s exists to refund", tokenPrefix(req.PaymentID)),
}
}
}
amount := req.Amount
// Known payments get the real Square over-refund rejection: refunding more
// than the remaining balance answers 400 REFUND_AMOUNT_INVALID. Square
// returns that SAME code for an already-refunded payment, so — exactly like
// the real client — the mock reconciles: an existing refund (money already
// moved) → ErrRefundAlreadyProcessed; no refund recorded → the amount is
// genuinely invalid → ErrRefundDeclined.
if payment, ok := m.payments[req.PaymentID]; ok {
remaining := payment.Amount
for _, r := range m.refunds {
if r.PaymentID == req.PaymentID && (r.Status == "COMPLETED" || r.Status == "APPROVED" || r.Status == "PENDING") {
remaining -= r.Amount
}
}
if req.Amount > remaining {
apiErr := &squareAPIError{
Code: "REFUND_AMOUNT_INVALID",
Category: "INVALID_REQUEST_ERROR",
Detail: "The refunded amount is more than the remaining balance",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: refund amount %d exceeds remaining balance %d for payment %s", req.Amount, remaining, req.PaymentID),
}
if remaining < payment.Amount {
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, apiErr)
}
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, apiErr)
}
} else {
// Payment not in mock map — this happens when integration tests create
// payments via DB fixture with a square_payment_id, bypassing the mock.
// Process the refund without full payment data (the balance is unknown,
// so no over-refund check applies).
log.Printf("[SQUARE-MOCK] RefundPayment: payment %s not in mock map — proceeding without full payment data", req.PaymentID)
}
locationID := req.LocationID
if locationID == "" {
locationID = "L_MOCK"
@@ -758,6 +868,26 @@ func (m *MockClient) RefundKeyCount() int {
return len(m.refundByKey)
}
// PaymentWasRefunded mirrors the real client's reconciliation source: true when
// any refund with status COMPLETED, APPROVED, or PENDING exists for the payment
// (FAILED/REJECTED refunds never moved money and are ignored). Shares the exact
// status set the real client's paymentWasRefundedWithClient uses so handler
// reconciliation behaves identically in dev/mock and production.
func (m *MockClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, r := range m.refunds {
if r.PaymentID != paymentID {
continue
}
switch r.Status {
case "COMPLETED", "APPROVED", "PENDING":
return true, nil
}
}
return false, nil
}
// UsedSources returns the card sources consumed by CreateCardOnFile while
// SimulateSourceUsed is enabled. Test accessor for asserting that a reused
// source is rejected with SOURCE_USED after a previous save.
@@ -883,14 +1013,26 @@ func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error
m.mu.Lock()
defer m.mu.Unlock()
// Production callers pass the DB-stored ccof: card reference
// (CardOnFile.CardID, e.g. "ccof:mock_..."), which the mock must resolve
// through cardByToken (keyed by the full CardID) so the deletion actually
// finds and disables the card — previously the mock keyed only by its
// mock-local ID (mock_card_...) and silently missed every ccof: call.
if card, ok := m.cardByToken[cardID]; ok {
card.Enabled = false
log.Printf("[SQUARE-MOCK] Card disabled: id=%s (user=%s)", tokenPrefix(cardID), tokenPrefix(card.ReferenceID))
return nil
}
// Fallback for the mock-local ID form (mock_card_...) still exercised by
// this package's own tests — resolve the card through the per-user maps.
for userID, cards := range m.cards {
if card, ok := cards[cardID]; ok {
card.Enabled = false
log.Printf("[SQUARE-MOCK] Card disabled: id=%s (user=%s)", cardID, userID)
log.Printf("[SQUARE-MOCK] Card disabled: id=%s (user=%s)", tokenPrefix(cardID), userID)
return nil
}
}
return fmt.Errorf("card not found: %s", cardID)
return fmt.Errorf("square: card not found: %s", cardID)
}
func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
+332
View File
@@ -438,6 +438,47 @@ func TestDevClient_RefundPayment_RefundAlreadyPending(t *testing.T) {
assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored when a refund is already pending")
}
func TestDevClient_PaymentWasRefunded_StatusSet(t *testing.T) {
// Locks the mock's PaymentWasRefunded status set against the real client's
// reconciliation source (COMPLETED/APPROVED/PENDING → true; FAILED/REJECTED
// → false): handler-side REFUND_AMOUNT_INVALID reconciliation must behave
// identically in dev/mock and production.
client := NewDevClient().(*MockClient)
ctx := context.Background()
now := time.Now().UTC()
for _, tc := range []struct {
status string
want bool
}{
{"COMPLETED", true},
{"APPROVED", true},
{"PENDING", true},
{"FAILED", false},
{"REJECTED", false},
} {
client.mu.Lock()
id := fmt.Sprintf("ref_mock_%d", now.UnixNano())
client.refunds[id] = &RefundResult{
ID: id,
Status: tc.status,
Amount: 5000,
PaymentID: "pay_mock_was_refunded",
CreatedAt: now.Format(time.RFC3339),
}
client.mu.Unlock()
got, err := client.PaymentWasRefunded(ctx, "pay_mock_was_refunded")
require.NoError(t, err)
assert.Equal(t, tc.want, got, "status %s", tc.status)
}
// A different payment with no refunds reports false.
got, err := client.PaymentWasRefunded(ctx, "pay_mock_no_refunds")
require.NoError(t, err)
assert.False(t, got)
}
func TestDevClient_RefundPayment_FailRefundCode_OtherCode(t *testing.T) {
// Any other code configured via FailRefundCode preserves the prior
// ErrRefundDeclined classification (e.g. REFUND_DECLINED in prod).
@@ -1890,3 +1931,294 @@ func TestDevClient_CreateCheckout_CompletedPaymentResolvableByID(t *testing.T) {
assert.Equal(t, completed.ID, got.ID)
assert.Equal(t, "COMPLETED", got.Status)
}
// TestDevClient_DeleteCardOnFile_CcofResolution locks the DeleteCardOnFile
// ccof: resolution fix: production callers pass the DB-stored ccof: card
// reference (CardOnFile.CardID, e.g. "ccof:mock_..."), which the mock must
// resolve through cardByToken so the deletion actually finds and disables the
// card — previously the mock keyed only by its mock-local ID (mock_card_...)
// and silently missed every ccof: call.
func TestDevClient_DeleteCardOnFile_CcofResolution(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
userID := "user-ccof-delete"
t.Run("ccof_card_id_disables_and_hides", func(t *testing.T) {
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-ccof", "cus_test123")
require.NoError(t, err)
require.True(t, strings.HasPrefix(card.CardID, "ccof:"), "mock CardID must be ccof:-prefixed to exercise the cardByToken path")
err = client.DeleteCardOnFile(ctx, card.CardID)
require.NoError(t, err, "deleting by the DB-stored ccof: CardID must resolve the card through cardByToken")
// The card object itself must be disabled, not just hidden.
client.mu.RLock()
deleted := client.cardByToken[card.CardID]
client.mu.RUnlock()
require.NotNil(t, deleted, "the ccof: token must remain resolvable after deletion")
assert.False(t, deleted.Enabled, "the ccof: resolved card must be disabled")
// Square's List Cards API excludes disabled cards by default — the
// deleted card disappears from GetCardsOnFile.
cards, err := client.GetCardsOnFile(ctx, userID)
require.NoError(t, err)
assert.Empty(t, cards, "a card deleted by its ccof: CardID must disappear from GetCardsOnFile")
})
t.Run("mock_local_id_still_works", func(t *testing.T) {
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-local", "cus_test123")
require.NoError(t, err)
err = client.DeleteCardOnFile(ctx, card.ID)
require.NoError(t, err, "deleting by the mock-local ID (mock_card_...) must keep working via the per-user fallback")
cards, err := client.GetCardsOnFile(ctx, userID)
require.NoError(t, err)
assert.Empty(t, cards, "a card deleted by its mock-local ID must also disappear from GetCardsOnFile")
})
t.Run("unknown_id_errors_not_silent", func(t *testing.T) {
err := client.DeleteCardOnFile(ctx, "ccof:never-created")
require.Error(t, err, "an unknown card ID must return an error, never silent success")
assert.Contains(t, err.Error(), "card not found")
})
}
// TestDevClient_RefundPayment_UnknownPayMockID_NotFound locks the mock's
// NOT_FOUND strictness: a refund targeting a "pay_mock_*" ID that was never
// created is a provable bug (that charge never went through this mock) and real
// Square answers 404 NOT_FOUND — the mock must surface the structured error,
// never silently proceed.
func TestDevClient_RefundPayment_UnknownPayMockID_NotFound(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
result, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: "pay_mock_never_created",
Amount: 5000,
IdempotencyKey: "refund-unknown-pay-mock",
})
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, "NOT_FOUND", ErrorCode(err))
assert.Equal(t, http.StatusNotFound, ErrorStatusCode(err))
client.mu.RLock()
defer client.mu.RUnlock()
assert.Len(t, client.refunds, 0, "no refund must be stored for an unknown pay_mock_* payment")
assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored for an unknown pay_mock_* payment")
}
// TestDevClient_RefundPayment_OverRefund locks the mock's real Square
// over-refund rejection: refunding more than the remaining balance answers 400
// REFUND_AMOUNT_INVALID. Square returns that SAME code for an already-refunded
// payment, so — exactly like the real client — the mock reconciles: no refund
// recorded yet → the amount is genuinely invalid → ErrRefundDeclined; an
// existing refund (money already moved) → ErrRefundAlreadyProcessed. The
// exact-remaining boundary refund succeeds.
func TestDevClient_RefundPayment_OverRefund(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
t.Run("over_refund_no_prior_refunds_is_declined", func(t *testing.T) {
payment, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-overrefund-fresh",
})
require.NoError(t, err)
result, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: payment.ID, Amount: 12000, IdempotencyKey: "refund-overrefund-fresh",
})
require.Error(t, err)
assert.Nil(t, result)
// The mock builds a squareAPIError with Code REFUND_AMOUNT_INVALID but
// wraps it with %v (exactly like the real client's sentinel wrap), so
// the code is not reachable via ErrorCode(err) — the observable
// contract is the ErrRefundDeclined sentinel.
assert.True(t, errors.Is(err, ErrRefundDeclined), "a genuine over-refund with no prior refunds must be ErrRefundDeclined, got %v", err)
assert.False(t, errors.Is(err, ErrRefundAlreadyProcessed))
})
t.Run("exact_remaining_refund_succeeds", func(t *testing.T) {
payment, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-exact-remaining",
})
require.NoError(t, err)
// A partial refund leaves 7000 remaining.
first, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: payment.ID, Amount: 3000, IdempotencyKey: "refund-partial",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", first.Status)
// Refunding exactly the remaining balance is NOT an over-refund.
second, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: payment.ID, Amount: 7000, IdempotencyKey: "refund-exact-remaining",
})
require.NoError(t, err)
assert.Equal(t, int64(7000), second.Amount)
assert.Equal(t, "COMPLETED", second.Status)
})
t.Run("over_refund_with_existing_refund_is_already_processed", func(t *testing.T) {
payment, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-overrefund-existing",
})
require.NoError(t, err)
// A COMPLETED refund moves money; the remaining balance drops to 7000.
first, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: payment.ID, Amount: 3000, IdempotencyKey: "refund-first-move",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", first.Status)
result, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: payment.ID, Amount: 8000, IdempotencyKey: "refund-overrefund-existing",
})
require.Error(t, err)
assert.Nil(t, result)
// The REFUND_AMOUNT_INVALID squareAPIError is hidden behind the %v
// sentinel wrap (ErrorCode returns ""), so the observable contract is
// the ErrRefundAlreadyProcessed sentinel.
assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "an over-refund on a payment that already has refunds must reconcile to ErrRefundAlreadyProcessed, got %v", err)
assert.False(t, errors.Is(err, ErrRefundDeclined))
})
}
// TestDevClient_RefundPayment_LenientPathForNonMockIDs locks the lenient refund
// path: payment IDs that are NOT "pay_mock_*" (e.g. the DB-fixture
// square_payment_id values like "sqp_..." that sweep tests seed refunds
// against) exist outside the mock's ledger — exactly as they would at real
// Square — so RefundPayment processes them without the NOT_FOUND rejection.
func TestDevClient_RefundPayment_LenientPathForNonMockIDs(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
result, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: "sqp_fixture_123",
Amount: 5000,
IdempotencyKey: "refund-lenient-sqp",
})
require.NoError(t, err, "a non-mock fixture payment ID must take the lenient path, not NOT_FOUND")
assert.Equal(t, "COMPLETED", result.Status)
assert.Equal(t, int64(5000), result.Amount)
assert.Equal(t, "sqp_fixture_123", result.PaymentID)
}
// TestDevClient_CreatePayment_SimulateVerificationRequired locks the mock's SCA
// enforcement: with SimulateVerificationRequired=true, a new-card (cnon:) charge
// without a 3DS/SCA verification token is rejected with a structured 400
// CARD_DECLINED_VERIFICATION_REQUIRED (a definitive payment error the buyer must
// resolve by re-verifying — never retried as-is); a present verification token
// (verify_mock_...) satisfies the gate; and with the toggle off (default) no
// verification is required.
func TestDevClient_CreatePayment_SimulateVerificationRequired(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateVerificationRequired = true
ctx := context.Background()
t.Run("cnon_without_verification_token_is_rejected", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-no-token",
})
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
assert.True(t, IsDefinitivePaymentError(err), "CARD_DECLINED_VERIFICATION_REQUIRED must classify as a definitive payment error")
})
t.Run("verification_token_satisfies_gate", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-with-token",
VerificationToken: "verify_mock_ok",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", result.Status)
})
t.Run("toggle_off_requires_no_verification", func(t *testing.T) {
client.SimulateVerificationRequired = false
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-default-off",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", result.Status, "with SimulateVerificationRequired off (default), no verification token is required")
})
}
// TestDevClient_CreatePayment_SimulateSourceUsed_CnonConsumption locks the
// mock's single-use cnon: nonce simulation on CreatePayment: with
// SimulateSourceUsed enabled, a cnon used in one CreatePayment is rejected with
// CARD_TOKEN_USED on a second CreatePayment under a DIFFERENT idempotency key;
// ccof: (card-on-file) sources are NEVER consumed (they are stored references,
// not single-use nonces); and with the toggle off (default) the same cnon can
// be reused freely.
func TestDevClient_CreatePayment_SimulateSourceUsed_CnonConsumption(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSourceUsed = true
ctx := context.Background()
t.Run("cnon_reuse_rejected_with_card_token_used", func(t *testing.T) {
source := "cnon:single-use-pay"
first, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: source, IdempotencyKey: "consumed-key-1",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", first.Status)
// Second CreatePayment with the SAME cnon under a DIFFERENT key →
// Square's CARD_TOKEN_USED rejection (the CreatePayment code for a used
// source).
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: source, IdempotencyKey: "consumed-key-2",
})
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, "CARD_TOKEN_USED", ErrorCode(err))
assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
assert.Contains(t, client.UsedSources(), source, "the consumed cnon must be reported by UsedSources")
})
t.Run("ccof_sources_are_never_consumed", func(t *testing.T) {
// Create the card with the toggle off so the underlying cnon is not
// consumed; CreatePayment charges the ccof: CardID, which is a stored
// reference rather than a single-use nonce.
client.SimulateSourceUsed = false
card, err := client.CreateCardOnFile(ctx, "user-ccof-never-consumed", "cnon:card-src", "cus_test123")
require.NoError(t, err)
client.SimulateSourceUsed = true
first, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: card.CardID, CustomerID: "cus_test123", IdempotencyKey: "ccof-charge-1",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", first.Status)
second, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: card.CardID, CustomerID: "cus_test123", IdempotencyKey: "ccof-charge-2",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", second.Status, "a ccof: card must be chargeable again under a different key")
assert.NotContains(t, client.UsedSources(), card.CardID, "ccof: sources must never be consumed")
})
t.Run("toggle_off_allows_reuse", func(t *testing.T) {
client.SimulateSourceUsed = false
first, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:reused-pay", IdempotencyKey: "reuse-key-1",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", first.Status)
second, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:reused-pay", IdempotencyKey: "reuse-key-2",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", second.Status, "with SimulateSourceUsed off (default), the same cnon must be reusable")
})
}
+151 -13
View File
@@ -135,7 +135,9 @@ func (c *httpClient) doJSON(ctx context.Context, method, path string, body, targ
respBody = respBody[:maxResponseBody]
}
if resp.StatusCode >= 300 {
var errResp struct{ Errors []SquareError `json:"errors"` }
var errResp struct {
Errors []SquareError `json:"errors"`
}
if json.Unmarshal(respBody, &errResp) == nil && len(errResp.Errors) > 0 {
se := errResp.Errors[0]
msg := fmt.Sprintf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, capBody(se.Detail), se.Field)
@@ -212,6 +214,11 @@ type sqCreatePaymentRequest struct {
TipMoney *sqMoney `json:"tip_money,omitempty"`
VerificationToken string `json:"verification_token,omitempty"`
BuyerEmailAddress string `json:"buyer_email_address,omitempty"`
// CustomerDetails carries customer_initiated so Square classifies the
// charge as cardholder-initiated (SCA applies) rather than defaulting to a
// merchant-initiated classification. Online card entry is always
// cardholder-initiated in this app, so the flag is sent as true when set.
CustomerDetails *CreateCustomerDetails `json:"customer_details,omitempty"`
}
type sqCreatePaymentResponse struct {
@@ -498,6 +505,7 @@ func buildCreatePaymentBody(req CreatePaymentReq, hc *httpClient) sqCreatePaymen
Note: req.Note,
VerificationToken: req.VerificationToken,
BuyerEmailAddress: req.BuyerEmail,
CustomerDetails: req.CustomerDetails,
}
if req.TipMoney != nil {
body.TipMoney = &sqMoney{Amount: *req.TipMoney, Currency: req.Currency}
@@ -614,6 +622,12 @@ func replayPaymentByKeyHTTP(ctx context.Context, snapshotJSON []byte) (*PaymentR
// A replay body missing fields the original charge carried would return
// IDEMPOTENCY_KEY_REUSED for a RETAINED key and strand the row pending forever,
// so the snapshot is never reconstructed from partial row data.
//
// TODO (UNVERIFIED ASSUMPTION): this codebase assumes Square retains
// idempotency keys for ~24 hours (the stale-pending sweeps use a 23h/25h age
// guard on that window). Square's public docs no longer state the exact
// retention window — confirm the current value with Square support and update
// the sweep age guards and this comment when confirmed.
func replayPaymentByKeyHTTPWithClient(ctx context.Context, snapshotJSON []byte, hc *httpClient) (*PaymentResult, error) {
var req CreatePaymentReq
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
@@ -687,7 +701,9 @@ func (e *squareAPIError) Unwrap() error { return e.err }
// error it wraps) is a *squareAPIError — i.e. a structured error parsed from
// Square's error response body. It returns "" for non-Square errors so callers
// can classify charge failures structurally instead of substring-matching the
// message.
// message. This is the exported Code accessor for the error type (a direct
// `(*SquareError).Code()` method is impossible: SquareError already declares a
// field named Code, and Go forbids a method colliding with a struct field).
func ErrorCode(err error) string {
var sqErr *squareAPIError
if errors.As(err, &sqErr) {
@@ -719,7 +735,11 @@ func ErrorStatusCode(err error) int {
}
// ErrorCategory returns the Square error Category carried by err when err (or
// any error it wraps) is a *squareAPIError, and "" otherwise.
// any error it wraps) is a *squareAPIError, and "" otherwise. This is the
// exported Category accessor for the error type (a direct
// `(*SquareError).Category()` method is impossible: SquareError already
// declares a field named Category, and Go forbids a method colliding with a
// struct field).
func ErrorCategory(err error) string {
var sqErr *squareAPIError
if errors.As(err, &sqErr) {
@@ -754,17 +774,65 @@ func IsNotFound(err error) bool {
return strings.Contains(err.Error(), "HTTP 404")
}
// definitivePaymentCodes are Square CreatePayment error codes that mean the
// charge can NEVER succeed as-is. This includes the card decline/expiry codes
// and — critically for SCA — the buyer-verification codes
// (CARD_DECLINED_VERIFICATION_REQUIRED, VERIFICATION_TOKEN_EXPIRED,
// VERIFICATION_TOKEN_INVALID, CVV_VERIFICATION_REQUIRED,
// ADDRESS_VERIFICATION_REQUIRED, MISSING_PIN, MISSING_VERIFICATION_TOKEN):
// those mean the user must re-verify (3DS/SCA) or re-tokenize the card, NOT
// that the same request should be retried. A same-request retry with the same
// source/token can never succeed, so the failure is DEFINITIVE. This map is
// the package-level source of truth; handlers mirror it via
// IsDefinitivePaymentError / square.ErrorCode (the dev mock emits the same
// codes so dev parity holds).
var definitivePaymentCodes = map[string]bool{
"CARD_DECLINED": true,
"CARD_EXPIRED": true,
"INVALID_EXPIRATION": true,
"INVALID_EXPIRATION_DATE": true,
"CARD_NOT_SUPPORTED": true,
"VERIFY_CVV_FAILURE": true,
"AVS_FAILURE": true,
"PAYMENT_CARD_DECLINED": true,
"GENERIC_DECLINE": true,
"INSUFFICIENT_FUNDS": true,
"ADDRESS_VERIFICATION_FAILURE": true,
"TRANSACTION_LIMIT": true,
// SCA / buyer-verification codes — the buyer must re-verify or the card be
// re-tokenized before the charge can succeed; retrying is pointless.
"CARD_DECLINED_VERIFICATION_REQUIRED": true,
"VERIFICATION_TOKEN_EXPIRED": true,
"VERIFICATION_TOKEN_INVALID": true,
"CVV_VERIFICATION_REQUIRED": true,
"ADDRESS_VERIFICATION_REQUIRED": true,
"MISSING_PIN": true,
"MISSING_VERIFICATION_TOKEN": true,
}
// IsDefinitivePaymentError reports whether err is a definitive CreatePayment
// rejection (declined card, expired source, or an SCA/verification failure the
// buyer must resolve) rather than an ambiguous transport/server error. Handlers
// use this to avoid retrying a request that can never succeed as-is.
func IsDefinitivePaymentError(err error) bool {
return definitivePaymentCodes[ErrorCode(err)]
}
// Definitive Square refund rejection codes — the refund was declined and can
// never succeed, so retrying is pointless and the refund record should be
// marked 'failed'. Anything else (transport errors, 5xx) is left ambiguous so
// callers leave the refund 'pending' for a scheduler retry. Codes match
// Square's documented Refunds error list (REFUND_DECLINED, REFUND_AMOUNT_INVALID,
// PAYMENT_NOT_REFUNDABLE); note PAYMENT_ALREADY_REFUNDED and
// REFUND_ALREADY_PENDING are intentionally absent — money is in flight or has
// moved, so they map to ErrRefundAlreadyProcessed instead of ErrRefundDeclined.
// PAYMENT_NOT_REFUNDABLE). REFUND_AMOUNT_INVALID is special: Square returns it
// BOTH for a genuinely invalid refund amount AND for an already-refunded
// payment, so refundPaymentHTTP reconciles it via PaymentWasRefunded before
// classifying (an existing refund → ErrRefundAlreadyProcessed, otherwise
// ErrRefundDeclined). REFUND_ALREADY_PENDING maps to ErrRefundAlreadyProcessed
// (money in flight); PAYMENT_ALREADY_REFUNDED is no longer emitted by Square
// but is kept as a defensive fallback for the same outcome.
var definitiveRefundCodes = map[string]bool{
"REFUND_DECLINED": true,
"REFUND_AMOUNT_INVALID": true,
"REFUND_DECLINED": true,
"REFUND_AMOUNT_INVALID": true,
"PAYMENT_NOT_REFUNDABLE": true,
}
@@ -782,17 +850,87 @@ func refundPaymentHTTPWithClient(ctx context.Context, req RefundPaymentReq, hc *
var resp sqRefundPaymentResponse
if err := hc.doJSON(ctx, http.MethodPost, "/v2/refunds", body, &resp); err != nil {
var sqErr *squareAPIError
if errors.As(err, &sqErr) && definitiveRefundCodes[sqErr.Code] {
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
}
if errors.As(err, &sqErr) && (sqErr.Code == "PAYMENT_ALREADY_REFUNDED" || sqErr.Code == "REFUND_ALREADY_PENDING") {
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
if errors.As(err, &sqErr) {
switch sqErr.Code {
case "PAYMENT_ALREADY_REFUNDED", "REFUND_ALREADY_PENDING":
// Money is in flight or has already moved at Square — never
// mark 'failed' (that would let the guard over-refund).
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
case "REFUND_AMOUNT_INVALID":
// Square returns REFUND_AMOUNT_INVALID both for a genuinely
// invalid refund amount AND for an already-refunded payment
// (Square no longer emits PAYMENT_ALREADY_REFUNDED). Reconcile
// against the refund list to tell the two apart: money already
// moved → ErrRefundAlreadyProcessed (resolve 'completed');
// nothing moved → ErrRefundDeclined (mark 'failed', never
// retry). The reconciliation is amount-aware: only an EXACT-
// amount COMPLETED refund proves THIS requested amount already
// moved. A smaller partial refund does NOT cover the requested
// amount — resolving the row 'completed' against a partial
// refund would claim the full amount was returned when only
// part of it was, permanently blocking the remaining refund
// (the over-refund guard excludes completed rows). If the
// reconciliation itself fails, return the error unwrapped so
// the caller keeps the refund pending rather than making a
// money decision on partial data.
exactRefund, rErr := paymentRefundedExactlyWithClient(ctx, req.PaymentID, req.Amount, hc)
if rErr != nil {
return nil, rErr
}
if exactRefund {
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
}
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
}
if definitiveRefundCodes[sqErr.Code] {
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
}
}
return nil, err
}
return refundFromSquare(&resp.Refund), nil
}
// PaymentWasRefunded reports whether Square holds any refund for the payment
// (status COMPLETED, APPROVED, or PENDING). It is the reconciliation source for
// deciding whether a REFUND_AMOUNT_INVALID rejection means "already refunded"
// (money has already moved) vs "amount invalid" (nothing happened).
func PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
return paymentWasRefundedWithClient(ctx, paymentID, newHTTPClient())
}
func paymentWasRefundedWithClient(ctx context.Context, paymentID string, hc *httpClient) (bool, error) {
refunds, err := listRefundsHTTPWithClient(ctx, paymentID, time.Time{}, hc)
if err != nil {
return false, err
}
for _, r := range refunds {
switch r.Status {
case "COMPLETED", "APPROVED", "PENDING":
return true, nil
}
}
return false, nil
}
// paymentRefundedExactlyWithClient reports whether Square holds a COMPLETED
// refund for the EXACT amount requested. Unlike paymentWasRefundedWithClient
// (any refund counts), an exact-match is required so a REFUND_AMOUNT_INVALID
// rejection can only resolve to "already refunded" when THIS requested amount
// provably moved — a partial refund does not cover it.
func paymentRefundedExactlyWithClient(ctx context.Context, paymentID string, amount int64, hc *httpClient) (bool, error) {
refunds, err := listRefundsHTTPWithClient(ctx, paymentID, time.Time{}, hc)
if err != nil {
return false, err
}
for _, r := range refunds {
if r.Status == "COMPLETED" && r.Amount == amount {
return true, nil
}
}
return false, nil
}
func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
return listRefundsHTTPWithClient(ctx, paymentID, beginTime, newHTTPClient())
}
@@ -518,15 +518,21 @@ func TestCreatePaymentHTTP_TipMoneyAbsentWhenNil(t *testing.T) {
// PAYMENT_NOT_REFUNDABLE) map to ErrRefundDeclined, the money-in-flight codes
// (PAYMENT_ALREADY_REFUNDED, REFUND_ALREADY_PENDING) map to
// ErrRefundAlreadyProcessed, and ambiguous errors pass through unwrapped.
// REFUND_AMOUNT_INVALID is special: Square returns it BOTH for a genuinely
// invalid amount and for an already-refunded payment, so the client reconciles
// via PaymentWasRefunded (GET /v2/refunds) — no existing refund → declined,
// an existing COMPLETED/APPROVED/PENDING refund → already processed.
func TestRefundPaymentHTTP_CodeClassification(t *testing.T) {
cases := []struct {
name string
code string
wantErrIs error // nil = no sentinel expected
refundList string // GET /v2/refunds body served to the PaymentWasRefunded reconciliation
wantErrIs error // nil = no sentinel expected
wantErrNil bool
}{
{name: "refund_declined", code: "REFUND_DECLINED", wantErrIs: ErrRefundDeclined},
{name: "amount_invalid", code: "REFUND_AMOUNT_INVALID", wantErrIs: ErrRefundDeclined},
{name: "amount_invalid_no_refund_reconciles_to_declined", code: "REFUND_AMOUNT_INVALID", refundList: `{"refunds":[]}`, wantErrIs: ErrRefundDeclined},
{name: "amount_invalid_existing_refund_reconciles_to_already_processed", code: "REFUND_AMOUNT_INVALID", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","location_id":"loc","reason":"cancellation","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundAlreadyProcessed},
{name: "payment_not_refundable", code: "PAYMENT_NOT_REFUNDABLE", wantErrIs: ErrRefundDeclined},
{name: "already_refunded", code: "PAYMENT_ALREADY_REFUNDED", wantErrIs: ErrRefundAlreadyProcessed},
{name: "already_pending", code: "REFUND_ALREADY_PENDING", wantErrIs: ErrRefundAlreadyProcessed},
@@ -537,6 +543,17 @@ func TestRefundPaymentHTTP_CodeClassification(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet {
// The PaymentWasRefunded reconciliation call (GET /v2/refunds)
// must be answered with the configured refund list so the
// REFUND_AMOUNT_INVALID branch runs end to end.
body := tc.refundList
if body == "" {
body = `{"refunds":[]}`
}
_, _ = w.Write([]byte(body))
return
}
w.WriteHeader(http.StatusBadRequest)
if tc.code == "" {
_, _ = w.Write([]byte("plain text failure"))
@@ -1783,3 +1800,232 @@ func TestDoJSON_CardProcessingNotEnabled403(t *testing.T) {
t.Errorf("expected ErrorCategory PAYMENT_METHOD_ERROR, got %q", got)
}
}
// TestCreatePaymentHTTP_CustomerDetailsWireShape verifies the customer_details
// wiring on POST /v2/payments: when CustomerDetails is set
// (CustomerInitiated=true — online card entry is always cardholder-initiated),
// the request body carries customer_details.customer_initiated=true; when nil,
// the field is omitted entirely (Square's default classification applies).
func TestCreatePaymentHTTP_CustomerDetailsWireShape(t *testing.T) {
t.Run("customer_initiated_true_is_sent", func(t *testing.T) {
var captured map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Errorf("failed to decode request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"payment":{"id":"pay_cd","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242"},"entry_method":"KEYED"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
_, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-cd",
CustomerDetails: &CreateCustomerDetails{CustomerInitiated: true},
}, hc)
if err != nil {
t.Fatalf("createPaymentHTTP failed: %v", err)
}
cd, ok := captured["customer_details"].(map[string]any)
if !ok {
t.Fatalf("expected customer_details object, got %v", captured["customer_details"])
}
if cd["customer_initiated"] != true {
t.Errorf("expected customer_details.customer_initiated=true, got %v", cd["customer_initiated"])
}
})
t.Run("nil_customer_details_is_omitted", func(t *testing.T) {
var captured map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Errorf("failed to decode request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"payment":{"id":"pay_cd2","status":"COMPLETED","total_money":{"amount":1000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"MASTERCARD","last_4":"4444"},"entry_method":"KEYED"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
_, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
Amount: 1000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-cd-nil",
}, hc)
if err != nil {
t.Fatalf("createPaymentHTTP failed: %v", err)
}
if _, present := captured["customer_details"]; present {
t.Errorf("expected customer_details to be ABSENT when CustomerDetails is nil, got %v", captured["customer_details"])
}
})
}
// TestPaymentWasRefunded verifies the exported PaymentWasRefunded reconciliation
// helper: an existing COMPLETED/APPROVED/PENDING refund for the payment means
// money has moved (true), a FAILED/rejected refund or an empty list means
// nothing moved (false), and a server error propagates as an error.
func TestPaymentWasRefunded(t *testing.T) {
cases := []struct {
name string
refundList string
statusCode int
want bool
wantErr bool
}{
{name: "completed_refund_means_money_moved", refundList: `{"refunds":[{"id":"ref_c","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true},
{name: "approved_refund_means_money_moved", refundList: `{"refunds":[{"id":"ref_a","status":"APPROVED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true},
{name: "pending_refund_means_money_in_flight", refundList: `{"refunds":[{"id":"ref_p","status":"PENDING","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true},
{name: "failed_refund_means_nothing_moved", refundList: `{"refunds":[{"id":"ref_f","status":"FAILED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: false},
{name: "rejected_refund_means_nothing_moved", refundList: `{"refunds":[{"id":"ref_r","status":"REJECTED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: false},
{name: "empty_list_means_nothing_moved", refundList: `{"refunds":[]}`, want: false},
{name: "other_payment_refund_is_filtered_out", refundList: `{"refunds":[{"id":"ref_o","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_other","created_at":"2026-07-31T00:00:00Z"}]}`, want: false},
{name: "server_error_propagates", refundList: `{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"INTERNAL_SERVER_ERROR","detail":"boom"}]}`, statusCode: http.StatusInternalServerError, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/v2/refunds" {
t.Errorf("expected /v2/refunds, got %s", r.URL.Path)
}
if !strings.Contains(r.URL.RawQuery, "begin_time=") {
t.Errorf("expected begin_time in query, got %q", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
if tc.statusCode != 0 {
w.WriteHeader(tc.statusCode)
}
_, _ = w.Write([]byte(tc.refundList))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
got, err := paymentWasRefundedWithClient(context.Background(), "pay_1", hc)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got wasRefunded=%v", got)
}
return
}
if err != nil {
t.Fatalf("paymentWasRefunded failed: %v", err)
}
if got != tc.want {
t.Errorf("paymentWasRefunded = %v, want %v", got, tc.want)
}
})
}
}
// TestRefundPaymentHTTP_RefundAmountInvalidReconciliation locks the
// REFUND_AMOUNT_INVALID reconciliation end-to-end: Square returns that code BOTH
// for a genuinely invalid refund amount AND for an already-refunded payment, so
// refundPaymentHTTP re-checks the refund list (GET /v2/refunds) before
// classifying — an existing COMPLETED refund → ErrRefundAlreadyProcessed (money
// already moved), an empty list → ErrRefundDeclined (mark failed, never retry).
func TestRefundPaymentHTTP_RefundAmountInvalidReconciliation(t *testing.T) {
cases := []struct {
name string
refundList string
wantErrIs error
}{
{name: "existing_exact_amount_completed_refund_reconciles_to_already_processed", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":5000,"currency":"GBP"},"payment_id":"pay_rec","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundAlreadyProcessed},
{name: "partial_refund_does_not_cover_requested_amount_reconciles_to_declined", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_rec","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundDeclined},
{name: "no_refunds_reconciles_to_declined", refundList: `{"refunds":[]}`, wantErrIs: ErrRefundDeclined},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var reconcileGETs int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet {
// The PaymentWasRefunded reconciliation call must hit
// GET /v2/refunds for the payment.
reconcileGETs++
if r.URL.Path != "/v2/refunds" {
t.Errorf("expected reconciliation GET /v2/refunds, got %s", r.URL.Path)
}
if !strings.Contains(r.URL.RawQuery, "begin_time=") {
t.Errorf("expected begin_time in reconciliation query, got %q", r.URL.RawQuery)
}
_, _ = w.Write([]byte(tc.refundList))
return
}
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"REFUND_AMOUNT_INVALID","detail":"The refunded amount is more than the remaining balance"}]}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
_, err := refundPaymentHTTPWithClient(context.Background(), RefundPaymentReq{
PaymentID: "pay_rec", Amount: 5000, IdempotencyKey: "ik-rec",
}, hc)
if err == nil {
t.Fatal("expected REFUND_AMOUNT_INVALID rejection error")
}
if reconcileGETs == 0 {
t.Error("expected the client to reconcile against GET /v2/refunds before classifying")
}
if !errors.Is(err, tc.wantErrIs) {
t.Errorf("expected errors.Is(%v), got %v", tc.wantErrIs, err)
}
})
}
}
// TestSCACodes_ClassifyAsDefinitivePaymentErrors locks the SCA / buyer-verification
// classification: the seven Square verification codes all mean the buyer must
// re-verify (3DS/SCA) or re-tokenize the card, NOT that the same request should
// be retried — so each must classify as a definitive payment error via
// IsDefinitivePaymentError and surface its code through ErrorCode.
func TestSCACodes_ClassifyAsDefinitivePaymentErrors(t *testing.T) {
scaCodes := []string{
"CARD_DECLINED_VERIFICATION_REQUIRED",
"VERIFICATION_TOKEN_EXPIRED",
"VERIFICATION_TOKEN_INVALID",
"CVV_VERIFICATION_REQUIRED",
"ADDRESS_VERIFICATION_REQUIRED",
"MISSING_PIN",
"MISSING_VERIFICATION_TOKEN",
}
for _, code := range scaCodes {
t.Run(code, func(t *testing.T) {
err := &squareAPIError{
Code: code, Category: "PAYMENT_METHOD_ERROR", StatusCode: http.StatusBadRequest,
err: errors.New("square: " + code),
}
if !IsDefinitivePaymentError(err) {
t.Errorf("IsDefinitivePaymentError(%s) must be true — SCA codes are definitive", code)
}
if got := ErrorCode(err); got != code {
t.Errorf("expected ErrorCode %s, got %q", code, got)
}
// Handlers wrap the client error before classifying — the accessor
// must see through the wrap.
if !IsDefinitivePaymentError(fmt.Errorf("wrap: %w", err)) {
t.Errorf("IsDefinitivePaymentError must work through a wrapped error for %s", code)
}
})
}
}
// TestInvalidRequestError_IsCategoryNotCode locks the category/code distinction:
// INVALID_REQUEST_ERROR is a Square error CATEGORY, never an error CODE — so
// ErrorCategory surfaces it while ErrorCode must NOT (ErrorCode returns "" for
// a code-less squareAPIError), and it must never classify as definitive.
func TestInvalidRequestError_IsCategoryNotCode(t *testing.T) {
err := &squareAPIError{
Category: "INVALID_REQUEST_ERROR", StatusCode: http.StatusBadRequest,
err: errors.New("square: invalid request"),
}
if got := ErrorCategory(err); got != "INVALID_REQUEST_ERROR" {
t.Errorf("expected ErrorCategory INVALID_REQUEST_ERROR, got %q", got)
}
if got := ErrorCode(err); got != "" {
t.Errorf("expected ErrorCode \"\" for a category-only error, got %q", got)
}
if IsDefinitivePaymentError(err) {
t.Error("INVALID_REQUEST_ERROR is a category, never a definitive payment code")
}
}
+45 -4
View File
@@ -15,10 +15,16 @@ import (
// money has already moved, so it maps to ErrRefundAlreadyProcessed instead.
var ErrRefundDeclined = errors.New("square: refund declined")
// ErrRefundAlreadyProcessed is returned by RefundPayment when Square reports
// PAYMENT_ALREADY_REFUNDED — the payment is already fully refunded at Square,
// so the money has already moved. Callers resolve the refund record to
// 'completed' rather than 'failed' (which would let the guard over-refund).
// ErrRefundAlreadyProcessed is returned by RefundPayment when the money has
// already moved at Square — either Square reports REFUND_ALREADY_PENDING (a
// refund for this payment is in flight) or the reconciliation performed on a
// REFUND_AMOUNT_INVALID rejection finds an existing COMPLETED/APPROVED/PENDING
// refund for the payment (PaymentWasRefunded). Note: Square no longer returns
// PAYMENT_ALREADY_REFUNDED for an already-refunded payment — it returns
// REFUND_AMOUNT_INVALID, which the client now reconciles via PaymentWasRefunded
// (the PAYMENT_ALREADY_REFUNDED mapping is kept only as a defensive fallback).
// Callers resolve the refund record to 'completed' rather than 'failed' (which
// would let the guard over-refund).
var ErrRefundAlreadyProcessed = errors.New("square: refund already processed")
// ErrReplayKeyNotRetained is returned by ReplayPaymentByKey when Square proves
@@ -53,6 +59,27 @@ type CreatePaymentReq struct {
LocationID string // Square location ID (optional; defaults to main location)
VerificationToken string // 3DS / SCA verification token from buyer verification
BuyerEmail string // buyer email for receipt
// CustomerDetails classifies the charge as cardholder-initiated (true) or
// merchant-initiated (false) for Square's SCA / liability-shift logic,
// wired through as customer_details.customer_initiated on POST /v2/payments.
// Online card entry in this app is always cardholder-initiated (the buyer
// is present, typing their card details), so the flag is true when set;
// nil omits the field from the wire body (Square's default classification).
CustomerDetails *CreateCustomerDetails
}
// CreateCustomerDetails maps to Square's customer_details object on
// CreatePayment. customer_initiated tells Square whether the cardholder
// initiated the transaction (true — buyer present, e.g. online card entry) or
// the merchant initiated it on the cardholder's behalf (false — e.g. a
// subscription/recurring charge). Square uses it to classify the charge for
// SCA (Strong Customer Authentication) and card-scheme liability-shift rules:
// omitting it can silently change how the transaction is classified (a missing
// customer_initiated can be read as merchant-initiated, skipping the SCA that
// a cardholder-present charge must undergo).
// Reference: https://developer.squareup.com/reference/square/objects/Payment
type CreateCustomerDetails struct {
CustomerInitiated bool `json:"customer_initiated"`
}
// CreateCheckoutReq maps to Square's CreateTerminalCheckout endpoint
@@ -189,7 +216,21 @@ type SquareClient interface {
CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error)
GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error)
RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error)
// PaymentWasRefunded reports whether Square holds any refund for the
// payment (status COMPLETED, APPROVED, or PENDING). It is the
// reconciliation source for deciding whether a REFUND_AMOUNT_INVALID
// rejection means "already refunded" (money has already moved) vs "amount
// invalid" (nothing happened). On the interface (not just the package
// function) so handlers can reconcile through the injected client — a
// package-level call constructs a real HTTP client even in dev/mock
// builds, making the dev path dead code and untestable.
PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error)
CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error)
// GetCardsOnFile returns the enabled cards on file for a user. TEST-ONLY:
// it has ZERO production callers (grep across the repo confirms the only
// users are this package's tests) and is kept on the interface solely so
// the dev mock's List Cards parity tests can exercise it. Do not add
// production callers without also re-examining the interface surface.
GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error)
DeleteCardOnFile(ctx context.Context, cardID string) error
+91 -7
View File
@@ -8,6 +8,7 @@ import (
"crussell/internal/logutil"
"crussell/internal/s3"
"crussell/internal/square"
"encoding/base64"
"encoding/json"
"fmt"
"log"
@@ -180,10 +181,23 @@ func initSquare() {
// case-insensitive) or SQUARE_ENVIRONMENT explicitly selects the dev/mock
// stack. Warn loudly when a non-dev env (empty/unknown — a likely
// misconfiguration) leaves the gate disabled, so saved-card charges can
// never silently ship without the PSD2 SCA stand-in.
// never silently ship without this merchant-level authorization gate (an
// additional fraud control; NOT PSD2 SCA — Square buyer verification is the
// SCA mechanism, wired for new-card charges).
enforced := payments.NewPaymentService().TwoFactorEnforced()
if enforced {
log.Printf("WARNING: 2FA codes are delivered in PLAINTEXT via the server log ([2FA] prefix) — anyone with log read access can defeat the 2FA gate. Restrict backend log access and relay codes out-of-band; this loose-fake delivery must be replaced by email/SMS (P6) before launch.")
// Delivery is build-dependent (handlers/user/twofa_dev.go /
// twofa_prod.go): dev/test builds ALWAYS write the plaintext code to
// the [2FA] log line; production builds write it ONLY when the operator
// explicitly opts in with TWO_FACTOR_ALLOW_LOG_DELIVERY=true and refuse
// issuance otherwise. Warn accurately per case so the operator is never
// misled into thinking codes are reaching users when issuance is
// actually failing closed.
if os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" {
log.Printf("WARNING: 2FA codes are delivered in PLAINTEXT via the server log ([2FA] prefix) — anyone with backend log access can defeat the 2FA gate. Restrict log access and relay codes out-of-band; replace this loose-fake delivery with email/SMS (P6) before launch.")
} else {
log.Printf("WARNING: 2FA enforcement is ON but TWO_FACTOR_ALLOW_LOG_DELIVERY is unset: in a production build there is NO code-delivery channel (email/SMS is not wired — P6), so 2FA code issuance FAILS CLOSED and no user can complete setup or disable. Every enforced saved-card online payment for a user without 2FA will 403 with no way to enable it. Set TWO_FACTOR_ALLOW_LOG_DELIVERY=true to opt into the insecure [2FA] log-delivery channel (plaintext codes in the server log — restrict log access), or wire email/SMS (P6).")
}
}
if !enforced && !payments.IsExplicitDevOrMockEnv() {
log.Printf("WARNING: 2FA enforcement is OFF (REQUIRE_2FA=%q) with SQUARE_ENVIRONMENT=%q (not an explicit mock/dev value). Online saved-card payments will NOT require 2FA.", os.Getenv("REQUIRE_2FA"), env)
@@ -196,6 +210,59 @@ func initSquare() {
if enforced && env != "sandbox" && env != "production" {
log.Printf("WARNING: 2FA enforcement is ON but SQUARE_ENVIRONMENT=%q is empty/unknown — the Square client is the dev mock while the 2FA gate stays enforced (fail-closed). Online saved-card payments will 403 until users enable 2FA; set SQUARE_ENVIRONMENT to a dev value (mock/dev/development/test) to lift the gate, or to sandbox/production for the real API.", env)
}
checkSnapshotEncKey()
checkProxyRateLimitConfig()
}
// checkSnapshotEncKey validates SNAPSHOT_ENC_KEY at startup in non-mock
// deployments. charge_helpers.snapshotEncKey() (handlers/payments) parses the
// key on every call and silently falls back to storing square_request_snapshot
// rows PLAINTEXT (buyer PII: email + ccof card tokens) with a one-time CRITICAL
// log. This startup check makes the misconfiguration unmissable at boot: the
// key must be present and decode to exactly 32 bytes (AES-256). Money-safety
// first — it warns CRITICAL but does NOT fail the process (a failing startup
// would strand pending replayable snapshots), matching the runtime fallback.
func checkSnapshotEncKey() {
if payments.IsExplicitDevOrMockEnv() {
return
}
raw := strings.TrimSpace(os.Getenv("SNAPSHOT_ENC_KEY"))
switch {
case raw == "":
log.Printf("CRITICAL: SNAPSHOT_ENC_KEY is not set with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows (buyer PII: email + ccof card tokens) will be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", os.Getenv("SQUARE_ENVIRONMENT"))
return
default:
decoded, err := base64.StdEncoding.DecodeString(raw)
switch {
case err != nil:
log.Printf("CRITICAL: SNAPSHOT_ENC_KEY is not valid base64 (%v) with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows will be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", err, os.Getenv("SQUARE_ENVIRONMENT"))
case len(decoded) != 32:
log.Printf("CRITICAL: SNAPSHOT_ENC_KEY must decode to exactly 32 bytes for AES-256 (got %d) with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows will be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", len(decoded), os.Getenv("SQUARE_ENVIRONMENT"))
}
}
}
// checkProxyRateLimitConfig warns when per-IP rate limiting collapses to a
// single GLOBAL budget: TRUST_PROXY_HEADERS is unset/false (the shipped
// default — .env.example ships false, compose.yml never sets it) while
// SQUARE_ENVIRONMENT selects a real deployment (sandbox/production/empty).
// Behind a trusted proxy (the nginx in compose.yml), every request's
// RemoteAddr is the proxy's IP, so clientIP() returns the SAME key for all
// users and any one client can exhaust the shared per-IP budget — permanently
// 429ing the whole surface for everyone. The user+IP 2FA limiter is immune
// (each authenticated account gets its own bucket), but every other per-IP
// limiter still collapses. Set TRUST_PROXY_HEADERS=true when a trusted proxy
// (nginx and/or the Cloudflare edge) sits in front and overwrites X-Real-IP /
// CF-Connecting-IP with the real client IP.
func checkProxyRateLimitConfig() {
if payments.IsExplicitDevOrMockEnv() {
return
}
if mw.TrustProxyHeaders() {
return
}
log.Printf("WARNING: TRUST_PROXY_HEADERS is unset/false with SQUARE_ENVIRONMENT=%q (not a dev/mock value) — behind a trusted proxy (e.g. the nginx in compose.yml) every per-IP rate-limit key uses the proxy's RemoteAddr, collapsing all rate limiters to ONE global budget that any single client can exhaust for everyone. Set TRUST_PROXY_HEADERS=true when a trusted proxy sits in front and overwrites X-Real-IP/CF-Connecting-IP; keep it false only when the backend is origin-exposed.", os.Getenv("SQUARE_ENVIRONMENT"))
}
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
@@ -496,14 +563,31 @@ func main() {
r.Put("/user/change-password", user.ChangePasswordHandler)
r.Get("/user/notification-preferences", user.GetNotificationPreferencesHandler)
r.Put("/user/notification-preferences", user.UpdateNotificationPreferencesHandler)
// 2FA settings (loose-fake PSD2 SCA gate for online card payments).
// 2FA settings — merchant-level authorization gate on saved-card
// payments; NOT PSD2 SCA (Square buyer verification is the SCA
// mechanism, wired for new-card charges); kept as an additional
// fraud control until Square verification is wired for saved-card
// charges.
// RequireNonGuest: any logged-in user who could save cards must be
// able to reach these, not just verified accounts.
r.With(mw.RequireNonGuest).Get("/user/2fa/status", user.GetTwoFAStatusHandler)
r.With(mw.RequireNonGuest).Post("/user/2fa/setup", user.SetupTwoFAHandler)
r.With(mw.RequireNonGuest).Post("/user/2fa/verify", user.VerifyTwoFAHandler)
r.With(mw.RequireNonGuest).Post("/user/2fa/disable", user.DisableTwoFAHandler)
r.With(mw.RequireNonGuest).Post("/user/2fa/disable/code", user.SendDisableCodeHandler)
// The code-issuing/verifying endpoints get a dedicated per-user+IP
// limiter (10/min) on top of the group's generic 120/min limiter:
// the 6-digit codes live in a 1M space, so a single user must not be
// able to hammer setup/verify/disable faster than the per-user
// 5-attempt lockout can trip. The key combines the authenticated
// userID with the client IP: even behind a proxy that does not set
// TRUST_PROXY_HEADERS=true (so every request's RemoteAddr is the
// proxy's IP), the budget stays per-account — one account holder can
// never exhaust a shared GLOBAL bucket that 429s the entire 2FA
// surface (setup/verify/disable, and thus saved-card payments) for
// everyone. One shared limiter for all four so the whole 2FA surface
// counts against a single per-user budget.
twoFALimiter := mw.RateLimitByUserAndIP(10, time.Minute)
r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/setup", user.SetupTwoFAHandler)
r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/verify", user.VerifyTwoFAHandler)
r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/disable", user.DisableTwoFAHandler)
r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/disable/code", user.SendDisableCodeHandler)
r.Delete("/user/account", user.DeleteAccountHandler)
r.Get("/user/gdpr-export", user.GetGDPRExportHandler)
r.Get("/user/loyalty", user.GetLoyaltyHandler)
+79
View File
@@ -183,3 +183,82 @@ func TestCORSPreflight_RejectsUnlistedOrigin(t *testing.T) {
t.Errorf("expected no Access-Control-Allow-Origin on preflight for unlisted origin, got %q", got)
}
}
// ============================================================
// isWeakJWTSecret — fail-closed JWT signing-key guard (batch-1 fix)
// ============================================================
// TestIsWeakJWTSecret_WeakSecrets verifies known placeholder/example values
// (publicly documented in .env.example, READMEs, or attack tooling) are always
// rejected even when they clear the 32-character minimum.
func TestIsWeakJWTSecret_WeakSecrets(t *testing.T) {
weak := []string{
"password",
"secret",
"changeme",
"change-me",
"changethis",
"CHANGE_ME",
"your-secret-key",
"your-secret",
"jwt-secret",
"jwt-secret-key",
"default-secret",
"my-secret",
"test-secret",
"test-secret-key",
"super-secret",
"a-very-secret-key-that-should-be-in-env", // 39 chars — length passes, list blocks
"test-secret-key-for-testing-only", // 32 chars — length passes, list blocks
}
for _, s := range weak {
if !isWeakJWTSecret(s) {
t.Errorf("expected known weak secret %q to be rejected", s)
}
}
}
// TestIsWeakJWTSecret_ShortSecrets verifies anything under 32 bytes is weak:
// HS256 keys must be at least 256 bits to be meaningful.
func TestIsWeakJWTSecret_ShortSecrets(t *testing.T) {
short := []string{
"",
"a",
"short",
"0123456789",
"0123456789012345678901234567890", // 31 chars < 32
}
for _, s := range short {
if !isWeakJWTSecret(s) {
t.Errorf("expected %q (%d chars) to be weak", s, len(s))
}
}
}
// TestIsWeakJWTSecret_StrongRandomSecret verifies a strong random 32+ byte
// secret (not a known placeholder) is accepted.
func TestIsWeakJWTSecret_StrongRandomSecret(t *testing.T) {
strong := "b8f0c2a1d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2"
if isWeakJWTSecret(strong) {
t.Errorf("expected a strong 50-char random secret to be accepted")
}
exactly32 := "9f2kL7mP4qR8sT1uV5wX3yZ6aB0cD2eG" // exactly 32 chars
if isWeakJWTSecret(exactly32) {
t.Errorf("expected an exactly-32-char random secret to be accepted")
}
}
// TestIsWeakJWTSecret_CaseAndWhitespaceInsensitive verifies the check lowercases
// and trims before comparing, so padded/case-varied placeholders cannot slip
// through.
func TestIsWeakJWTSecret_CaseAndWhitespaceInsensitive(t *testing.T) {
for _, s := range []string{"PASSWORD", " SeCrEt ", "\tchangeme\n", " super-secret "} {
if !isWeakJWTSecret(s) {
t.Errorf("expected %q to be weak after case/whitespace normalisation", s)
}
}
paddedStrong := " xY9Q2mR7vB4nK8wP1tL5sD3fG6hJ0cVnW4 "
if isWeakJWTSecret(paddedStrong) {
t.Errorf("expected a whitespace-padded strong secret to be accepted")
}
}
+29
View File
@@ -136,6 +136,35 @@ func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler
}
}
// RateLimitByUserAndIP limits requests per authenticated user + client IP. The
// key combines the authenticated userID (mw.UserIDKey, injected by RequireAuth)
// with the derived client IP, so a per-IP budget can never collapse into a
// single GLOBAL bucket when the backend sits behind a proxy that does not set
// TRUST_PROXY_HEADERS=true: without that flag clientIP() keys every request on
// RemoteAddr = the proxy's IP, so one account holder could otherwise exhaust
// the shared budget and permanently 429 the whole surface for everyone. With
// the userID in the key each account gets its own independent budget per IP.
// When no userID is present (unauthenticated path) the key falls back to
// clientIP alone, matching RateLimit's behaviour.
func RateLimitByUserAndIP(limit int, window time.Duration) func(http.Handler) http.Handler {
limiter := NewRateLimiter(limit, window)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := clientIP(r)
if userID, ok := GetUserID(r.Context()); ok && userID != "" {
key = userID + "|" + key
}
if !limiter.Allow(key) {
RespondJSON(w, http.StatusTooManyRequests, map[string]string{"error": "Rate limit exceeded"})
return
}
next.ServeHTTP(w, r)
})
}
}
// clientIP derives the per-client rate-limit key. Priority:
// 1. CF-Connecting-IP header — honored ONLY when TRUST_PROXY_HEADERS=true
// (see trustProxyHeaders). A trusted edge (Cloudflare, or nginx whose
+12
View File
@@ -34,3 +34,15 @@ func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler
})
}
}
// RateLimitByUserAndIP is the dev-build no-op twin of the production
// user+IP-keyed limiter in ratelimit.go: pure dev builds pass everything
// through so the dev seed's bursty traffic is never throttled (tests build
// with the `test` tag and get the real implementation).
func RateLimitByUserAndIP(limit int, window time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}
}
+48
View File
@@ -0,0 +1,48 @@
//go:build dev && !test
package mw
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// The dev build (ratelimit_dev.go) swaps the real rate limiters for no-op
// passthroughs so the dev seed (11+ logins, bursty seeding traffic) never
// trips a limiter. These tests pin that contract for a pure dev build
// (-tags dev, no test tag): every request passes through untouched.
func TestDevBuild_RateLimit_PassesEverythingThrough(t *testing.T) {
handler := RateLimit(2, time.Minute)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
for i := 0; i < 25; i++ {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("dev no-op RateLimit blocked request %d with %d", i+1, w.Code)
}
}
}
func TestDevBuild_ProgressiveRateLimit_PassesEverythingThrough(t *testing.T) {
handler := ProgressiveRateLimit(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
for i := 0; i < 100; i++ {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("dev no-op ProgressiveRateLimit blocked request %d with %d", i+1, w.Code)
}
if got := w.Header().Get("X-RateLimit-Delay"); got != "" {
t.Fatalf("dev no-op ProgressiveRateLimit set X-RateLimit-Delay on request %d: %q", i+1, got)
}
}
}
+126
View File
@@ -0,0 +1,126 @@
//go:build test
package mw
import (
"testing"
"time"
"crussell/clock"
)
// ============================================================
// TrustProxyHeaders — exported accessor for the init-time
// TRUST_PROXY_HEADERS capture (batch-1 fix)
// ============================================================
// TestTrustProxyHeaders_ReflectsPackageVar pins the accessor contract: it
// reports the current value of the package-level trustProxyHeaders flag. The
// flag is captured once at init from TRUST_PROXY_HEADERS (ratelimit_shared.go)
// and cannot be re-read after process start, so the true/false paths are
// exercised by toggling the var directly (the test lives in package mw).
func TestTrustProxyHeaders_ReflectsPackageVar(t *testing.T) {
saved := trustProxyHeaders
t.Cleanup(func() { trustProxyHeaders = saved })
trustProxyHeaders = false
if TrustProxyHeaders() {
t.Error("expected TrustProxyHeaders() == false when trustProxyHeaders is false")
}
trustProxyHeaders = true
if !TrustProxyHeaders() {
t.Error("expected TrustProxyHeaders() == true when trustProxyHeaders is true")
}
}
// TestTrustProxyHeaders_DefaultIsFalse verifies the documented default: when
// the flag has never been flipped to true (the init-time no-TRUST_PROXY_HEADERS
// path resolves to false), the accessor reports false — an origin-exposed
// backend must not trust proxy headers by default.
func TestTrustProxyHeaders_DefaultIsFalse(t *testing.T) {
saved := trustProxyHeaders
t.Cleanup(func() { trustProxyHeaders = saved })
trustProxyHeaders = false
if TrustProxyHeaders() {
t.Error("expected TrustProxyHeaders() to default to false")
}
}
// ============================================================
// RateLimiter.Allow — fixed-window per-key semantics (batch-1
// fix regression: per-key bucketing)
// ============================================================
// TestRateLimiter_Allow_RespectsLimit verifies exactly `limit` allowances per
// key within the window, then refusals.
func TestRateLimiter_Allow_RespectsLimit(t *testing.T) {
rl := NewRateLimiter(2, time.Minute)
if !rl.Allow("key-a") {
t.Fatal("expected first request to be allowed")
}
if !rl.Allow("key-a") {
t.Fatal("expected second request to be allowed")
}
if rl.Allow("key-a") {
t.Error("expected third request to be refused once the limit is hit")
}
}
// TestRateLimiter_Allow_PerKeyBuckets verifies keys are bucketed independently:
// exhausting one key must not consume another key's allowance.
func TestRateLimiter_Allow_PerKeyBuckets(t *testing.T) {
rl := NewRateLimiter(2, time.Minute)
for i := 0; i < 2; i++ {
if !rl.Allow("busy-key") {
t.Fatalf("request %d: expected busy-key to be allowed", i+1)
}
}
if rl.Allow("busy-key") {
t.Error("expected busy-key to be exhausted after its limit")
}
if !rl.Allow("other-key") {
t.Error("expected other-key to keep its own allowance")
}
}
// TestRateLimiter_Allow_WindowExpiryPrunesStale verifies timestamps older than
// the window no longer count: an Allow after the window is free again.
func TestRateLimiter_Allow_WindowExpiryPrunesStale(t *testing.T) {
rl := NewRateLimiter(1, time.Minute)
// Fill the bucket with a timestamp from before the window started.
rl.mu.Lock()
rl.requests["key-stale"] = []time.Time{clock.Now().Add(-2 * time.Minute)}
rl.mu.Unlock()
if !rl.Allow("key-stale") {
t.Error("expected an expired entry to be pruned and the request allowed")
}
rl.mu.RLock()
got := len(rl.requests["key-stale"])
rl.mu.RUnlock()
if got != 1 {
t.Errorf("expected the stale entry to be replaced by the fresh one, got %d entries", got)
}
}
// TestRateLimiter_Allow_BoundaryExactLimit verifies the limit is an exclusive
// boundary: the request that makes the count EQUAL the limit is the last one
// allowed.
func TestRateLimiter_Allow_BoundaryExactLimit(t *testing.T) {
rl := NewRateLimiter(3, time.Minute)
for i := 0; i < 3; i++ {
if !rl.Allow("boundary-key") {
t.Fatalf("request %d: expected allowed (count == limit is allowed)", i+1)
}
}
if rl.Allow("boundary-key") {
t.Error("expected the count-over-limit request to be refused")
}
}
+421
View File
@@ -2,10 +2,15 @@ package mw
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crussell/clock"
"github.com/go-chi/chi/v5/middleware"
)
// TestProgressiveRateLimiter_CleanupRemovesStaleEntries verifies that
@@ -162,3 +167,419 @@ func TestCleanupProgressiveRateLimiter(t *testing.T) {
t.Error("expected global-stale to be removed")
}
}
// ============================================================
// clientIP — per-IP rate-limit key derivation (batch-1 fix)
// ============================================================
// setTrustProxyHeaders temporarily overrides the package-level
// trustProxyHeaders flag so clientIP()'s priority order can be exercised in
// both states. The flag is an init-time capture of TRUST_PROXY_HEADERS (see
// ratelimit_shared.go) that cannot be re-read after process start; the tests
// live in package mw, so the unexported var is reachable directly.
func setTrustProxyHeaders(t *testing.T, v bool) {
t.Helper()
saved := trustProxyHeaders
trustProxyHeaders = v
t.Cleanup(func() { trustProxyHeaders = saved })
}
// TestClientIP_CFConnectingIP_HonoredWhenTrusted verifies that with
// TRUST_PROXY_HEADERS=true a CF-Connecting-IP header becomes the rate-limit
// key (a trusted edge has already overwritten it with the real client IP).
func TestClientIP_CFConnectingIP_HonoredWhenTrusted(t *testing.T) {
setTrustProxyHeaders(t, true)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("CF-Connecting-IP", "203.0.113.7")
if got := clientIP(req); got != "203.0.113.7" {
t.Errorf("expected trusted CF-Connecting-IP to win, got %q", got)
}
}
// TestClientIP_CFConnectingIP_IgnoredWhenUntrusted verifies the default
// TRUST_PROXY_HEADERS=false behaviour: a client-supplied CF-Connecting-IP is
// ignored so an origin-exposed backend can never let a client forge its own
// rate-limit key.
func TestClientIP_CFConnectingIP_IgnoredWhenUntrusted(t *testing.T) {
setTrustProxyHeaders(t, false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("CF-Connecting-IP", "203.0.113.7")
if got := clientIP(req); got != "192.0.2.1" {
t.Errorf("expected untrusted CF-Connecting-IP to be ignored, got %q", got)
}
}
// TestClientIP_XRealIPContext_WhenMiddlewareRegistered verifies the chi
// ClientIPFromHeader("X-Real-IP") context path: the X-Real-IP value nginx sets
// from $remote_addr is used as the key once the middleware has captured it.
func TestClientIP_XRealIPContext_WhenMiddlewareRegistered(t *testing.T) {
setTrustProxyHeaders(t, true)
var got string
h := middleware.ClientIPFromHeader("X-Real-IP")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = clientIP(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("X-Real-IP", "198.51.100.42")
h.ServeHTTP(httptest.NewRecorder(), req)
if got != "198.51.100.42" {
t.Errorf("expected X-Real-IP from context, got %q", got)
}
}
// TestClientIP_CFWinsOverContext verifies the priority order when both a
// trusted CF-Connecting-IP header and a context client IP are present: the
// CF header is priority 1, the context (X-Real-IP) value priority 2.
func TestClientIP_CFWinsOverContext(t *testing.T) {
setTrustProxyHeaders(t, true)
var got string
h := middleware.ClientIPFromHeader("X-Real-IP")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = clientIP(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("CF-Connecting-IP", "203.0.113.9")
req.Header.Set("X-Real-IP", "198.51.100.42")
h.ServeHTTP(httptest.NewRecorder(), req)
if got != "203.0.113.9" {
t.Errorf("expected CF-Connecting-IP to beat the context value, got %q", got)
}
}
// TestClientIP_ContextBeatsRemoteAddrEvenWhenCFUntrusted verifies the context
// value (set by the middleware) is read unconditionally — clientIP does not
// re-check trustProxyHeaders for it — so a spoofed CF header with no trusted
// edge cannot override a middleware-captured value.
func TestClientIP_ContextBeatsRemoteAddrEvenWhenCFUntrusted(t *testing.T) {
setTrustProxyHeaders(t, false)
var got string
h := middleware.ClientIPFromHeader("X-Real-IP")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = clientIP(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("CF-Connecting-IP", "203.0.113.7")
req.Header.Set("X-Real-IP", "198.51.100.42")
h.ServeHTTP(httptest.NewRecorder(), req)
if got != "198.51.100.42" {
t.Errorf("expected context client IP to beat RemoteAddr, got %q", got)
}
}
// TestClientIP_FallbackToRemoteAddr verifies the last-resort key source: the
// TCP peer from r.RemoteAddr once the port is split off.
func TestClientIP_FallbackToRemoteAddr(t *testing.T) {
setTrustProxyHeaders(t, false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
if got := clientIP(req); got != "192.0.2.1" {
t.Errorf("expected RemoteAddr host as fallback, got %q", got)
}
}
// TestClientIP_RemoteAddrWithoutPort_ReturnedAsIs verifies that a RemoteAddr
// lacking a port (no SplitHostPort success) is returned verbatim rather than
// being dropped.
func TestClientIP_RemoteAddrWithoutPort_ReturnedAsIs(t *testing.T) {
setTrustProxyHeaders(t, false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.5"
if got := clientIP(req); got != "192.0.2.5" {
t.Errorf("expected portless RemoteAddr to pass through, got %q", got)
}
}
// ============================================================
// RateLimit middleware — 429 on burst, pass-through under limit,
// per-key buckets (batch-1 fix regression)
// ============================================================
// newRateLimitTestHandler builds a RateLimit-wrapped handler that records how
// many times the inner handler was reached.
func newRateLimitTestHandler(limit int, window time.Duration) (http.Handler, *int) {
calls := 0
handler := RateLimit(limit, window)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
}))
return handler, &calls
}
func serveRateLimitRequest(t *testing.T, h http.Handler, remoteAddr string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remoteAddr
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
// TestRateLimitMiddleware_RequestsUnderLimitPassThrough verifies requests
// within the per-IP limit reach the handler untouched.
func TestRateLimitMiddleware_RequestsUnderLimitPassThrough(t *testing.T) {
handler, calls := newRateLimitTestHandler(2, time.Minute)
for i := 0; i < 2; i++ {
w := serveRateLimitRequest(t, handler, "192.0.2.10:1234")
if w.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d (body: %s)", i+1, w.Code, w.Body.String())
}
}
if *calls != 2 {
t.Errorf("expected 2 handler calls, got %d", *calls)
}
}
// TestRateLimitMiddleware_BurstAboveLimitReturns429 verifies the request that
// crosses the limit is answered 429 and the inner handler is never reached.
func TestRateLimitMiddleware_BurstAboveLimitReturns429(t *testing.T) {
handler, calls := newRateLimitTestHandler(2, time.Minute)
for i := 0; i < 2; i++ {
if w := serveRateLimitRequest(t, handler, "192.0.2.11:1234"); w.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
}
}
w := serveRateLimitRequest(t, handler, "192.0.2.11:1234")
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 on the request above the limit, got %d", w.Code)
}
if body := w.Body.String(); !strings.Contains(body, "Rate limit exceeded") {
t.Errorf("expected a rate-limit error body, got %q", body)
}
if *calls != 2 {
t.Errorf("expected inner handler to be called exactly twice, got %d", *calls)
}
}
// TestRateLimitMiddleware_PerKeyBuckets verifies different IPs get independent
// buckets: exhausting one IP must not exhaust another.
func TestRateLimitMiddleware_PerKeyBuckets(t *testing.T) {
handler, _ := newRateLimitTestHandler(2, time.Minute)
// Exhaust IP A.
for i := 0; i < 2; i++ {
if w := serveRateLimitRequest(t, handler, "192.0.2.20:1234"); w.Code != http.StatusOK {
t.Fatalf("A request %d: expected 200, got %d", i+1, w.Code)
}
}
if w := serveRateLimitRequest(t, handler, "192.0.2.20:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected IP A to be rate-limited after its burst, got %d", w.Code)
}
// IP B is a separate bucket and still has its full allowance.
for i := 0; i < 2; i++ {
if w := serveRateLimitRequest(t, handler, "192.0.2.21:1234"); w.Code != http.StatusOK {
t.Fatalf("B request %d: expected 200 (independent bucket), got %d", i+1, w.Code)
}
}
if w := serveRateLimitRequest(t, handler, "192.0.2.21:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected IP B to be rate-limited only after ITS OWN burst, got %d", w.Code)
}
}
// ============================================================
// RateLimitByUserAndIP — user+IP-keyed middleware (A7 fix:
// per-IP limiter collapsing to a global budget behind a proxy)
// ============================================================
// newRateLimitByUserAndIPTestHandler builds a RateLimitByUserAndIP-wrapped
// handler that records how many times the inner handler was reached.
func newRateLimitByUserAndIPTestHandler(limit int, window time.Duration) (http.Handler, *int) {
calls := 0
handler := RateLimitByUserAndIP(limit, window)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
}))
return handler, &calls
}
// serveRateLimitUserRequest serves a request with an optional authenticated
// userID in context (the equivalent of RequireAuth having run) through h.
func serveRateLimitUserRequest(t *testing.T, h http.Handler, userID, remoteAddr string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remoteAddr
if userID != "" {
req = req.WithContext(context.WithValue(req.Context(), UserIDKey, userID))
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
// TestRateLimitByUserAndIP_PerUserBucketsBehindSameIP verifies the keyed
// limiter's core guarantee: two different users behind the SAME proxy IP (the
// TRUST_PROXY_HEADERS=false nginx scenario, where clientIP returns the proxy's
// address for everyone) get INDEPENDENT budgets — one user exhausting their
// 10/min allowance can never 429 the other user's 2FA setup/verify/disable.
func TestRateLimitByUserAndIP_PerUserBucketsBehindSameIP(t *testing.T) {
handler, calls := newRateLimitByUserAndIPTestHandler(2, time.Minute)
// User A exhausts its own budget from the shared proxy IP.
for i := 0; i < 2; i++ {
if w := serveRateLimitUserRequest(t, handler, "user-a", "10.0.0.5:1234"); w.Code != http.StatusOK {
t.Fatalf("A request %d: expected 200, got %d", i+1, w.Code)
}
}
if w := serveRateLimitUserRequest(t, handler, "user-a", "10.0.0.5:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected user A to be rate-limited after its own burst, got %d", w.Code)
}
// User B shares the proxy IP but must keep its own full allowance.
for i := 0; i < 2; i++ {
if w := serveRateLimitUserRequest(t, handler, "user-b", "10.0.0.5:1234"); w.Code != http.StatusOK {
t.Fatalf("B request %d: expected 200 (independent user bucket), got %d", i+1, w.Code)
}
}
if w := serveRateLimitUserRequest(t, handler, "user-b", "10.0.0.5:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected user B to be limited only after ITS OWN burst, got %d", w.Code)
}
if *calls != 4 {
t.Errorf("expected exactly 4 handler calls (2 per user), got %d", *calls)
}
}
// TestRateLimitByUserAndIP_SameUserSharesOneBudget verifies the same user's
// requests across all four 2FA routes count against one shared budget (the
// "one shared limiter for all four routes" contract).
func TestRateLimitByUserAndIP_SameUserSharesOneBudget(t *testing.T) {
handler, _ := newRateLimitByUserAndIPTestHandler(3, time.Minute)
for i := 0; i < 3; i++ {
if w := serveRateLimitUserRequest(t, handler, "user-c", "198.51.100.15:1234"); w.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
}
}
if w := serveRateLimitUserRequest(t, handler, "user-c", "198.51.100.15:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected the 4th request from the same user to be limited, got %d", w.Code)
}
}
// TestRateLimitByUserAndIP_UnauthenticatedFallsBackToIP verifies the fallback:
// without a userID in context the key is the client IP alone (same behaviour
// as RateLimit), so the middleware stays safe on unauthenticated paths.
func TestRateLimitByUserAndIP_UnauthenticatedFallsBackToIP(t *testing.T) {
handler, _ := newRateLimitByUserAndIPTestHandler(1, time.Minute)
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.20:1234"); w.Code != http.StatusOK {
t.Fatalf("expected 200 for the first request, got %d", w.Code)
}
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.20:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected a second unauthenticated request from the same IP to be limited, got %d", w.Code)
}
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.21:1234"); w.Code != http.StatusOK {
t.Errorf("expected a different IP to keep its own bucket, got %d", w.Code)
}
}
// ============================================================
// ProgressiveRateLimiter.Check — dual-window progressive delay
// algorithm (batch-1 fix regression)
// ============================================================
// seedProgressiveTimestamps arms prl.requests[ip] so the NEXT Check() call
// observes exactly `burst` timestamps inside the 5s window and `sustained`
// inside the 60s window. Check() appends its own timestamp first, so one fewer
// is seeded: `burst-1` recent timestamps (now-1s, inside the burst window) and
// `sustained-burst` older-but-in-window timestamps (now-10s, outside the 5s
// burst window but inside the 60s sustained window).
func seedProgressiveTimestamps(t *testing.T, prl *ProgressiveRateLimiter, ip string, burst, sustained int) {
t.Helper()
if sustained < burst {
t.Fatalf("sustained (%d) must be >= burst (%d)", sustained, burst)
}
now := clock.Now()
ts := make([]time.Time, 0, sustained-1)
for i := 0; i < burst-1; i++ {
ts = append(ts, now.Add(-time.Second))
}
for i := 0; i < sustained-burst; i++ {
ts = append(ts, now.Add(-10*time.Second))
}
prl.mu.Lock()
prl.requests[ip] = &ipProgressiveState{timestamps: ts}
prl.mu.Unlock()
}
// TestProgressiveRateLimiter_CheckDelayTiers pins the exact algorithm: no
// delay while burst <= 30 AND sustained <= 120; then delay escalates with the
// sustained rate (500ms / 2s / 5s / 10s tiers).
func TestProgressiveRateLimiter_CheckDelayTiers(t *testing.T) {
cases := []struct {
name string
burst int
sustained int
wantMs int
}{
{"under both thresholds is free", 30, 120, 0},
{"burst alone trips the lowest tier", 31, 120, 500},
{"sustained alone trips the lowest tier", 30, 121, 500},
{"500ms tier ceiling", 31, 140, 500},
{"2s tier floor", 31, 141, 2000},
{"2s tier ceiling", 31, 200, 2000},
{"5s tier floor", 31, 201, 5000},
{"5s tier ceiling", 31, 300, 5000},
{"10s abuse tier", 31, 301, 10000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
prl := NewProgressiveRateLimiter()
ip := "198.51.100.7"
seedProgressiveTimestamps(t, prl, ip, tc.burst, tc.sustained)
if got := prl.Check(ip); got != tc.wantMs {
t.Errorf("burst=%d sustained=%d: expected %dms delay, got %dms", tc.burst, tc.sustained, tc.wantMs, got)
}
})
}
}
// TestProgressiveRateLimiter_DelayEscalatesWithSustainedRate verifies the
// delay increases as a sustained high rate climbs through the tiers on
// repeated hits.
func TestProgressiveRateLimiter_DelayEscalatesWithSustainedRate(t *testing.T) {
prl := NewProgressiveRateLimiter()
ip := "203.0.113.42"
// Start already over the burst limit so every check is throttled.
seedProgressiveTimestamps(t, prl, ip, 31, 31)
steps := []struct {
extraSustained int
wantMs int
}{
{0, 500}, // observed sustained=31 → 500ms tier
{109, 2000}, // observed sustained=141 → 2s tier
{60, 5000}, // observed sustained=202 → 5s tier
{100, 10000}, // observed sustained=303 → 10s abuse tier
}
for i, s := range steps {
if s.extraSustained > 0 {
prl.mu.Lock()
state := prl.requests[ip]
for j := 0; j < s.extraSustained; j++ {
state.timestamps = append(state.timestamps, clock.Now().Add(-6*time.Second))
}
prl.mu.Unlock()
}
if got := prl.Check(ip); got != s.wantMs {
t.Errorf("step %d: expected %dms delay, got %dms", i, s.wantMs, got)
}
}
}