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