Implement full Square payment review fixes + frontend polish
Implement every finding from the deep payment review (P0-P2, minors, nitpicks), then close the post-implementation re-review items, then align card-form typography and roll out the Square trust badge. Backend - Square API alignment: - tip_settings.allow_tipping nested under device_options (was top-level: terminal tips were silently lost in prod) - CreateCardOnFile now accepts customerID and sends card.customer_id; saved-card (ccof:) charges forward square_customer_id as CustomerID - New SquareClient methods GetPayment, CreateCustomer, CancelCheckout - SCA verification_token accepted + forwarded in all charge paths - ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/ ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts emails, ForceRefundPending hook Backend - money safety: - sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id instead of stranding them forever - SweepStalePendingPayments reconciles at Square before failing (tri-state: leave pending on transport error, rescue completed, fail definitively) - GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution; SweepStaleTerminalCheckouts covers terminal_checkouts table - till gift-card clawback on definitive failure incl. retry path + INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT - cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id)) - customer provisioning (lazy, save-only); one-off/guest mint no customer - discount preview/apply unified in discounts.go (global-milestone visible in preview, N+1 eliminated, redemption counter preserved on failures) - webhook event_id dedup; refund loop dedup; stale comment fixes - test-isolation t.Cleanup on committed sweep tests Frontend: - SCA tokenizeWithVerification across all charge flows (amount as major-units decimal), 5-min token-expiry re-tokenize, verification_token in request bodies - PaymentModal synchronous double-click + zero/negative-amount guards - till online-card UI wired to /api/admin/till/sale - policyPopover generalised; new /privacy-policy route; consent checkbox copy + Square privacy link - Square card iframe styled to app typography (Inter 14px, oklch tokens); mock form md:text-sm parity - 'Secure payment powered by Square' badge on all 8 card-payment flows Schema/docs: terminal_checkouts + square_customer_id + per-user card constraint in init-script.sql; README migrations; P14 plan + backlog + Technical Manual updated. Includes 39 modified/new test files; full backend suite (25 pkgs), -race on payments+square, and frontend build are green.
This commit is contained in:
@@ -31,12 +31,24 @@ func (p *ProdClient) GetCheckout(ctx context.Context, checkoutID string) (*Payme
|
||||
return getCheckoutHTTP(ctx, checkoutID)
|
||||
}
|
||||
|
||||
func (p *ProdClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) {
|
||||
return getPaymentHTTP(ctx, paymentID)
|
||||
}
|
||||
|
||||
func (p *ProdClient) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) {
|
||||
return createCustomerHTTP(ctx, name, email)
|
||||
}
|
||||
|
||||
func (p *ProdClient) CancelCheckout(ctx context.Context, checkoutID string) error {
|
||||
return cancelCheckoutHTTP(ctx, checkoutID)
|
||||
}
|
||||
|
||||
func (p *ProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
return refundPaymentHTTP(ctx, req)
|
||||
}
|
||||
|
||||
func (p *ProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
return createCardOnFileHTTP(ctx, userID, cardToken)
|
||||
func (p *ProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||
return createCardOnFileHTTP(ctx, userID, cardToken, customerID)
|
||||
}
|
||||
|
||||
func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ package square
|
||||
import (
|
||||
"context"
|
||||
"crussell/clock"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -31,6 +32,7 @@ type MockClient struct {
|
||||
paymentByKey map[string]*PaymentResult
|
||||
refunds map[string]*RefundResult
|
||||
refundByKey map[string]*RefundResult
|
||||
customers map[string]*CustomerResult
|
||||
completed map[string]*PaymentResult
|
||||
HoldCheckouts bool
|
||||
ShouldFail bool // if true, CreatePayment/RefundPayment return errors for testing error paths
|
||||
@@ -38,6 +40,10 @@ type MockClient struct {
|
||||
// = normal success; when set (e.g. "PAYMENT_ALREADY_REFUNDED"),
|
||||
// RefundPayment returns the sentinel-wrapped error for that code.
|
||||
FailRefundCode string
|
||||
// ForceRefundPending makes RefundPayment return a PENDING refund so the
|
||||
// prod-only pending-refund branch (normally only reachable against the
|
||||
// real Square API) can be exercised in dev/tests.
|
||||
ForceRefundPending bool
|
||||
}
|
||||
|
||||
type devProdClient struct{}
|
||||
@@ -51,11 +57,20 @@ func (d *devProdClient) CreateCheckout(ctx context.Context, req CreateCheckoutRe
|
||||
func (d *devProdClient) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) {
|
||||
return getCheckoutHTTP(ctx, checkoutID)
|
||||
}
|
||||
func (d *devProdClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) {
|
||||
return getPaymentHTTP(ctx, paymentID)
|
||||
}
|
||||
func (d *devProdClient) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) {
|
||||
return createCustomerHTTP(ctx, name, email)
|
||||
}
|
||||
func (d *devProdClient) CancelCheckout(ctx context.Context, checkoutID string) error {
|
||||
return cancelCheckoutHTTP(ctx, checkoutID)
|
||||
}
|
||||
func (d *devProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
return refundPaymentHTTP(ctx, req)
|
||||
}
|
||||
func (d *devProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
return createCardOnFileHTTP(ctx, userID, cardToken)
|
||||
func (d *devProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||
return createCardOnFileHTTP(ctx, userID, cardToken, customerID)
|
||||
}
|
||||
func (d *devProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
||||
return getCardsOnFileHTTP(ctx, userID)
|
||||
@@ -85,6 +100,7 @@ func NewDevClient() SquareClient {
|
||||
paymentByKey: make(map[string]*PaymentResult),
|
||||
refunds: make(map[string]*RefundResult),
|
||||
refundByKey: make(map[string]*RefundResult),
|
||||
customers: make(map[string]*CustomerResult),
|
||||
completed: make(map[string]*PaymentResult),
|
||||
}
|
||||
}
|
||||
@@ -108,6 +124,12 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
if m.ShouldFail {
|
||||
return nil, fmt.Errorf("mock: payment declined (simulated failure)")
|
||||
}
|
||||
// Match the real Square API: source_id must be a token (cnon:xxx nonce or
|
||||
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
|
||||
// mock behaves identically to production (PCI-DSS parity).
|
||||
if !isTokenLike(req.SourceID) {
|
||||
return nil, fmt.Errorf("invalid source_id: %q — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", req.SourceID)
|
||||
}
|
||||
// Do NOT log the full source token — it is a single-use nonce (cnon:) or a
|
||||
// card reference (ccof:) that could be replayed. Log only its prefix and
|
||||
// length for debugging (S-2).
|
||||
@@ -164,6 +186,9 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
locationID = "L_MOCK"
|
||||
}
|
||||
|
||||
expMonth := 12
|
||||
expYear := 2030
|
||||
|
||||
result := &PaymentResult{
|
||||
ID: paymentID,
|
||||
Status: status,
|
||||
@@ -171,8 +196,8 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
CardBrand: cardBrand,
|
||||
CardLast4: cardLast4,
|
||||
CardFingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()),
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
ExpMonth: &expMonth,
|
||||
ExpYear: &expYear,
|
||||
EntryMethod: entryMethod,
|
||||
CVVStatus: "CVV_ACCEPTED",
|
||||
AVSStatus: "AVS_ACCEPTED",
|
||||
@@ -198,7 +223,7 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
}
|
||||
|
||||
func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
|
||||
log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, tipEnabled=%v, reference=%s", req.Amount, req.TipEnabled, req.ReferenceID)
|
||||
log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, allowTipping=%v, reference=%s", req.Amount, req.AllowTipping, req.ReferenceID)
|
||||
|
||||
now := clock.Now().UTC()
|
||||
checkoutID := fmt.Sprintf("chk_mock_%d", now.UnixNano())
|
||||
@@ -239,12 +264,15 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
|
||||
paymentID := fmt.Sprintf("pay_mock_%d", payNow.UnixNano())
|
||||
amount := req.Amount
|
||||
tipAmount := int64(0)
|
||||
if req.TipEnabled {
|
||||
if req.AllowTipping {
|
||||
tipAmount = 500
|
||||
amount += tipAmount
|
||||
}
|
||||
fees := amount * 175 / 10000 // in-person rate: 1.75%
|
||||
|
||||
expMonth := 12
|
||||
expYear := 2030
|
||||
|
||||
paymentResult := &PaymentResult{
|
||||
ID: paymentID,
|
||||
Status: "COMPLETED",
|
||||
@@ -252,8 +280,8 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
|
||||
CardBrand: "VISA",
|
||||
CardLast4: "4242",
|
||||
CardFingerprint: fmt.Sprintf("sqfp_mock_%d", payNow.UnixNano()),
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
ExpMonth: &expMonth,
|
||||
ExpYear: &expYear,
|
||||
EntryMethod: "EMV",
|
||||
CVVStatus: "CVV_ACCEPTED",
|
||||
AVSStatus: "AVS_ACCEPTED",
|
||||
@@ -302,6 +330,19 @@ func (m *MockClient) GetCheckout(ctx context.Context, checkoutID string) (*Payme
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) {
|
||||
log.Printf("[SQUARE-MOCK] GetPayment: id=%s", paymentID)
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
payment, ok := m.payments[paymentID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("payment not found: %s", paymentID)
|
||||
}
|
||||
return payment, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
if m.ShouldFail {
|
||||
return nil, fmt.Errorf("%w: refund declined (simulated failure)", ErrRefundDeclined)
|
||||
@@ -336,6 +377,13 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
||||
|
||||
payment, ok := m.payments[req.PaymentID]
|
||||
if !ok {
|
||||
if req.Amount == 0 {
|
||||
// A £0 refund resolves to a full refund only when the payment is
|
||||
// known; against an unknown payment there is nothing to size it
|
||||
// from. The real DB has a CHECK (amount > 0), so an empty refund
|
||||
// must fail rather than silently record £0.
|
||||
return nil, fmt.Errorf("square: refund amount must be positive (payment %s not found, cannot resolve full refund)", req.PaymentID)
|
||||
}
|
||||
// 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.
|
||||
@@ -352,9 +400,14 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
||||
locationID = "L_MOCK"
|
||||
}
|
||||
|
||||
status := "COMPLETED"
|
||||
if m.ForceRefundPending {
|
||||
status = "PENDING"
|
||||
}
|
||||
|
||||
result := &RefundResult{
|
||||
ID: refundID,
|
||||
Status: "COMPLETED",
|
||||
Status: status,
|
||||
Amount: amount,
|
||||
PaymentID: req.PaymentID,
|
||||
LocationID: locationID,
|
||||
@@ -369,7 +422,7 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
|
||||
|
||||
// Match the real Square API: source_id must be a token (cnon:xxx nonce or
|
||||
@@ -450,8 +503,8 @@ func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error
|
||||
func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
|
||||
log.Printf("[SQUARE-MOCK] ListPaymentRefunds: payment=%s, begin=%s", paymentID, beginTime.UTC().Format(time.RFC3339))
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
out := []RefundResult{}
|
||||
for _, r := range m.refunds {
|
||||
@@ -473,6 +526,63 @@ func isTokenLike(s string) bool {
|
||||
return strings.HasPrefix(s, "cnon:") || strings.HasPrefix(s, "ccof:")
|
||||
}
|
||||
|
||||
// redactedEmail masks a customer email for dev logs (PII, S-2 convention):
|
||||
// only the first two characters of the local part plus the domain are shown,
|
||||
// e.g. "ja***@example.com". Malformed addresses fall back to "[redacted]".
|
||||
func redactedEmail(email string) string {
|
||||
at := strings.Index(email, "@")
|
||||
if at < 2 || at+1 >= len(email) {
|
||||
return "[redacted]"
|
||||
}
|
||||
return email[:2] + "***@" + email[at+1:]
|
||||
}
|
||||
|
||||
func (m *MockClient) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) {
|
||||
log.Printf("[SQUARE-MOCK] CreateCustomer: name=%s, email=%s", name, redactedEmail(email))
|
||||
|
||||
if email == "" {
|
||||
return nil, fmt.Errorf("mock: customer email is required")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Real Square dedups on the idempotency key (derived from the email);
|
||||
// the mock mirrors this by deduping on email so a retry returns the
|
||||
// original customer rather than creating a duplicate.
|
||||
if existing, ok := m.customers[email]; ok {
|
||||
log.Printf("[SQUARE-MOCK] CreateCustomer dedup hit: email=%s → id=%s", redactedEmail(email), existing.ID)
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(email))
|
||||
customer := &CustomerResult{
|
||||
ID: "cus_mock_" + fmt.Sprintf("%x", sum)[:12],
|
||||
Email: email,
|
||||
CreatedAt: clock.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
m.customers[email] = customer
|
||||
log.Printf("[SQUARE-MOCK] Customer created: id=%s, email=%s", customer.ID, redactedEmail(email))
|
||||
return customer, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) CancelCheckout(ctx context.Context, checkoutID string) error {
|
||||
log.Printf("[SQUARE-MOCK] CancelCheckout: id=%s", checkoutID)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Real Square cancels only pending/in-progress checkouts; a completed or
|
||||
// missing checkout is a no-op (Square returns 404/NOT_FOUND in prod).
|
||||
if checkout, ok := m.checkouts[checkoutID]; ok {
|
||||
if checkout.Status == "PENDING" || checkout.Status == "IN_PROGRESS" {
|
||||
checkout.Status = "CANCELED"
|
||||
checkout.UpdatedAt = clock.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func realBaseURL(env string) string {
|
||||
if env == "production" {
|
||||
return squareProductionURL
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
package square
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -36,8 +40,10 @@ func TestDevClient_CreatePayment_ReturnsCompleted(t *testing.T) {
|
||||
assert.NotZero(t, result.Fees)
|
||||
|
||||
assert.NotEmpty(t, result.CardFingerprint)
|
||||
assert.Equal(t, 12, result.ExpMonth)
|
||||
assert.Equal(t, 2030, result.ExpYear)
|
||||
require.NotNil(t, result.ExpMonth)
|
||||
assert.Equal(t, 12, *result.ExpMonth)
|
||||
require.NotNil(t, result.ExpYear)
|
||||
assert.Equal(t, 2030, *result.ExpYear)
|
||||
assert.Equal(t, "KEYED", result.EntryMethod)
|
||||
assert.Equal(t, "CVV_ACCEPTED", result.CVVStatus)
|
||||
assert.Equal(t, "AVS_ACCEPTED", result.AVSStatus)
|
||||
@@ -55,7 +61,7 @@ func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) {
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "checkout-key-1",
|
||||
ReferenceID: "booking-456",
|
||||
TipEnabled: true,
|
||||
AllowTipping: true,
|
||||
}
|
||||
|
||||
result, err := client.CreateCheckout(ctx, req)
|
||||
@@ -91,7 +97,7 @@ func TestDevClient_CreateCheckout_NoTip(t *testing.T) {
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "checkout-key-notip",
|
||||
ReferenceID: "booking-789",
|
||||
TipEnabled: false,
|
||||
AllowTipping: false,
|
||||
}
|
||||
|
||||
result, err := client.CreateCheckout(ctx, req)
|
||||
@@ -157,7 +163,7 @@ func TestDevClient_CardOnFile_CreateAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := "user-test-123"
|
||||
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token")
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, card.ID)
|
||||
@@ -182,10 +188,10 @@ func TestDevClient_CardOnFile_MultipleCards(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := "user-test-multiple"
|
||||
|
||||
card1, err := client.CreateCardOnFile(ctx, userID, "cnon:token-1")
|
||||
card1, err := client.CreateCardOnFile(ctx, userID, "cnon:token-1", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
card2, err := client.CreateCardOnFile(ctx, userID, "cnon:token-2")
|
||||
card2, err := client.CreateCardOnFile(ctx, userID, "cnon:token-2", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, card1.Enabled)
|
||||
@@ -207,7 +213,7 @@ func TestDevClient_CardOnFile_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := "user-test-delete"
|
||||
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-delete")
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-delete", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.DeleteCardOnFile(ctx, card.ID)
|
||||
@@ -257,7 +263,7 @@ func TestDevClient_CreateCardOnFile_RejectsRawPAN(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
card, err := client.CreateCardOnFile(ctx, "user-raw-"+tt.name, tt.cardNumber)
|
||||
card, err := client.CreateCardOnFile(ctx, "user-raw-"+tt.name, tt.cardNumber, "")
|
||||
require.Error(t, err, "raw PAN must be rejected for production parity")
|
||||
assert.Nil(t, card)
|
||||
assert.Contains(t, err.Error(), "invalid source_id")
|
||||
@@ -535,14 +541,14 @@ func TestDevClient_CreateCardOnFile_WithNewFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := "user-new-fields"
|
||||
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token")
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, card.Enabled)
|
||||
assert.NotEmpty(t, card.CardholderName)
|
||||
// Local linkage goes in reference_id, NOT customer_id — the app has no
|
||||
// Square customer provisioning, and a local ID in customer_id would be
|
||||
// rejected by the real Cards API.
|
||||
// Local linkage goes in reference_id; the mock does not store the
|
||||
// customer_id (prod sends it on card creation when the app has provisioned
|
||||
// a Square customer for the user).
|
||||
assert.Equal(t, userID, card.ReferenceID)
|
||||
assert.Empty(t, card.CustomerID)
|
||||
assert.Greater(t, card.Version, int64(0))
|
||||
@@ -554,7 +560,7 @@ func TestDevClient_DeleteCardOnFile_SoftDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := "user-soft-delete"
|
||||
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-soft")
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-soft", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.DeleteCardOnFile(ctx, card.ID)
|
||||
@@ -703,3 +709,290 @@ func TestDetectCardInfo_Variants(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevClient_GetPayment_Found(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "payment-for-get",
|
||||
ReferenceID: "booking-get",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := client.GetPayment(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, created.ID, got.ID)
|
||||
assert.Equal(t, int64(5000), got.Amount)
|
||||
assert.Equal(t, "COMPLETED", got.Status)
|
||||
}
|
||||
|
||||
func TestDevClient_GetPayment_NotFound(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.GetPayment(ctx, "pay_does_not_exist")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCustomer_Dedup(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := client.CreateCustomer(ctx, "Jane Doe", "jane@example.com")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, first.ID)
|
||||
assert.Equal(t, "jane@example.com", first.Email)
|
||||
assert.NotEmpty(t, first.CreatedAt)
|
||||
assert.True(t, strings.HasPrefix(first.ID, "cus_mock_"))
|
||||
|
||||
// Same email → same deterministic customer (Square dedups on the
|
||||
// email-derived idempotency key; the mock dedups on email).
|
||||
second, err := client.CreateCustomer(ctx, "Jane Doe", "jane@example.com")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first.ID, second.ID, "same-email retry must return the original customer")
|
||||
|
||||
other, err := client.CreateCustomer(ctx, "John Doe", "john@example.com")
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, first.ID, other.ID)
|
||||
|
||||
client.mu.RLock()
|
||||
defer client.mu.RUnlock()
|
||||
assert.Len(t, client.customers, 2)
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCustomer_EmptyEmail(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.CreateCustomer(ctx, "Jane Doe", "")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "email")
|
||||
}
|
||||
|
||||
func TestDevClient_CancelCheckout_CancelsPending(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.HoldCheckouts = true
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.CreateCheckout(ctx, CreateCheckoutReq{
|
||||
Amount: 2500,
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "cancel-checkout",
|
||||
ReferenceID: "cancel-ref",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PENDING", result.Status)
|
||||
|
||||
err = client.CancelCheckout(ctx, result.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
client.mu.RLock()
|
||||
checkout := client.checkouts[result.ID]
|
||||
client.mu.RUnlock()
|
||||
require.NotNil(t, checkout)
|
||||
assert.Equal(t, "CANCELED", checkout.Status)
|
||||
}
|
||||
|
||||
func TestDevClient_CancelCheckout_UnknownIsNoOp(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.CancelCheckout(ctx, "chk_does_not_exist")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDevClient_CancelCheckout_CompletedIsNoOp(t *testing.T) {
|
||||
// Square documents that disabling an already-completed/cancelled checkout
|
||||
// has no effect, so the mock must return nil and leave the status alone.
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.CreateCheckout(ctx, CreateCheckoutReq{
|
||||
Amount: 2500,
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "cancel-completed",
|
||||
ReferenceID: "cancel-comp-ref",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
_, err := client.GetCheckout(ctx, result.ID)
|
||||
return err == nil
|
||||
}, 5*time.Second, 100*time.Millisecond, "expected checkout to complete")
|
||||
|
||||
err = client.CancelCheckout(ctx, result.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
client.mu.RLock()
|
||||
checkout := client.checkouts[result.ID]
|
||||
client.mu.RUnlock()
|
||||
require.NotNil(t, checkout)
|
||||
assert.Equal(t, "COMPLETED", checkout.Status, "cancelling an already-completed checkout must be a no-op")
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCustomer_RedactsEmailInLogs(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
var buf bytes.Buffer
|
||||
log.SetOutput(&buf)
|
||||
defer log.SetOutput(os.Stderr)
|
||||
|
||||
email := "pii.marker@example.com"
|
||||
cust, err := client.CreateCustomer(ctx, "PII Marker", email)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, email, cust.Email, "return value must keep the full email")
|
||||
|
||||
logs := buf.String()
|
||||
if strings.Contains(logs, email) {
|
||||
t.Errorf("full email %q leaked into mock logs: %q", email, logs)
|
||||
}
|
||||
if !strings.Contains(logs, "pi***@example.com") {
|
||||
t.Errorf("expected redacted email 'pi***@example.com' in logs, got %q", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevClient_CreatePayment_RejectsRawPAN(t *testing.T) {
|
||||
// PCI-DSS parity: CreatePayment accepts only token-like source_ids
|
||||
// (cnon:xxx / ccof:xxx). Raw PANs are rejected exactly like real Square.
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pan string
|
||||
}{
|
||||
{"visa", "4111111111111111"},
|
||||
{"mastercard", "5555555555554444"},
|
||||
{"amex", "378282246310005"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: tt.pan,
|
||||
IdempotencyKey: "raw-pan-" + tt.name,
|
||||
ReferenceID: "booking-raw",
|
||||
})
|
||||
require.Error(t, err, "raw PAN must be rejected for production parity")
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "invalid source_id")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevClient_RefundPayment_ForcePending(t *testing.T) {
|
||||
// ForceRefundPending exercises the prod-only PENDING refund branch that
|
||||
// is otherwise only reachable against the real Square API.
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.ForceRefundPending = true
|
||||
ctx := context.Background()
|
||||
|
||||
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 10000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "payment-for-pending-refund",
|
||||
ReferenceID: "booking-pending-refund",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
refundResult, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||
PaymentID: paymentResult.ID,
|
||||
Amount: 5000,
|
||||
IdempotencyKey: "pending-refund-key",
|
||||
Reason: "customer request",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PENDING", refundResult.Status)
|
||||
assert.Equal(t, int64(5000), refundResult.Amount)
|
||||
assert.Equal(t, paymentResult.ID, refundResult.PaymentID)
|
||||
}
|
||||
|
||||
func TestDevClient_RefundPayment_ZeroAmountUnknownPayment(t *testing.T) {
|
||||
// A £0 refund resolves to a full refund only when the payment is known.
|
||||
// Against an unknown payment it must fail (the real DB has a CHECK
|
||||
// amount > 0) rather than silently record a £0 refund.
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||
PaymentID: "pay_unknown_zero",
|
||||
Amount: 0,
|
||||
IdempotencyKey: "zero-refund-unknown",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "amount must be positive")
|
||||
}
|
||||
|
||||
func TestDevClient_RefundPayment_ZeroAmountFullRefundWhenPaymentExists(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 10000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "payment-for-zero-refund",
|
||||
ReferenceID: "booking-zero-refund",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
refundResult, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||
PaymentID: paymentResult.ID,
|
||||
Amount: 0,
|
||||
IdempotencyKey: "zero-refund-known",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(10000), refundResult.Amount, "amount 0 = full refund when the payment exists")
|
||||
}
|
||||
|
||||
func TestDevClient_ListPaymentRefunds_ConcurrentReads(t *testing.T) {
|
||||
// Exercises the RLock read path concurrently with writes (Lock) — would
|
||||
// deadlock or panic under -race if ListPaymentRefunds wrongly used a
|
||||
// write lock.
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 10000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "payment-for-concurrent-list",
|
||||
ReferenceID: "booking-concurrent-list",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(2)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
_, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||
PaymentID: paymentResult.ID,
|
||||
Amount: 100,
|
||||
IdempotencyKey: fmt.Sprintf("refund-concurrent-%d", idx),
|
||||
Reason: "concurrent",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}(i)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := client.ListPaymentRefunds(ctx, paymentResult.ID, time.Now().Add(-time.Hour))
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
results, err := client.ListPaymentRefunds(ctx, paymentResult.ID, time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 8)
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -192,15 +194,27 @@ type sqTerminalCheckoutRequest struct {
|
||||
}
|
||||
|
||||
type sqTerminalCheckoutPayload struct {
|
||||
AmountMoney sqMoney `json:"amount_money"`
|
||||
ReferenceID string `json:"reference_id,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
CustomerID string `json:"customer_id,omitempty"`
|
||||
DeviceOptions *sqDeviceOptions `json:"device_options,omitempty"`
|
||||
AmountMoney sqMoney `json:"amount_money"`
|
||||
ReferenceID string `json:"reference_id,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
CustomerID string `json:"customer_id,omitempty"`
|
||||
DeviceOptions *sqDeviceOptions `json:"device_options,omitempty"`
|
||||
}
|
||||
|
||||
// sqTipSettings maps to Square's DeviceCheckoutOptions.tip_settings object
|
||||
// (nested INSIDE device_options — a top-level tip_settings is silently ignored
|
||||
// by Square's TerminalCheckout API, losing terminal tip revenue). Only
|
||||
// allow_tipping is emitted — Square's wire field for enabling terminal tips.
|
||||
type sqTipSettings struct {
|
||||
AllowTipping bool `json:"allow_tipping"`
|
||||
}
|
||||
|
||||
// sqDeviceOptions maps to Square's DeviceCheckoutOptions object inside the
|
||||
// TerminalCheckout payload. device_id is REQUIRED; tip_settings lives here
|
||||
// (not at the checkout top level) so terminal tips are actually collected.
|
||||
type sqDeviceOptions struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
TipSettings *sqTipSettings `json:"tip_settings,omitempty"`
|
||||
}
|
||||
|
||||
type sqTerminalCheckoutResponse struct {
|
||||
@@ -214,9 +228,11 @@ type sqTerminalCheckout struct {
|
||||
ReferenceID string `json:"reference_id,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
PaymentIDs []string `json:"payment_ids,omitempty"`
|
||||
Deadline string `json:"deadline_duration,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
// Deadline (deadline_duration) is deprecated in the TerminalCheckout API —
|
||||
// retained read-only for informational purposes; harmless when set.
|
||||
Deadline string `json:"deadline_duration,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type sqGetPaymentResponse struct {
|
||||
@@ -280,11 +296,49 @@ type sqDisableCardResponse struct {
|
||||
Card sqCard `json:"card"`
|
||||
}
|
||||
|
||||
// --- Customer types ---
|
||||
|
||||
type sqCreateCustomerRequest struct {
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
}
|
||||
|
||||
type sqCreateCustomerResponse struct {
|
||||
Customer sqCustomer `json:"customer"`
|
||||
}
|
||||
|
||||
// sqCustomer maps to Square's Customer object. Only fields this application
|
||||
// consumes are included.
|
||||
type sqCustomer struct {
|
||||
ID string `json:"id"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
GivenName string `json:"given_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Package-level HTTP functions — shared by ProdClient and devProdClient.
|
||||
// Each builds a fresh httpClient from env vars and makes the Square API call.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// validSquareID reports whether id is safe to embed in a Square REST URL path
|
||||
// segment. Square IDs are alphanumeric plus '_' and '-' and well under 64
|
||||
// characters; anything else could produce a malformed URL or enable path
|
||||
// traversal in a future caller.
|
||||
func validSquareID(id string) bool {
|
||||
if len(id) == 0 || len(id) > 64 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(id); i++ {
|
||||
c := id[i]
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func createPaymentHTTP(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
return createPaymentHTTPWithClient(ctx, req, newHTTPClient())
|
||||
}
|
||||
@@ -336,6 +390,12 @@ func createCheckoutHTTPWithClient(ctx context.Context, req CreateCheckoutReq, hc
|
||||
},
|
||||
},
|
||||
}
|
||||
// AllowTipping must reach Square as device_options.tip_settings.allow_tipping
|
||||
// — without it the terminal never prompts for a tip and tip revenue is
|
||||
// silently lost. A top-level tip_settings would be ignored by Square.
|
||||
if req.AllowTipping {
|
||||
body.Checkout.DeviceOptions.TipSettings = &sqTipSettings{AllowTipping: true}
|
||||
}
|
||||
var resp sqTerminalCheckoutResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/terminals/checkouts", body, &resp); err != nil {
|
||||
return nil, err
|
||||
@@ -348,6 +408,9 @@ func getCheckoutHTTP(ctx context.Context, checkoutID string) (*PaymentResult, er
|
||||
}
|
||||
|
||||
func getCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpClient) (*PaymentResult, error) {
|
||||
if !validSquareID(checkoutID) {
|
||||
return nil, fmt.Errorf("square: invalid checkout id %q", checkoutID)
|
||||
}
|
||||
var tcResp sqTerminalCheckoutResponse
|
||||
if err := hc.doJSON(ctx, http.MethodGet, "/v2/terminals/checkouts/"+checkoutID, nil, &tcResp); err != nil {
|
||||
return nil, err
|
||||
@@ -373,6 +436,21 @@ func getCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpC
|
||||
return paymentFromSquare(&payResp.Payment), nil
|
||||
}
|
||||
|
||||
func getPaymentHTTP(ctx context.Context, paymentID string) (*PaymentResult, error) {
|
||||
return getPaymentHTTPWithClient(ctx, paymentID, newHTTPClient())
|
||||
}
|
||||
|
||||
func getPaymentHTTPWithClient(ctx context.Context, paymentID string, hc *httpClient) (*PaymentResult, error) {
|
||||
if !validSquareID(paymentID) {
|
||||
return nil, fmt.Errorf("square: invalid payment id %q", paymentID)
|
||||
}
|
||||
var resp sqGetPaymentResponse
|
||||
if err := hc.doJSON(ctx, http.MethodGet, "/v2/payments/"+paymentID, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paymentFromSquare(&resp.Payment), nil
|
||||
}
|
||||
|
||||
// squareAPIError wraps a formatted Square API error while exposing the
|
||||
// structured Square error code so callers can classify definitive business
|
||||
// rejections (e.g. ErrRefundDeclined) vs ambiguous transport/server errors.
|
||||
@@ -385,6 +463,29 @@ type squareAPIError struct {
|
||||
func (e *squareAPIError) Error() string { return e.err.Error() }
|
||||
func (e *squareAPIError) Unwrap() error { return e.err }
|
||||
|
||||
// ErrorCode returns the Square error Code carried by err when err (or any
|
||||
// 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.
|
||||
func ErrorCode(err error) string {
|
||||
var sqErr *squareAPIError
|
||||
if errors.As(err, &sqErr) {
|
||||
return sqErr.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ErrorDetail returns the Square error Detail carried by err when err (or any
|
||||
// error it wraps) is a *squareAPIError, and "" otherwise.
|
||||
func ErrorDetail(err error) string {
|
||||
var sqErr *squareAPIError
|
||||
if errors.As(err, &sqErr) {
|
||||
return sqErr.Detail
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -448,14 +549,18 @@ func listRefundsHTTPWithClient(ctx context.Context, paymentID string, beginTime
|
||||
}
|
||||
path = base + "&cursor=" + url.QueryEscape(resp.Cursor)
|
||||
}
|
||||
return nil, fmt.Errorf("square: list refunds exceeded 20 pages (infinite loop guard)")
|
||||
// 20 pages fetched and a cursor is still present — return what we
|
||||
// collected rather than discarding partial results (the previous
|
||||
// infinite-loop guard dropped everything and returned an error).
|
||||
log.Printf("[SQUARE] list refunds exceeded 20 pages (infinite-loop guard) — returning partial results: %d refunds for %s", len(results), paymentID)
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func createCardOnFileHTTP(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
return createCardOnFileHTTPWithClient(ctx, userID, cardToken, newHTTPClient())
|
||||
func createCardOnFileHTTP(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||
return createCardOnFileHTTPWithClient(ctx, userID, cardToken, customerID, newHTTPClient())
|
||||
}
|
||||
|
||||
func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken string, hc *httpClient) (*CardOnFile, error) {
|
||||
func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken, customerID string, hc *httpClient) (*CardOnFile, error) {
|
||||
|
||||
// Deterministic idempotency key derived from user + card (not time-based)
|
||||
// so that retries with the same details don't create duplicate cards.
|
||||
@@ -467,11 +572,13 @@ func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken strin
|
||||
IdempotencyKey: "card-" + fmt.Sprintf("%x", ikHash)[:38],
|
||||
SourceID: cardToken,
|
||||
Card: sqCardPayload{
|
||||
// The app does not provision Square customers, so the local user
|
||||
// ID must NOT be sent as customer_id (Square would reject it).
|
||||
// reference_id is Square's free-form client reference, used to link
|
||||
// the card to the local user for client-side filtering.
|
||||
// the card to the local user for client-side filtering. customer_id
|
||||
// is sent when the app has provisioned a Square customer for the
|
||||
// user (Square marks customer_id Required on the Card object for
|
||||
// saved-card flows) and omitted otherwise.
|
||||
ReferenceID: userID,
|
||||
CustomerID: customerID,
|
||||
},
|
||||
}
|
||||
var resp sqCreateCardResponse
|
||||
@@ -488,9 +595,10 @@ func getCardsOnFileHTTP(ctx context.Context, userID string) ([]CardOnFile, error
|
||||
func getCardsOnFileHTTPWithClient(ctx context.Context, userID string, hc *httpClient) ([]CardOnFile, error) {
|
||||
// Filter by reference_id natively: Square's List Cards API supports the
|
||||
// reference_id query param, and cards are created with reference_id = the
|
||||
// local user ID (the app has no Square customers, so customer_id cannot be
|
||||
// used). List Cards pages at 25 cards, so loop on the cursor to avoid
|
||||
// silently truncating a large saved-card list (N-10).
|
||||
// local user ID. customer_id is not used for the filter because a user may
|
||||
// have no provisioned Square customer. List Cards pages at 25 cards, so
|
||||
// loop on the cursor to avoid silently truncating a large saved-card list
|
||||
// (N-10).
|
||||
var cards []CardOnFile
|
||||
path := "/v2/cards?reference_id=" + url.QueryEscape(userID)
|
||||
for page := 0; page < 20; page++ {
|
||||
@@ -521,6 +629,56 @@ func deleteCardOnFileHTTP(ctx context.Context, cardID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createCustomerHTTP(ctx context.Context, name, email string) (*CustomerResult, error) {
|
||||
return createCustomerHTTPWithClient(ctx, name, email, newHTTPClient())
|
||||
}
|
||||
|
||||
func createCustomerHTTPWithClient(ctx context.Context, name, email string, hc *httpClient) (*CustomerResult, error) {
|
||||
// Deterministic idempotency key derived from the email (not time-based)
|
||||
// so retries with the same email don't create duplicate customers. SHA-256
|
||||
// prevents recovering the email from the key. Truncated to ≤45 chars —
|
||||
// Square's documented idempotency-key limit.
|
||||
ikHash := sha256.Sum256([]byte(email))
|
||||
body := sqCreateCustomerRequest{
|
||||
IdempotencyKey: "customer-" + fmt.Sprintf("%x", ikHash)[:35],
|
||||
EmailAddress: email,
|
||||
GivenName: name,
|
||||
}
|
||||
var resp sqCreateCustomerResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/customers", body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CustomerResult{
|
||||
ID: resp.Customer.ID,
|
||||
Email: resp.Customer.EmailAddress,
|
||||
CreatedAt: resp.Customer.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cancelCheckoutHTTP(ctx context.Context, checkoutID string) error {
|
||||
return cancelCheckoutHTTPWithClient(ctx, checkoutID, newHTTPClient())
|
||||
}
|
||||
|
||||
func cancelCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpClient) error {
|
||||
if !validSquareID(checkoutID) {
|
||||
return fmt.Errorf("square: invalid checkout id %q", checkoutID)
|
||||
}
|
||||
var resp sqTerminalCheckoutResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/terminals/checkouts/"+checkoutID+"/cancel", nil, &resp); err != nil {
|
||||
// Square returns 404 / NOT_FOUND when the checkout is already
|
||||
// completed or canceled — that is a no-op, not a failure.
|
||||
var sqErr *squareAPIError
|
||||
if errors.As(err, &sqErr) && sqErr.Code == "NOT_FOUND" {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "HTTP 404") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversion helpers — Square JSON → domain types.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -552,16 +710,15 @@ func paymentFromSquare(sq *sqPayment) *PaymentResult {
|
||||
r.EntryMethod = cd.EntryMethod
|
||||
r.CVVStatus = cd.CVVStatus
|
||||
r.AVSStatus = cd.AVSStatus
|
||||
r.CardBrand = cd.Card.CardBrand
|
||||
r.CardLast4 = cd.Card.Last4
|
||||
// exp_month/exp_year ride on the card object — pointer set when present.
|
||||
expMonth := cd.Card.ExpMonth
|
||||
expYear := cd.Card.ExpYear
|
||||
r.ExpMonth = &expMonth
|
||||
r.ExpYear = &expYear
|
||||
if cd.Card.ID != "" {
|
||||
r.CardBrand = cd.Card.CardBrand
|
||||
r.CardLast4 = cd.Card.Last4
|
||||
r.CardFingerprint = cd.Card.Fingerprint
|
||||
r.ExpMonth = cd.Card.ExpMonth
|
||||
r.ExpYear = cd.Card.ExpYear
|
||||
} else {
|
||||
// Card details present but no card ID — still surface the brand/last4.
|
||||
r.CardBrand = cd.Card.CardBrand
|
||||
r.CardLast4 = cd.Card.Last4
|
||||
}
|
||||
}
|
||||
return r
|
||||
|
||||
@@ -506,23 +506,29 @@ func TestListRefundsHTTP_Pagination(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("page_guard_triggers_after_20_pages", func(t *testing.T) {
|
||||
t.Run("page_guard_returns_partial_results", func(t *testing.T) {
|
||||
// The 20-page guard must not discard what was already collected: it
|
||||
// logs a truncation warning and returns the partial results instead
|
||||
// of failing the reconcile with an error.
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"refunds":[],"cursor":"next"}`))
|
||||
_, _ = w.Write([]byte(`{"refunds":[{"id":"ref_x","status":"COMPLETED","amount_money":{"amount":100,"currency":"GBP"},"payment_id":"pay_partial","created_at":"2026-07-31T00:00:00Z"}],"cursor":"next"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
_, err := listRefundsHTTPWithClient(context.Background(), "pay_x", time.Now(), hc)
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeded 20 pages") {
|
||||
t.Fatalf("expected 20-page guard error, got %v", err)
|
||||
refunds, err := listRefundsHTTPWithClient(context.Background(), "pay_partial", time.Now(), hc)
|
||||
if err != nil {
|
||||
t.Fatalf("expected partial results (nil error), got %v", err)
|
||||
}
|
||||
if calls != 20 {
|
||||
t.Errorf("expected exactly 20 HTTP calls before guard, got %d", calls)
|
||||
}
|
||||
if len(refunds) != 20 {
|
||||
t.Errorf("expected 20 refunds collected across pages (one per page), got %d", len(refunds))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -548,7 +554,7 @@ func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "secret", http: srv.Client()}
|
||||
res, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "cnon:test-card", hc)
|
||||
res, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "cnon:test-card", "", hc)
|
||||
if err != nil {
|
||||
t.Fatalf("createCardOnFileHTTP failed: %v", err)
|
||||
}
|
||||
@@ -570,14 +576,14 @@ func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected card object, got %v", captured["card"])
|
||||
}
|
||||
// The local user ID goes in reference_id (free-form), NOT customer_id —
|
||||
// the app has no Square customer provisioning, and customer_id would be
|
||||
// rejected by the real Cards API (P1 regression guard).
|
||||
// The local user ID goes in reference_id (free-form); customer_id is
|
||||
// emitted only when the app has provisioned a Square customer for the user
|
||||
// (empty customerID → omitted via omitempty).
|
||||
if card["reference_id"] != "user_1" {
|
||||
t.Errorf("expected card.reference_id user_1, got %v", card["reference_id"])
|
||||
}
|
||||
if _, present := card["customer_id"]; present {
|
||||
t.Errorf("expected card.customer_id to be ABSENT (local IDs must not go in customer_id), got %v", card["customer_id"])
|
||||
t.Errorf("expected card.customer_id to be ABSENT when customerID is empty, got %v", card["customer_id"])
|
||||
}
|
||||
if gotAuth != "Bearer secret" {
|
||||
t.Errorf("expected Authorization 'Bearer secret', got %q", gotAuth)
|
||||
@@ -587,6 +593,48 @@ func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateCardOnFileHTTP_CustomerIDEmitted verifies card.customer_id is sent
|
||||
// when the app has provisioned a Square customer for the user (Square marks
|
||||
// customer_id Required on the Card object for saved-card flows).
|
||||
func TestCreateCardOnFileHTTP_CustomerIDEmitted(t *testing.T) {
|
||||
var captured map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v2/cards" {
|
||||
t.Errorf("expected /v2/cards, got %s", r.URL.Path)
|
||||
}
|
||||
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(`{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp1","customer_id":"cus_1","reference_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "secret", http: srv.Client()}
|
||||
_, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "cnon:test-card", "cus_1", hc)
|
||||
if err != nil {
|
||||
t.Fatalf("createCardOnFileHTTP failed: %v", err)
|
||||
}
|
||||
|
||||
card, ok := captured["card"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected card object, got %v", captured["card"])
|
||||
}
|
||||
if card["customer_id"] != "cus_1" {
|
||||
t.Errorf("expected card.customer_id cus_1, got %v", card["customer_id"])
|
||||
}
|
||||
// The idempotency key is derived solely from user|card, so it is identical
|
||||
// whether or not a customer_id accompanies the request.
|
||||
sum := sha256.Sum256([]byte("user_1|cnon:test-card"))
|
||||
wantIK := "card-" + fmt.Sprintf("%x", sum)[:38]
|
||||
if captured["idempotency_key"] != wantIK {
|
||||
t.Errorf("expected idempotency_key %q, got %v", wantIK, captured["idempotency_key"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCardsOnFileHTTP_ReferenceIDFilter verifies the List Cards request uses
|
||||
// the native reference_id filter (the local user ID) — not the invalid
|
||||
// customer_id — and that cards are returned unfiltered server-side.
|
||||
@@ -630,3 +678,378 @@ func TestGetCardsOnFileHTTP_ReferenceIDFilter(t *testing.T) {
|
||||
t.Errorf("unexpected cards: %+v %+v", cards[0], cards[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateCheckoutHTTP_TipSettings verifies AllowTipping is emitted as
|
||||
// checkout.device_options.tip_settings.allow_tipping (Square's wire shape for
|
||||
// enabling terminal tips) and omitted entirely when not set.
|
||||
func TestCreateCheckoutHTTP_TipSettings(t *testing.T) {
|
||||
t.Run("allow_tipping_true_emits_tip_settings", 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(`{"checkout":{"id":"chk_tip","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"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 := createCheckoutHTTPWithClient(context.Background(), CreateCheckoutReq{
|
||||
Amount: 5000, Currency: "GBP", IdempotencyKey: "ik-tip", DeviceID: "dvc_1", AllowTipping: true,
|
||||
}, hc)
|
||||
if err != nil {
|
||||
t.Fatalf("createCheckoutHTTP failed: %v", err)
|
||||
}
|
||||
checkout, ok := captured["checkout"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected checkout object, got %v", captured)
|
||||
}
|
||||
// tip_settings must NOT be at the checkout top level — a top-level
|
||||
// tip_settings is silently ignored by Square (terminal tip loss).
|
||||
if _, hasTopLevel := checkout["tip_settings"]; hasTopLevel {
|
||||
t.Errorf("tip_settings must not be top-level in terminal checkout request: %v", checkout)
|
||||
}
|
||||
// tip_settings must live under checkout.device_options
|
||||
devOpts, ok := checkout["device_options"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected checkout.device_options in body, got %v", checkout)
|
||||
}
|
||||
tipSettings, ok := devOpts["tip_settings"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected device_options.tip_settings when AllowTipping is true, got %v", devOpts)
|
||||
}
|
||||
if tipSettings["allow_tipping"] != true {
|
||||
t.Errorf("expected tip_settings.allow_tipping=true, got %v", tipSettings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allow_tipping_false_omits_tip_settings", 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(`{"checkout":{"id":"chk_notip","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"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 := createCheckoutHTTPWithClient(context.Background(), CreateCheckoutReq{
|
||||
Amount: 5000, Currency: "GBP", IdempotencyKey: "ik-notip", DeviceID: "dvc_1", AllowTipping: false,
|
||||
}, hc)
|
||||
if err != nil {
|
||||
t.Fatalf("createCheckoutHTTP failed: %v", err)
|
||||
}
|
||||
checkout, ok := captured["checkout"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected checkout object, got %v", captured)
|
||||
}
|
||||
// device_options is always present (device_id is required); only the
|
||||
// tip_settings sub-object must be absent.
|
||||
devOpts, ok := checkout["device_options"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected checkout.device_options in body, got %v", checkout)
|
||||
}
|
||||
if _, present := devOpts["tip_settings"]; present {
|
||||
t.Errorf("expected device_options.tip_settings ABSENT when AllowTipping is false, got %v", devOpts["tip_settings"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetPaymentHTTP verifies GET /v2/payments/{id} maps via paymentFromSquare
|
||||
// and that an empty payment ID errors before any HTTP call.
|
||||
func TestGetPaymentHTTP_WireShape(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/payments/pay_1" {
|
||||
t.Errorf("expected /v2/payments/pay_1, got %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"payment":{"id":"pay_1","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":"EMV"},"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()}
|
||||
res, err := getPaymentHTTPWithClient(context.Background(), "pay_1", hc)
|
||||
if err != nil {
|
||||
t.Fatalf("getPaymentHTTP failed: %v", err)
|
||||
}
|
||||
if res.ID != "pay_1" || res.Amount != 5000 || res.EntryMethod != "EMV" {
|
||||
t.Errorf("unexpected payment result: %+v", res)
|
||||
}
|
||||
|
||||
_, err = getPaymentHTTPWithClient(context.Background(), "", hc)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid payment id") {
|
||||
t.Fatalf("expected empty-ID error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPaymentHTTP_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOT_FOUND","detail":"Payment not found"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
_, err := getPaymentHTTPWithClient(context.Background(), "pay_missing", hc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for not-found payment")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateCustomerHTTP_WireShape verifies the CreateCustomer request body:
|
||||
// deterministic "customer-" + sha256(email) idempotency key (≤45 chars),
|
||||
// email_address, and given_name.
|
||||
func TestCreateCustomerHTTP_WireShape(t *testing.T) {
|
||||
var captured map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v2/customers" {
|
||||
t.Errorf("expected /v2/customers, got %s", r.URL.Path)
|
||||
}
|
||||
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(`{"customer":{"id":"cus_1","email_address":"jane@example.com","given_name":"Jane","created_at":"2026-07-31T00:00:00Z"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
res, err := createCustomerHTTPWithClient(context.Background(), "Jane", "jane@example.com", hc)
|
||||
if err != nil {
|
||||
t.Fatalf("createCustomerHTTP failed: %v", err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte("jane@example.com"))
|
||||
wantIK := "customer-" + fmt.Sprintf("%x", sum)[:35]
|
||||
if captured["idempotency_key"] != wantIK {
|
||||
t.Errorf("expected idempotency_key %q, got %v", wantIK, captured["idempotency_key"])
|
||||
}
|
||||
// Square's documented idempotency-key limit is 45 chars — the truncated
|
||||
// key must never exceed it.
|
||||
if len(wantIK) > 45 {
|
||||
t.Errorf("idempotency_key %q is %d chars, exceeds Square's 45-char limit", wantIK, len(wantIK))
|
||||
}
|
||||
if captured["email_address"] != "jane@example.com" {
|
||||
t.Errorf("expected email_address jane@example.com, got %v", captured["email_address"])
|
||||
}
|
||||
if captured["given_name"] != "Jane" {
|
||||
t.Errorf("expected given_name Jane, got %v", captured["given_name"])
|
||||
}
|
||||
if res.ID != "cus_1" || res.Email != "jane@example.com" || res.CreatedAt == "" {
|
||||
t.Errorf("unexpected customer result: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelCheckoutHTTP_NonFatalErrors verifies CancelCheckout treats
|
||||
// already-completed/unknown checkouts as a no-op: structured NOT_FOUND, plain
|
||||
// HTTP 404, and success all return nil. Genuine failures propagate.
|
||||
func TestCancelCheckoutHTTP_NonFatalErrors(t *testing.T) {
|
||||
t.Run("success_is_nil", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v2/terminals/checkouts/chk_1/cancel" {
|
||||
t.Errorf("expected cancel path, got %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"checkout":{"id":"chk_1","status":"CANCELED","amount_money":{"amount":5000,"currency":"GBP"},"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()}
|
||||
if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_1", hc); err != nil {
|
||||
t.Fatalf("expected nil for successful cancel, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("structured_not_found_is_nil", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOT_FOUND","detail":"Checkout not found or already completed"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_missing", hc); err != nil {
|
||||
t.Fatalf("expected nil for NOT_FOUND (already completed is a no-op), got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plain_404_is_nil", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte("checkout not found"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_404", hc); err != nil {
|
||||
t.Fatalf("expected nil for plain HTTP 404, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("other_error_propagates", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"INVALID_VALUE","detail":"bad"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_bad", hc); err == nil {
|
||||
t.Fatal("expected non-nil error for genuine failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("noop_code_is_error", func(t *testing.T) {
|
||||
// "NOOP" is NOT a confirmed Square error code, so it must propagate as
|
||||
// an error — only NOT_FOUND is treated as an idempotent no-op.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOOP","detail":"nothing to cancel"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
err := cancelCheckoutHTTPWithClient(context.Background(), "chk_noop", hc)
|
||||
if err == nil {
|
||||
t.Fatal("expected NOOP code to propagate as an error (NOOP is not a confirmed Square code)")
|
||||
}
|
||||
if code := ErrorCode(err); code != "NOOP" {
|
||||
t.Errorf("expected NOOP code on error, got %q", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPaymentFromSquare_ExpiryPointers verifies exp_month/exp_year are set as
|
||||
// pointers when card details are present and left nil when absent.
|
||||
func TestPaymentFromSquare_ExpiryPointers(t *testing.T) {
|
||||
t.Run("card_details_sets_pointers", func(t *testing.T) {
|
||||
p := &sqPayment{
|
||||
ID: "pay_exp", Status: "COMPLETED", TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"},
|
||||
CardDetails: &sqCardDetails{
|
||||
Card: sqCard{ID: "ccof_x", CardBrand: "VISA", Last4: "4242", ExpMonth: 12, ExpYear: 2030},
|
||||
},
|
||||
}
|
||||
result := paymentFromSquare(p)
|
||||
if result.ExpMonth == nil || result.ExpYear == nil {
|
||||
t.Fatalf("expected non-nil expiry pointers, got %v/%v", result.ExpMonth, result.ExpYear)
|
||||
}
|
||||
if *result.ExpMonth != 12 || *result.ExpYear != 2030 {
|
||||
t.Errorf("expected exp 12/2030, got %d/%d", *result.ExpMonth, *result.ExpYear)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no_card_details_leaves_nil", func(t *testing.T) {
|
||||
p := &sqPayment{ID: "pay_noexp", Status: "COMPLETED", TotalMoney: sqMoney{Amount: 2500, Currency: "GBP"}}
|
||||
result := paymentFromSquare(p)
|
||||
if result.ExpMonth != nil || result.ExpYear != nil {
|
||||
t.Errorf("expected nil expiry without card details, got %v/%v", result.ExpMonth, result.ExpYear)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidSquareID covers the URL path-segment safety check: Square IDs are
|
||||
// alphanumeric plus '_' and '-' and at most 64 chars. Empty, over-long, and
|
||||
// any character outside that set is rejected before it can reach a URL path.
|
||||
func TestValidSquareID(t *testing.T) {
|
||||
valid := []string{
|
||||
"pay_123",
|
||||
"P1-abc",
|
||||
"chk_1",
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-", // exactly 64 chars
|
||||
}
|
||||
invalid := []string{
|
||||
"",
|
||||
"has space",
|
||||
"bad/char",
|
||||
"bad.char",
|
||||
"traversal/../..",
|
||||
strings.Repeat("a", 65),
|
||||
}
|
||||
for _, id := range valid {
|
||||
if !validSquareID(id) {
|
||||
t.Errorf("expected %q to be a valid Square ID", id)
|
||||
}
|
||||
}
|
||||
for _, id := range invalid {
|
||||
if validSquareID(id) {
|
||||
t.Errorf("expected %q to be rejected", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIDValidation_RejectsBeforeHTTP verifies getPayment/getCheckout/cancelCheckout
|
||||
// reject malformed IDs before building the request URL. The client points at an
|
||||
// unused host: a request that slipped past validation would fail with a network
|
||||
// error instead of an "invalid ... id" error, so the assertion is meaningful.
|
||||
func TestIDValidation_RejectsBeforeHTTP(t *testing.T) {
|
||||
hc := &httpClient{baseURL: "http://unused", token: "t", http: &http.Client{}}
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := getPaymentHTTPWithClient(ctx, "bad/id", hc)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid payment id") {
|
||||
t.Fatalf("expected invalid payment id error, got %v", err)
|
||||
}
|
||||
|
||||
_, err = getCheckoutHTTPWithClient(ctx, "bad/id", hc)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid checkout id") {
|
||||
t.Fatalf("expected invalid checkout id error, got %v", err)
|
||||
}
|
||||
|
||||
err = cancelCheckoutHTTPWithClient(ctx, "bad/id", hc)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid checkout id") {
|
||||
t.Fatalf("expected invalid checkout id error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorCode_ErrorDetail verifies the exported accessors surface the
|
||||
// structured Square error Code/Detail for direct and wrapped *squareAPIError
|
||||
// values, and return "" for non-Square errors (so handlers can classify charge
|
||||
// failures structurally instead of substring-matching).
|
||||
func TestErrorCode_ErrorDetail(t *testing.T) {
|
||||
t.Run("direct", func(t *testing.T) {
|
||||
base := &squareAPIError{Code: "INVALID_VALUE", Detail: "bad thing", err: errors.New("square: boom")}
|
||||
if got := ErrorCode(base); got != "INVALID_VALUE" {
|
||||
t.Errorf("expected INVALID_VALUE, got %q", got)
|
||||
}
|
||||
if got := ErrorDetail(base); got != "bad thing" {
|
||||
t.Errorf("expected detail 'bad thing', got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrapped", func(t *testing.T) {
|
||||
base := &squareAPIError{Code: "CARD_DECLINED", Detail: "card declined", err: errors.New("square: boom")}
|
||||
wrapped := fmt.Errorf("wrap: %w", base)
|
||||
if got := ErrorCode(wrapped); got != "CARD_DECLINED" {
|
||||
t.Errorf("expected CARD_DECLINED through wrap, got %q", got)
|
||||
}
|
||||
if got := ErrorDetail(wrapped); got != "card declined" {
|
||||
t.Errorf("expected detail through wrap, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non_square_error", func(t *testing.T) {
|
||||
if got := ErrorCode(errors.New("plain")); got != "" {
|
||||
t.Errorf("expected \"\", got %q", got)
|
||||
}
|
||||
if got := ErrorDetail(errors.New("plain")); got != "" {
|
||||
t.Errorf("expected \"\", got %q", got)
|
||||
}
|
||||
if got := ErrorCode(nil); got != "" {
|
||||
t.Errorf("expected \"\" for nil, got %q", got)
|
||||
}
|
||||
if got := ErrorDetail(nil); got != "" {
|
||||
t.Errorf("expected \"\" for nil, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ type CreatePaymentReq struct {
|
||||
Autocomplete *bool // nil (default) = true — complete immediately; false = approve only
|
||||
TipMoney *int64 // optional tip amount in pence
|
||||
CustomerID string // Square customer ID for card-on-file payments
|
||||
LocationID string // Square location ID (required in production)
|
||||
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
|
||||
}
|
||||
@@ -46,10 +46,14 @@ type CreateCheckoutReq struct {
|
||||
Currency string
|
||||
IdempotencyKey string
|
||||
ReferenceID string
|
||||
TipEnabled bool // mock-only: simulates tip addition during checkout
|
||||
DeviceID string // Square Terminal device ID (required in production)
|
||||
Note string // optional note for the checkout
|
||||
CustomerID string // optional Square customer ID
|
||||
// AllowTipping enables tip entry on the Square Terminal: when true, the
|
||||
// checkout payload sends device_options.tip_settings.allow_tipping=true so
|
||||
// terminal tip revenue is actually collected (previously tips were silently
|
||||
// lost in production because the payload never emitted tip settings).
|
||||
AllowTipping bool // sends device_options.tip_settings.allow_tipping=true to Square
|
||||
DeviceID string // Square Terminal device ID (required in production)
|
||||
Note string // optional note for the checkout
|
||||
CustomerID string // optional Square customer ID
|
||||
}
|
||||
|
||||
// RefundPaymentReq maps to Square's RefundPayment endpoint (POST /v2/refunds).
|
||||
@@ -58,7 +62,7 @@ type RefundPaymentReq struct {
|
||||
Amount int64 // in pence, 0 = full refund
|
||||
IdempotencyKey string
|
||||
Reason string
|
||||
LocationID string // Square location ID (required in production)
|
||||
LocationID string // Square location ID (optional; defaults to main location)
|
||||
}
|
||||
|
||||
// PaymentResult maps to the Square Payment object returned by
|
||||
@@ -74,8 +78,11 @@ type PaymentResult struct {
|
||||
CardBrand string // "VISA", "MASTERCARD", "AMERICAN_EXPRESS", "DISCOVER", etc.
|
||||
CardLast4 string
|
||||
CardFingerprint string // unique card fingerprint from Square
|
||||
ExpMonth int
|
||||
ExpYear int
|
||||
// ExpMonth/ExpYear are nil when the payment has no card details (e.g. a
|
||||
// non-card source). Square returns exp_month/exp_year only for card
|
||||
// payments, so a plain int could not distinguish 0 from an absent value.
|
||||
ExpMonth *int
|
||||
ExpYear *int
|
||||
EntryMethod string // "KEYED", "ON_FILE", "EMV", "SWIPED", "CONTACTLESS"
|
||||
CVVStatus string // "CVV_ACCEPTED", "CVV_REJECTED", "CVV_NOT_CHECKED"
|
||||
AVSStatus string // "AVS_ACCEPTED", "AVS_REJECTED", "AVS_NOT_CHECKED"
|
||||
@@ -119,7 +126,7 @@ type CardOnFile struct {
|
||||
ExpYear int
|
||||
Fingerprint string // Square card fingerprint
|
||||
CardholderName string // cardholder name (if provided)
|
||||
CustomerID string // Square customer ID this card belongs to (unused: the app does not provision Square customers)
|
||||
CustomerID string // Square customer ID this card belongs to (set when the app has provisioned a Square customer)
|
||||
ReferenceID string // Square free-form client reference — holds the local user ID for client-side filtering
|
||||
Enabled bool // whether the card is enabled (not disabled/expired)
|
||||
IsDefault bool // mock-only: first card saved for a user
|
||||
@@ -148,6 +155,16 @@ type SquareError struct {
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
// CustomerResult maps to the Square Customer object returned by
|
||||
// CreateCustomer (POST /v2/customers). Only the fields this application
|
||||
// consumes are included.
|
||||
// Reference: https://developer.squareup.com/reference/square/objects/Customer
|
||||
type CustomerResult struct {
|
||||
ID string // Square customer ID (e.g. "cus_xxx")
|
||||
Email string // customer email address
|
||||
CreatedAt string // ISO 8601 timestamp
|
||||
}
|
||||
|
||||
// SquareClient is the interface for all Square payment operations.
|
||||
// All implementations (mock, prod) must satisfy this interface.
|
||||
type SquareClient interface {
|
||||
@@ -155,10 +172,24 @@ 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)
|
||||
CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error)
|
||||
CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error)
|
||||
GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error)
|
||||
DeleteCardOnFile(ctx context.Context, cardID string) error
|
||||
|
||||
// GetPayment returns a single payment by ID. Used by the sweep reconcile
|
||||
// flow to check the authoritative payment status at Square.
|
||||
GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error)
|
||||
|
||||
// CreateCustomer provisions a Square customer (customer provisioning for
|
||||
// card-on-file payments). Square dedups on the deterministic
|
||||
// idempotency key (derived from email).
|
||||
CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error)
|
||||
|
||||
// CancelCheckout cancels a pending terminal checkout. Square returns a
|
||||
// 404 / NOT_FOUND if the checkout is already completed or canceled —
|
||||
// that is treated as a no-op, so CancelCheckout returns nil.
|
||||
CancelCheckout(ctx context.Context, checkoutID string) error
|
||||
|
||||
// ListPaymentRefunds returns the refunds Square has recorded for a payment
|
||||
// (charge), created at or after beginTime. Used to reconcile pending refund
|
||||
// rows against Square before marking them failed (money may already have
|
||||
|
||||
Reference in New Issue
Block a user