Square payment integration: real HTTP client, tip flow rewrite, card UI/validation overhaul
Backend: - Create square_http_client.go: real Square REST API client (Payments, Terminal Checkouts, Refunds, Cards, Locations) with proper JSON types, auth, error handling - Update ProdClient in square.go to delegate to shared HTTP functions - Wire devProdClient in square_dev.go to also make real HTTP calls for sandbox/prod env - Rewrite CreateTipPayment handler: accept card_id OR new_card_token (+save_card), advisory lock, idempotency check, max amount validation - Add ValidateCardInfo, bump ValidateAmount max to £10,000 - Fix mock CreateCardOnFile to detect brand/last4 from raw card numbers - Fix mock RefundPayment to index by SquarePayID and accept unknown payment IDs - Remove dead types (ProcessingFee, sqAddress), add Deadline parity - Fix AMEX brand inconsistency (AMEX -> AMERICAN_EXPRESS) - Pre-existing fix: remove unused context import in giftcards.go Frontend: - CardInput.svelte: add onfieldblur/onfieldinput callbacks for blur-based validation - CardBrandIcon.svelte: brand SVGs for VISA, MC, AMEX, Discover, Diners, JCB, Square Gift Card, UnionPay, Interac, EFTPOS - tip/+page, pay-tip/[id], UserBookingModal tip: saved card list + CardInput + Luhn/expiry/CVC validation + blur-based errors + no-saved-cards edge case - UserPaymentModal, BookingFlow: card validation parity (blur-based, all-valid check) - account page: replace text brand badges with CardBrandIcon - Fix handleCustomTip bug (state mutations outside if block) - Remove dead pageState variable - Add tip modal scroll (max-h-[90vh] overflow-y-auto) - Submit button disabled on !isCardValid Tests: - 30 square package tests (+new: CreateCardOnFile raw number path, detectCardInfo variants) - 5 tip handler tests (HappyPath, NoPriorPayment, WrongOwner, MultipleTips, TxFailure) - All +-race clean, refund tests fixed
This commit is contained in:
@@ -4,7 +4,7 @@ package square
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var Client SquareClient
|
||||
@@ -20,33 +20,33 @@ func NewProdClient() SquareClient {
|
||||
}
|
||||
|
||||
func (p *ProdClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
return nil, errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return createPaymentHTTP(ctx, req)
|
||||
}
|
||||
|
||||
func (p *ProdClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
|
||||
return nil, errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return createCheckoutHTTP(ctx, req)
|
||||
}
|
||||
|
||||
func (p *ProdClient) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) {
|
||||
return nil, errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return getCheckoutHTTP(ctx, checkoutID)
|
||||
}
|
||||
|
||||
func (p *ProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
return nil, errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return refundPaymentHTTP(ctx, req)
|
||||
}
|
||||
|
||||
func (p *ProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
return nil, errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return createCardOnFileHTTP(ctx, userID, cardToken)
|
||||
}
|
||||
|
||||
func (p *ProdClient) CreateCardOnFileRaw(ctx context.Context, userID, cardNumber string, expMonth, expYear int, cvc string) (*CardOnFile, error) {
|
||||
return nil, errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return nil, fmt.Errorf("square: raw card number input is not supported in production — use CreateCardOnFile with a card nonce")
|
||||
}
|
||||
|
||||
func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
||||
return nil, errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return getCardsOnFileHTTP(ctx, userID)
|
||||
}
|
||||
|
||||
func (p *ProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||
return errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return deleteCardOnFileHTTP(ctx, cardID)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -23,41 +24,41 @@ func mockSleep(d time.Duration) {
|
||||
}
|
||||
|
||||
type MockClient struct {
|
||||
mu sync.RWMutex
|
||||
cards map[string]map[string]*CardOnFile
|
||||
checkouts map[string]*CheckoutResult
|
||||
payments map[string]*PaymentResult
|
||||
refunds map[string]*RefundResult
|
||||
completed map[string]*PaymentResult
|
||||
HoldCheckouts bool
|
||||
ShouldFail bool // if true, CreatePayment/RefundPayment return errors for testing error paths
|
||||
mu sync.RWMutex
|
||||
cards map[string]map[string]*CardOnFile
|
||||
checkouts map[string]*CheckoutResult
|
||||
payments map[string]*PaymentResult
|
||||
refunds map[string]*RefundResult
|
||||
completed map[string]*PaymentResult
|
||||
HoldCheckouts bool
|
||||
ShouldFail bool // if true, CreatePayment/RefundPayment return errors for testing error paths
|
||||
}
|
||||
|
||||
type devProdClient struct{}
|
||||
|
||||
func (d *devProdClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
return nil, fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return createPaymentHTTP(ctx, req)
|
||||
}
|
||||
func (d *devProdClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
|
||||
return nil, fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return createCheckoutHTTP(ctx, req)
|
||||
}
|
||||
func (d *devProdClient) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) {
|
||||
return nil, fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return getCheckoutHTTP(ctx, checkoutID)
|
||||
}
|
||||
func (d *devProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
return nil, fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return refundPaymentHTTP(ctx, req)
|
||||
}
|
||||
func (d *devProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
return nil, fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return createCardOnFileHTTP(ctx, userID, cardToken)
|
||||
}
|
||||
func (d *devProdClient) CreateCardOnFileRaw(ctx context.Context, userID, cardNumber string, expMonth, expYear int, cvc string) (*CardOnFile, error) {
|
||||
return nil, fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return nil, fmt.Errorf("square: raw card number input is not supported in production — use CreateCardOnFile with a card nonce")
|
||||
}
|
||||
func (d *devProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
||||
return nil, fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return getCardsOnFileHTTP(ctx, userID)
|
||||
}
|
||||
func (d *devProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||
return fmt.Errorf("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||
return deleteCardOnFileHTTP(ctx, cardID)
|
||||
}
|
||||
|
||||
func NewClient() SquareClient {
|
||||
@@ -67,7 +68,7 @@ func NewClient() SquareClient {
|
||||
func NewDevClient() SquareClient {
|
||||
env := os.Getenv("SQUARE_ENVIRONMENT")
|
||||
if env == "sandbox" || env == "production" {
|
||||
log.Printf("[SQUARE-MOCK] SQUARE_ENVIRONMENT=%s — real client TODO stub", env)
|
||||
log.Printf("[SQUARE-PROD] SQUARE_ENVIRONMENT=%s — making real API calls to %s", env, realBaseURL(env))
|
||||
return &devProdClient{}
|
||||
}
|
||||
log.Println("[SQUARE-MOCK] Using in-memory mock client")
|
||||
@@ -80,48 +81,118 @@ func NewDevClient() SquareClient {
|
||||
}
|
||||
}
|
||||
|
||||
func detectCardInfo(sourceID string) (brand, last4 string) {
|
||||
switch sourceID {
|
||||
case "cnon:test-card":
|
||||
return "VISA", "4242"
|
||||
case "cnon:visa":
|
||||
return "VISA", "1111"
|
||||
case "cnon:mastercard":
|
||||
return "MASTERCARD", "4444"
|
||||
case "cnon:amex":
|
||||
return "AMERICAN_EXPRESS", "0005"
|
||||
default:
|
||||
return "VISA", "4242"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
if m.ShouldFail {
|
||||
return nil, fmt.Errorf("mock: payment declined (simulated failure)")
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] CreatePayment: amount=%d, reference=%s", req.Amount, req.ReferenceID)
|
||||
log.Printf("[SQUARE-MOCK] CreatePayment: amount=%d, reference=%s, source=%s", req.Amount, req.ReferenceID, req.SourceID)
|
||||
mockSleep(1 * time.Second)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
paymentID := fmt.Sprintf("pay_mock_%d", clock.Now().UnixNano())
|
||||
fees := req.Amount*14/1000 + 25 // online rate: 1.4% + 25p
|
||||
now := clock.Now().UTC()
|
||||
|
||||
status := "COMPLETED"
|
||||
if req.Autocomplete != nil && !*req.Autocomplete {
|
||||
status = "APPROVED"
|
||||
}
|
||||
|
||||
amount := req.Amount
|
||||
tipAmount := int64(0)
|
||||
if req.TipMoney != nil {
|
||||
tipAmount = *req.TipMoney
|
||||
amount += tipAmount
|
||||
}
|
||||
|
||||
cardBrand, cardLast4 := detectCardInfo(req.SourceID)
|
||||
|
||||
// Entry method: ON_FILE for card-on-file tokens, KEYED for nonces
|
||||
entryMethod := "KEYED"
|
||||
if len(req.SourceID) >= 5 && req.SourceID[:5] == "ccof:" {
|
||||
entryMethod = "ON_FILE"
|
||||
}
|
||||
|
||||
paymentID := fmt.Sprintf("pay_mock_%d", now.UnixNano())
|
||||
fees := amount*14/1000 + 25 // online rate: 1.4% + 25p
|
||||
|
||||
locationID := req.LocationID
|
||||
if locationID == "" {
|
||||
locationID = "L_MOCK"
|
||||
}
|
||||
|
||||
result := &PaymentResult{
|
||||
ID: paymentID,
|
||||
Status: "COMPLETED",
|
||||
Amount: req.Amount,
|
||||
CardBrand: "VISA",
|
||||
CardLast4: "4242",
|
||||
TipAmount: 0,
|
||||
ReceiptURL: "https://squareup.com/receipt/" + paymentID,
|
||||
SquarePayID: "sqp_" + paymentID,
|
||||
Fees: fees,
|
||||
ID: paymentID,
|
||||
Status: status,
|
||||
Amount: amount,
|
||||
CardBrand: cardBrand,
|
||||
CardLast4: cardLast4,
|
||||
CardFingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()),
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
EntryMethod: entryMethod,
|
||||
CVVStatus: "CVV_ACCEPTED",
|
||||
AVSStatus: "AVS_ACCEPTED",
|
||||
TipAmount: tipAmount,
|
||||
ReceiptURL: "https://squareup.com/receipt/" + paymentID,
|
||||
ReceiptNumber: fmt.Sprintf("RCPT_mock_%d", now.UnixNano()),
|
||||
SquarePayID: "sqp_" + paymentID,
|
||||
Fees: fees,
|
||||
BuyerEmail: req.BuyerEmail,
|
||||
CustomerID: req.CustomerID,
|
||||
LocationID: locationID,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
UpdatedAt: now.Format(time.RFC3339),
|
||||
ReferenceID: req.ReferenceID,
|
||||
}
|
||||
m.payments[paymentID] = result
|
||||
log.Printf("[SQUARE-MOCK] Payment completed: id=%s, fees=%d", paymentID, fees)
|
||||
m.payments[result.SquarePayID] = result
|
||||
log.Printf("[SQUARE-MOCK] Payment created: id=%s, status=%s, amount=%d, fees=%d", paymentID, status, amount, fees)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
checkoutID := fmt.Sprintf("chk_mock_%d", clock.Now().UnixNano())
|
||||
now := clock.Now().UTC()
|
||||
checkoutID := fmt.Sprintf("chk_mock_%d", now.UnixNano())
|
||||
|
||||
result := &CheckoutResult{
|
||||
ID: checkoutID,
|
||||
Status: "PENDING",
|
||||
ID: checkoutID,
|
||||
Status: "PENDING",
|
||||
AmountMoney: req.Amount,
|
||||
Currency: req.Currency,
|
||||
DeviceID: req.DeviceID,
|
||||
ReferenceID: req.ReferenceID,
|
||||
Note: req.Note,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
UpdatedAt: now.Format(time.RFC3339),
|
||||
Deadline: now.Add(5 * time.Minute).Format(time.RFC3339),
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.checkouts[checkoutID] = &CheckoutResult{ID: checkoutID, Status: "PENDING"}
|
||||
m.checkouts[checkoutID] = result
|
||||
m.mu.Unlock()
|
||||
|
||||
// Copy the result before spawning the goroutine to avoid data races.
|
||||
// The caller gets this copy; the goroutine modifies the map-stored original.
|
||||
resultCopy := *result
|
||||
|
||||
if !m.HoldCheckouts {
|
||||
go func() {
|
||||
defer func() {
|
||||
@@ -134,7 +205,8 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
paymentID := fmt.Sprintf("pay_%d", clock.Now().UnixNano())
|
||||
payNow := clock.Now().UTC()
|
||||
paymentID := fmt.Sprintf("pay_mock_%d", payNow.UnixNano())
|
||||
amount := req.Amount
|
||||
tipAmount := int64(0)
|
||||
if req.TipEnabled {
|
||||
@@ -144,23 +216,37 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
|
||||
fees := amount * 175 / 10000 // in-person rate: 1.75%
|
||||
|
||||
paymentResult := &PaymentResult{
|
||||
ID: paymentID,
|
||||
Status: "COMPLETED",
|
||||
Amount: amount,
|
||||
CardBrand: "VISA",
|
||||
CardLast4: "4242",
|
||||
TipAmount: tipAmount,
|
||||
ReceiptURL: "https://squareup.com/receipt/" + paymentID,
|
||||
SquarePayID: "sqp_" + paymentID,
|
||||
Fees: fees,
|
||||
ID: paymentID,
|
||||
Status: "COMPLETED",
|
||||
Amount: amount,
|
||||
CardBrand: "VISA",
|
||||
CardLast4: "4242",
|
||||
CardFingerprint: fmt.Sprintf("sqfp_mock_%d", payNow.UnixNano()),
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
EntryMethod: "EMV",
|
||||
CVVStatus: "CVV_ACCEPTED",
|
||||
AVSStatus: "AVS_ACCEPTED",
|
||||
TipAmount: tipAmount,
|
||||
ReceiptURL: "https://squareup.com/receipt/" + paymentID,
|
||||
ReceiptNumber: fmt.Sprintf("RCPT_mock_%d", payNow.UnixNano()),
|
||||
SquarePayID: "sqp_" + paymentID,
|
||||
Fees: fees,
|
||||
CustomerID: req.CustomerID,
|
||||
LocationID: "L_MOCK",
|
||||
CreatedAt: payNow.Format(time.RFC3339),
|
||||
UpdatedAt: payNow.Format(time.RFC3339),
|
||||
ReferenceID: req.ReferenceID,
|
||||
}
|
||||
m.completed[checkoutID] = paymentResult
|
||||
m.checkouts[checkoutID].Status = "COMPLETED"
|
||||
m.checkouts[checkoutID].UpdatedAt = payNow.Format(time.RFC3339)
|
||||
m.checkouts[checkoutID].PaymentIDs = []string{paymentID}
|
||||
log.Printf("[SQUARE-MOCK] Checkout completed: id=%s, amount=%d, tip=%d", checkoutID, amount, tipAmount)
|
||||
}()
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return &resultCopy, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) {
|
||||
@@ -196,27 +282,72 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
refundID := fmt.Sprintf("ref_mock_%d", clock.Now().UnixNano())
|
||||
now := clock.Now().UTC()
|
||||
refundID := fmt.Sprintf("ref_mock_%d", now.UnixNano())
|
||||
|
||||
payment, ok := m.payments[req.PaymentID]
|
||||
if !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)
|
||||
}
|
||||
|
||||
amount := req.Amount
|
||||
if amount == 0 {
|
||||
if payment, ok := m.payments[req.PaymentID]; ok {
|
||||
amount = payment.Amount
|
||||
}
|
||||
if amount == 0 && ok {
|
||||
amount = payment.Amount
|
||||
}
|
||||
|
||||
locationID := req.LocationID
|
||||
if locationID == "" {
|
||||
locationID = "L_MOCK"
|
||||
}
|
||||
|
||||
result := &RefundResult{
|
||||
ID: refundID,
|
||||
Status: "COMPLETED",
|
||||
Amount: amount,
|
||||
ID: refundID,
|
||||
Status: "COMPLETED",
|
||||
Amount: amount,
|
||||
PaymentID: req.PaymentID,
|
||||
LocationID: locationID,
|
||||
Reason: req.Reason,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
}
|
||||
m.refunds[refundID] = result
|
||||
log.Printf("[SQUARE-MOCK] Refund completed: id=%s, amount=%d", refundID, amount)
|
||||
log.Printf("[SQUARE-MOCK] Refund completed: id=%s, payment=%s, amount=%d", refundID, req.PaymentID, amount)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
|
||||
|
||||
// Detect card info from the input token.
|
||||
// Raw card numbers (digit-only, possibly with spaces) are parsed directly.
|
||||
// Nonce-like tokens (cnon:xxx etc.) use detectCardInfo for mapped values.
|
||||
cleanDigits := strings.ReplaceAll(cardToken, " ", "")
|
||||
cardBrand := "VISA"
|
||||
cardLast4 := "4242"
|
||||
cardExpMonth := 12
|
||||
cardExpYear := 2030
|
||||
|
||||
if isAllDigits(cleanDigits) && len(cleanDigits) >= 13 {
|
||||
cardLast4 = cleanDigits[len(cleanDigits)-4:]
|
||||
firstDigit := string(cleanDigits[0])
|
||||
switch firstDigit {
|
||||
case "4":
|
||||
cardBrand = "VISA"
|
||||
case "5":
|
||||
cardBrand = "MASTERCARD"
|
||||
case "3":
|
||||
cardBrand = "AMERICAN_EXPRESS"
|
||||
case "6":
|
||||
cardBrand = "DISCOVER"
|
||||
}
|
||||
} else {
|
||||
brand, last4 := detectCardInfo(cardToken)
|
||||
cardBrand = brand
|
||||
cardLast4 = last4
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -224,16 +355,22 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken str
|
||||
m.cards[userID] = make(map[string]*CardOnFile)
|
||||
}
|
||||
|
||||
cardID := fmt.Sprintf("mock_card_%d", clock.Now().UnixNano())
|
||||
now := clock.Now().UTC()
|
||||
cardID := fmt.Sprintf("mock_card_%d", now.UnixNano())
|
||||
card := &CardOnFile{
|
||||
ID: cardID,
|
||||
CardID: "cfa_" + cardID,
|
||||
Brand: "VISA",
|
||||
Last4: "4242",
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
Fingerprint: fmt.Sprintf("fp_%d", clock.Now().UnixNano()),
|
||||
IsDefault: len(m.cards[userID]) == 0,
|
||||
ID: cardID,
|
||||
CardID: fmt.Sprintf("ccof_mock_%d", now.UnixNano()),
|
||||
Brand: cardBrand,
|
||||
Last4: cardLast4,
|
||||
ExpMonth: cardExpMonth,
|
||||
ExpYear: cardExpYear,
|
||||
Fingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()),
|
||||
CardholderName: "John Doe",
|
||||
CustomerID: userID,
|
||||
Enabled: true,
|
||||
IsDefault: len(m.cards[userID]) == 0,
|
||||
Version: 1,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
}
|
||||
m.cards[userID][cardID] = card
|
||||
log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4)
|
||||
@@ -243,6 +380,10 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken str
|
||||
func (m *MockClient) CreateCardOnFileRaw(ctx context.Context, userID, cardNumber string, expMonth, expYear int, cvc string) (*CardOnFile, error) {
|
||||
log.Printf("[SQUARE-MOCK] CreateCardOnFileRaw: user=%s", userID)
|
||||
|
||||
if len(cardNumber) < 4 {
|
||||
return nil, fmt.Errorf("invalid card number: too short")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -250,23 +391,29 @@ func (m *MockClient) CreateCardOnFileRaw(ctx context.Context, userID, cardNumber
|
||||
m.cards[userID] = make(map[string]*CardOnFile)
|
||||
}
|
||||
|
||||
cardID := fmt.Sprintf("mock_card_%d", clock.Now().UnixNano())
|
||||
now := clock.Now().UTC()
|
||||
cardID := fmt.Sprintf("mock_card_%d", now.UnixNano())
|
||||
last4 := cardNumber[len(cardNumber)-4:]
|
||||
brands := map[string]string{"4": "VISA", "5": "MASTERCARD", "3": "AMEX", "6": "DISCOVER"}
|
||||
brands := map[string]string{"4": "VISA", "5": "MASTERCARD", "3": "AMERICAN_EXPRESS", "6": "DISCOVER"}
|
||||
brand := brands[string(cardNumber[0])]
|
||||
if brand == "" {
|
||||
brand = "UNKNOWN"
|
||||
}
|
||||
|
||||
card := &CardOnFile{
|
||||
ID: cardID,
|
||||
CardID: "cfa_" + cardID,
|
||||
Brand: brand,
|
||||
Last4: last4,
|
||||
ExpMonth: expMonth,
|
||||
ExpYear: expYear,
|
||||
Fingerprint: fmt.Sprintf("fp_%d", clock.Now().UnixNano()),
|
||||
IsDefault: len(m.cards[userID]) == 0,
|
||||
ID: cardID,
|
||||
CardID: fmt.Sprintf("ccof_mock_%d", now.UnixNano()),
|
||||
Brand: brand,
|
||||
Last4: last4,
|
||||
ExpMonth: expMonth,
|
||||
ExpYear: expYear,
|
||||
Fingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()),
|
||||
CardholderName: "John Doe",
|
||||
CustomerID: userID,
|
||||
Enabled: true,
|
||||
IsDefault: len(m.cards[userID]) == 0,
|
||||
Version: 1,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
}
|
||||
m.cards[userID][cardID] = card
|
||||
log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4)
|
||||
@@ -298,11 +445,21 @@ func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for userID, cards := range m.cards {
|
||||
if _, ok := cards[cardID]; ok {
|
||||
delete(m.cards[userID], cardID)
|
||||
log.Printf("[SQUARE-MOCK] Card deleted: id=%s (user=%s)", cardID, userID)
|
||||
if card, ok := cards[cardID]; ok {
|
||||
card.Enabled = false
|
||||
log.Printf("[SQUARE-MOCK] Card disabled: id=%s (user=%s)", cardID, userID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("card not found: %s", cardID)
|
||||
}
|
||||
|
||||
// isAllDigits returns true if every rune in s is an ASCII digit.
|
||||
func isAllDigits(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) > 0
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package square
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -26,29 +27,22 @@ func TestDevClient_CreatePayment_ReturnsCompleted(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := client.CreatePayment(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePayment failed: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", result.Status)
|
||||
assert.Equal(t, int64(5000), result.Amount)
|
||||
assert.Equal(t, "VISA", result.CardBrand)
|
||||
assert.Equal(t, "4242", result.CardLast4)
|
||||
assert.NotZero(t, result.Fees)
|
||||
|
||||
if result.Status != "COMPLETED" {
|
||||
t.Errorf("expected status COMPLETED, got %s", result.Status)
|
||||
}
|
||||
|
||||
if result.Amount != 5000 {
|
||||
t.Errorf("expected amount 5000, got %d", result.Amount)
|
||||
}
|
||||
|
||||
if result.CardBrand != "VISA" {
|
||||
t.Errorf("expected card brand VISA, got %s", result.CardBrand)
|
||||
}
|
||||
|
||||
if result.CardLast4 != "4242" {
|
||||
t.Errorf("expected last4 4242, got %s", result.CardLast4)
|
||||
}
|
||||
|
||||
if result.Fees == 0 {
|
||||
t.Error("expected fees to be calculated")
|
||||
}
|
||||
assert.NotEmpty(t, result.CardFingerprint)
|
||||
assert.Equal(t, 12, result.ExpMonth)
|
||||
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)
|
||||
assert.NotEmpty(t, result.ReceiptNumber)
|
||||
assert.NotEmpty(t, result.CreatedAt)
|
||||
assert.NotEmpty(t, result.LocationID)
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) {
|
||||
@@ -64,17 +58,13 @@ func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := client.CreateCheckout(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCheckout failed: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PENDING", result.Status)
|
||||
assert.NotEmpty(t, result.ID)
|
||||
|
||||
if result.Status != "PENDING" {
|
||||
t.Errorf("expected status PENDING, got %s", result.Status)
|
||||
}
|
||||
|
||||
if result.ID == "" {
|
||||
t.Error("expected checkout ID to be set")
|
||||
}
|
||||
assert.Equal(t, int64(7500), result.AmountMoney)
|
||||
assert.Equal(t, "GBP", result.Currency)
|
||||
assert.NotEmpty(t, result.CreatedAt)
|
||||
|
||||
// Poll until the background goroutine completes using assert.Eventually
|
||||
var completed *PaymentResult
|
||||
@@ -84,13 +74,11 @@ func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) {
|
||||
return getErr == nil && completed.Status == "COMPLETED"
|
||||
}, 5*time.Second, 100*time.Millisecond, "expected checkout to complete")
|
||||
|
||||
if completed.Amount != 8000 {
|
||||
t.Errorf("expected amount 8000 (7500 + 500 tip), got %d", completed.Amount)
|
||||
}
|
||||
assert.Equal(t, int64(8000), completed.Amount, "expected amount 8000 (7500 + 500 tip)")
|
||||
assert.Equal(t, int64(500), completed.TipAmount)
|
||||
|
||||
if completed.TipAmount != 500 {
|
||||
t.Errorf("expected tip 500, got %d", completed.TipAmount)
|
||||
}
|
||||
assert.NotEmpty(t, completed.CardFingerprint)
|
||||
assert.NotEmpty(t, completed.EntryMethod)
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCheckout_NoTip(t *testing.T) {
|
||||
@@ -106,17 +94,13 @@ func TestDevClient_CreateCheckout_NoTip(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := client.CreateCheckout(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCheckout failed: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PENDING", result.Status)
|
||||
assert.NotEmpty(t, result.ID)
|
||||
|
||||
if result.Status != "PENDING" {
|
||||
t.Errorf("expected status PENDING, got %s", result.Status)
|
||||
}
|
||||
|
||||
if result.ID == "" {
|
||||
t.Error("expected checkout ID to be set")
|
||||
}
|
||||
assert.Equal(t, int64(5000), result.AmountMoney)
|
||||
assert.Equal(t, "GBP", result.Currency)
|
||||
assert.NotEmpty(t, result.CreatedAt)
|
||||
|
||||
var completed *PaymentResult
|
||||
assert.Eventually(t, func() bool {
|
||||
@@ -125,13 +109,11 @@ func TestDevClient_CreateCheckout_NoTip(t *testing.T) {
|
||||
return getErr == nil && completed.Status == "COMPLETED"
|
||||
}, 5*time.Second, 100*time.Millisecond, "expected checkout to complete")
|
||||
|
||||
if completed.Amount != 5000 {
|
||||
t.Errorf("expected amount 5000 (no tip), got %d", completed.Amount)
|
||||
}
|
||||
assert.Equal(t, int64(5000), completed.Amount, "expected amount 5000 (no tip)")
|
||||
assert.Equal(t, int64(0), completed.TipAmount)
|
||||
|
||||
if completed.TipAmount != 0 {
|
||||
t.Errorf("expected tip 0, got %d", completed.TipAmount)
|
||||
}
|
||||
assert.NotEmpty(t, completed.CardFingerprint)
|
||||
assert.NotEmpty(t, completed.EntryMethod)
|
||||
}
|
||||
|
||||
func TestDevClient_RefundPayment_ReturnsCompleted(t *testing.T) {
|
||||
@@ -149,9 +131,7 @@ func TestDevClient_RefundPayment_ReturnsCompleted(t *testing.T) {
|
||||
}
|
||||
|
||||
paymentResult, err := client.CreatePayment(ctx, paymentReq)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePayment failed: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
refundReq := RefundPaymentReq{
|
||||
PaymentID: paymentResult.ID,
|
||||
@@ -161,17 +141,13 @@ func TestDevClient_RefundPayment_ReturnsCompleted(t *testing.T) {
|
||||
}
|
||||
|
||||
refundResult, err := client.RefundPayment(ctx, refundReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RefundPayment failed: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", refundResult.Status)
|
||||
assert.Equal(t, int64(5000), refundResult.Amount)
|
||||
|
||||
if refundResult.Status != "COMPLETED" {
|
||||
t.Errorf("expected status COMPLETED, got %s", refundResult.Status)
|
||||
}
|
||||
|
||||
if refundResult.Amount != 5000 {
|
||||
t.Errorf("expected amount 5000, got %d", refundResult.Amount)
|
||||
}
|
||||
assert.NotEmpty(t, refundResult.PaymentID)
|
||||
assert.Equal(t, "customer request", refundResult.Reason)
|
||||
assert.NotEmpty(t, refundResult.CreatedAt)
|
||||
}
|
||||
|
||||
func TestDevClient_CardOnFile_CreateAndGet(t *testing.T) {
|
||||
@@ -181,38 +157,22 @@ func TestDevClient_CardOnFile_CreateAndGet(t *testing.T) {
|
||||
userID := "user-test-123"
|
||||
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCardOnFile failed: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if card.ID == "" {
|
||||
t.Error("expected card ID to be set")
|
||||
}
|
||||
assert.NotEmpty(t, card.ID)
|
||||
assert.Equal(t, "VISA", card.Brand)
|
||||
assert.Equal(t, "4242", card.Last4)
|
||||
assert.True(t, card.IsDefault)
|
||||
|
||||
if card.Brand != "VISA" {
|
||||
t.Errorf("expected brand VISA, got %s", card.Brand)
|
||||
}
|
||||
|
||||
if card.Last4 != "4242" {
|
||||
t.Errorf("expected last4 4242, got %s", card.Last4)
|
||||
}
|
||||
|
||||
if !card.IsDefault {
|
||||
t.Error("expected first card to be default")
|
||||
}
|
||||
assert.True(t, card.Enabled)
|
||||
assert.NotEmpty(t, card.CardholderName)
|
||||
assert.NotEmpty(t, card.CreatedAt)
|
||||
|
||||
cards, err := client.GetCardsOnFile(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCardsOnFile failed: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if len(cards) != 1 {
|
||||
t.Errorf("expected 1 card, got %d", len(cards))
|
||||
}
|
||||
|
||||
if cards[0].ID != card.ID {
|
||||
t.Errorf("expected card ID %s, got %s", card.ID, cards[0].ID)
|
||||
}
|
||||
require.Len(t, cards, 1)
|
||||
assert.Equal(t, card.ID, cards[0].ID)
|
||||
}
|
||||
|
||||
func TestDevClient_CardOnFile_MultipleCards(t *testing.T) {
|
||||
@@ -222,31 +182,22 @@ func TestDevClient_CardOnFile_MultipleCards(t *testing.T) {
|
||||
userID := "user-test-multiple"
|
||||
|
||||
card1, err := client.CreateCardOnFile(ctx, userID, "cnon:token-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCardOnFile failed: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
card2, err := client.CreateCardOnFile(ctx, userID, "cnon:token-2")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCardOnFile failed: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, card1.Enabled)
|
||||
assert.True(t, card2.Enabled)
|
||||
assert.NotEmpty(t, card1.CreatedAt)
|
||||
assert.NotEmpty(t, card2.CreatedAt)
|
||||
|
||||
cards, err := client.GetCardsOnFile(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCardsOnFile failed: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if len(cards) != 2 {
|
||||
t.Errorf("expected 2 cards, got %d", len(cards))
|
||||
}
|
||||
|
||||
if !card1.IsDefault {
|
||||
t.Error("first card should be default")
|
||||
}
|
||||
|
||||
if card2.IsDefault {
|
||||
t.Error("second card should not be default")
|
||||
}
|
||||
require.Len(t, cards, 2)
|
||||
assert.True(t, card1.IsDefault)
|
||||
assert.False(t, card2.IsDefault)
|
||||
}
|
||||
|
||||
func TestDevClient_CardOnFile_Delete(t *testing.T) {
|
||||
@@ -256,23 +207,16 @@ func TestDevClient_CardOnFile_Delete(t *testing.T) {
|
||||
userID := "user-test-delete"
|
||||
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-delete")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCardOnFile failed: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.DeleteCardOnFile(ctx, card.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteCardOnFile failed: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
cards, err := client.GetCardsOnFile(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCardsOnFile failed: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if len(cards) != 0 {
|
||||
t.Errorf("expected 0 cards after delete, got %d", len(cards))
|
||||
}
|
||||
require.Len(t, cards, 1)
|
||||
assert.False(t, cards[0].Enabled)
|
||||
}
|
||||
|
||||
func TestDevClient_CardOnFile_DeleteNotFound(t *testing.T) {
|
||||
@@ -281,9 +225,7 @@ func TestDevClient_CardOnFile_DeleteNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.DeleteCardOnFile(ctx, "non-existent-card")
|
||||
if err == nil {
|
||||
t.Error("expected error when deleting non-existent card")
|
||||
}
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDevClient_GetCheckout_NotFound(t *testing.T) {
|
||||
@@ -292,9 +234,7 @@ func TestDevClient_GetCheckout_NotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.GetCheckout(ctx, "non-existent-checkout")
|
||||
if err == nil {
|
||||
t.Error("expected error when checkout not found")
|
||||
}
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCardOnFileRaw_Visa(t *testing.T) {
|
||||
@@ -304,6 +244,11 @@ func TestDevClient_CreateCardOnFileRaw_Visa(t *testing.T) {
|
||||
assert.Equal(t, "VISA", card.Brand)
|
||||
assert.Equal(t, "1111", card.Last4)
|
||||
assert.True(t, card.IsDefault)
|
||||
assert.True(t, card.Enabled)
|
||||
assert.Equal(t, 12, card.ExpMonth)
|
||||
assert.Equal(t, 2030, card.ExpYear)
|
||||
assert.NotEmpty(t, card.CreatedAt)
|
||||
assert.Greater(t, card.Version, int64(0))
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCardOnFileRaw_Mastercard(t *testing.T) {
|
||||
@@ -318,15 +263,25 @@ func TestDevClient_CreateCardOnFileRaw_Mastercard(t *testing.T) {
|
||||
assert.Equal(t, "MASTERCARD", card.Brand)
|
||||
assert.Equal(t, "4444", card.Last4)
|
||||
assert.False(t, card.IsDefault)
|
||||
assert.True(t, card.Enabled)
|
||||
assert.Equal(t, 12, card.ExpMonth)
|
||||
assert.Equal(t, 2030, card.ExpYear)
|
||||
assert.NotEmpty(t, card.CreatedAt)
|
||||
assert.Greater(t, card.Version, int64(0))
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCardOnFileRaw_Amex(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
card, err := client.CreateCardOnFileRaw(context.Background(), "user-raw-3", "378282246310005", 12, 2030, "123")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "AMEX", card.Brand)
|
||||
assert.Equal(t, "AMERICAN_EXPRESS", card.Brand)
|
||||
assert.Equal(t, "0005", card.Last4)
|
||||
assert.True(t, card.IsDefault)
|
||||
assert.True(t, card.Enabled)
|
||||
assert.Equal(t, 12, card.ExpMonth)
|
||||
assert.Equal(t, 2030, card.ExpYear)
|
||||
assert.NotEmpty(t, card.CreatedAt)
|
||||
assert.Greater(t, card.Version, int64(0))
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCardOnFileRaw_Discover(t *testing.T) {
|
||||
@@ -336,6 +291,11 @@ func TestDevClient_CreateCardOnFileRaw_Discover(t *testing.T) {
|
||||
assert.Equal(t, "DISCOVER", card.Brand)
|
||||
assert.Equal(t, "1117", card.Last4)
|
||||
assert.True(t, card.IsDefault)
|
||||
assert.True(t, card.Enabled)
|
||||
assert.Equal(t, 12, card.ExpMonth)
|
||||
assert.Equal(t, 2030, card.ExpYear)
|
||||
assert.NotEmpty(t, card.CreatedAt)
|
||||
assert.Greater(t, card.Version, int64(0))
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCardOnFileRaw_UnknownBrand(t *testing.T) {
|
||||
@@ -345,6 +305,11 @@ func TestDevClient_CreateCardOnFileRaw_UnknownBrand(t *testing.T) {
|
||||
assert.Equal(t, "UNKNOWN", card.Brand)
|
||||
assert.Equal(t, "9999", card.Last4)
|
||||
assert.True(t, card.IsDefault)
|
||||
assert.True(t, card.Enabled)
|
||||
assert.Equal(t, 12, card.ExpMonth)
|
||||
assert.Equal(t, 2030, card.ExpYear)
|
||||
assert.NotEmpty(t, card.CreatedAt)
|
||||
assert.Greater(t, card.Version, int64(0))
|
||||
}
|
||||
|
||||
func TestCreatePayment_ShouldFail(t *testing.T) {
|
||||
@@ -361,12 +326,8 @@ func TestCreatePayment_ShouldFail(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := client.CreatePayment(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when ShouldFail is true, got nil")
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result, got %+v", result)
|
||||
}
|
||||
require.Error(t, err, "expected error when ShouldFail is true")
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestRefundPayment_ShouldFail(t *testing.T) {
|
||||
@@ -382,12 +343,8 @@ func TestRefundPayment_ShouldFail(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := client.RefundPayment(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when ShouldFail is true, got nil")
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result, got %+v", result)
|
||||
}
|
||||
require.Error(t, err, "expected error when ShouldFail is true")
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestDevClient_ConcurrentPayments(t *testing.T) {
|
||||
@@ -407,17 +364,17 @@ func TestDevClient_ConcurrentPayments(t *testing.T) {
|
||||
Amount: int64(1000 + idx*100),
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "concurrent-key-" + string(rune('0'+idx)),
|
||||
IdempotencyKey: fmt.Sprintf("concurrent-key-%d", idx),
|
||||
ReferenceID: "booking-concurrent",
|
||||
Note: "full",
|
||||
}
|
||||
|
||||
result, err := client.CreatePayment(ctx, req)
|
||||
if err != nil {
|
||||
errors <- err
|
||||
payResult, payErr := client.CreatePayment(ctx, req)
|
||||
if payErr != nil {
|
||||
errors <- payErr
|
||||
return
|
||||
}
|
||||
results <- result
|
||||
results <- payResult
|
||||
}(i)
|
||||
}
|
||||
|
||||
@@ -426,24 +383,231 @@ func TestDevClient_ConcurrentPayments(t *testing.T) {
|
||||
close(errors)
|
||||
|
||||
errorCount := 0
|
||||
for err := range errors {
|
||||
t.Logf("Concurrent payment error: %v", err)
|
||||
for range errors {
|
||||
errorCount++
|
||||
}
|
||||
|
||||
if errorCount > 0 {
|
||||
t.Errorf("expected no errors, got %d", errorCount)
|
||||
}
|
||||
assert.Zero(t, errorCount, "expected no concurrent errors")
|
||||
|
||||
resultCount := 0
|
||||
for result := range results {
|
||||
if result.Status != "COMPLETED" {
|
||||
t.Errorf("expected status COMPLETED, got %s", result.Status)
|
||||
}
|
||||
for payResult := range results {
|
||||
assert.Equal(t, "COMPLETED", payResult.Status)
|
||||
assert.NotEmpty(t, payResult.CreatedAt)
|
||||
resultCount++
|
||||
}
|
||||
assert.Equal(t, 10, resultCount)
|
||||
}
|
||||
|
||||
if resultCount != 10 {
|
||||
t.Errorf("expected 10 results, got %d", resultCount)
|
||||
func TestDevClient_CreatePayment_WithTipMoney(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
tip := int64(1000)
|
||||
req := CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "test-key-tip",
|
||||
ReferenceID: "booking-tip",
|
||||
Note: "full",
|
||||
TipMoney: &tip,
|
||||
}
|
||||
|
||||
result, err := client.CreatePayment(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", result.Status)
|
||||
assert.Equal(t, int64(6000), result.Amount)
|
||||
assert.Equal(t, int64(1000), result.TipAmount)
|
||||
}
|
||||
|
||||
func TestDevClient_CreatePayment_AutocompleteFalse(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
autocomplete := false
|
||||
req := CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "test-key-autocomplete",
|
||||
ReferenceID: "booking-autocomplete",
|
||||
Autocomplete: &autocomplete,
|
||||
}
|
||||
|
||||
result, err := client.CreatePayment(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "APPROVED", result.Status)
|
||||
assert.Equal(t, int64(5000), result.Amount)
|
||||
}
|
||||
|
||||
func TestDevClient_CreatePayment_WithBuyerEmail(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
req := CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "test-key-email",
|
||||
ReferenceID: "booking-email",
|
||||
BuyerEmail: "test@example.com",
|
||||
}
|
||||
|
||||
result, err := client.CreatePayment(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", result.Status)
|
||||
assert.Equal(t, "test@example.com", result.BuyerEmail)
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCardOnFile_WithNewFields(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
userID := "user-new-fields"
|
||||
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, card.Enabled)
|
||||
assert.NotEmpty(t, card.CardholderName)
|
||||
assert.Equal(t, userID, card.CustomerID)
|
||||
assert.Greater(t, card.Version, int64(0))
|
||||
assert.NotEmpty(t, card.CreatedAt)
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCardOnFileRaw_WithBrandDetection(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
userID := "user-raw-brand-detect"
|
||||
|
||||
card, err := client.CreateCardOnFileRaw(ctx, userID, "4111111111111111", 12, 2030, "123")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "VISA", card.Brand)
|
||||
assert.Equal(t, "1111", card.Last4)
|
||||
assert.True(t, card.Enabled)
|
||||
assert.Equal(t, 12, card.ExpMonth)
|
||||
assert.Equal(t, 2030, card.ExpYear)
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCardOnFile_RawNumber(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cardNum string
|
||||
wantBrand string
|
||||
wantLast4 string
|
||||
}{
|
||||
{"visa formatted", "4111 1111 1111 1111", "VISA", "1111"},
|
||||
{"visa raw", "4111111111111111", "VISA", "1111"},
|
||||
{"mastercard", "5500 0000 0000 0004", "MASTERCARD", "0004"},
|
||||
{"amex", "3400 0000 0000 009", "AMERICAN_EXPRESS", "0009"},
|
||||
{"discover", "6011 0000 0000 0004", "DISCOVER", "0004"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
userID := fmt.Sprintf("user-raw-card-%s", tt.name)
|
||||
card, err := client.CreateCardOnFile(ctx, userID, tt.cardNum)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantBrand, card.Brand)
|
||||
assert.Equal(t, tt.wantLast4, card.Last4)
|
||||
assert.True(t, card.Enabled)
|
||||
assert.NotEmpty(t, card.CardholderName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevClient_DeleteCardOnFile_SoftDelete(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
userID := "user-soft-delete"
|
||||
|
||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-soft")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.DeleteCardOnFile(ctx, card.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
cards, err := client.GetCardsOnFile(ctx, userID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cards, 1)
|
||||
assert.False(t, cards[0].Enabled)
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCardOnFileRaw_TooShort(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.CreateCardOnFileRaw(ctx, "user-too-short", "123", 12, 2030, "999")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "too short")
|
||||
}
|
||||
|
||||
func TestDevClient_GetCardsOnFile_Empty(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
cards, err := client.GetCardsOnFile(ctx, "user-no-cards")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cards)
|
||||
}
|
||||
|
||||
func TestDevClient_GetCheckout_StillPending(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.HoldCheckouts = true
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.CreateCheckout(ctx, CreateCheckoutReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "pending-checkout",
|
||||
ReferenceID: "pending-ref",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PENDING", result.Status)
|
||||
|
||||
_, err = client.GetCheckout(ctx, result.ID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "pending")
|
||||
}
|
||||
|
||||
func TestDevClient_CreateCheckout_HoldCheckouts(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.HoldCheckouts = true
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.CreateCheckout(ctx, CreateCheckoutReq{
|
||||
Amount: 2500,
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: "hold-checkout",
|
||||
ReferenceID: "hold-ref",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PENDING", result.Status)
|
||||
|
||||
_, err = client.GetCheckout(ctx, result.ID)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDetectCardInfo_Variants(t *testing.T) {
|
||||
tests := []struct {
|
||||
sourceID string
|
||||
wantBrand string
|
||||
wantLast4 string
|
||||
}{
|
||||
{"cnon:test-card", "VISA", "4242"},
|
||||
{"cnon:visa", "VISA", "1111"},
|
||||
{"cnon:mastercard", "MASTERCARD", "4444"},
|
||||
{"cnon:amex", "AMERICAN_EXPRESS", "0005"},
|
||||
{"unknown-source", "VISA", "4242"},
|
||||
{"", "VISA", "4242"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.sourceID, func(t *testing.T) {
|
||||
brand, last4 := detectCardInfo(tt.sourceID)
|
||||
assert.Equal(t, tt.wantBrand, brand)
|
||||
assert.Equal(t, tt.wantLast4, last4)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
package square
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Square REST API constants.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
squareSandboxURL = "https://connect.squareupsandbox.com"
|
||||
squareProductionURL = "https://connect.squareup.com"
|
||||
squareAPIVersion = "2026-05-20"
|
||||
defaultHTTPTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP client — shared by ProdClient (!dev) and devProdClient (dev).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type httpClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
locationID string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newHTTPClient() *httpClient {
|
||||
env := os.Getenv("SQUARE_ENVIRONMENT")
|
||||
baseURL := squareSandboxURL
|
||||
if env == "production" {
|
||||
baseURL = squareProductionURL
|
||||
}
|
||||
return &httpClient{
|
||||
baseURL: baseURL,
|
||||
token: os.Getenv("SQUARE_ACCESS_TOKEN"),
|
||||
locationID: os.Getenv("SQUARE_LOCATION_ID"),
|
||||
http: &http.Client{Timeout: defaultHTTPTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *httpClient) doJSON(ctx context.Context, method, path string, body, target any) error {
|
||||
if c.token == "" {
|
||||
return fmt.Errorf("square: SQUARE_ACCESS_TOKEN is not set")
|
||||
}
|
||||
var reqBody []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
reqBody, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("square: marshal request: %w", err)
|
||||
}
|
||||
}
|
||||
url := c.baseURL + path
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("square: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Square-Version", squareAPIVersion)
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("square: %s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("square: read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
var errResp struct{ Errors []SquareError `json:"errors"` }
|
||||
if json.Unmarshal(respBody, &errResp) == nil && len(errResp.Errors) > 0 {
|
||||
se := errResp.Errors[0]
|
||||
return fmt.Errorf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, se.Detail, se.Field)
|
||||
}
|
||||
return fmt.Errorf("square: %s %s: HTTP %d: %s", method, path, resp.StatusCode, string(respBody))
|
||||
}
|
||||
if target != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, target); err != nil {
|
||||
return fmt.Errorf("square: unmarshal response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Square JSON types — exact wire-format match with Square's REST API.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type sqMoney struct {
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// --- Payment types ---
|
||||
|
||||
type sqCreatePaymentRequest struct {
|
||||
SourceID string `json:"source_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
AmountMoney sqMoney `json:"amount_money"`
|
||||
Autocomplete *bool `json:"autocomplete,omitempty"`
|
||||
LocationID string `json:"location_id,omitempty"`
|
||||
ReferenceID string `json:"reference_id,omitempty"`
|
||||
CustomerID string `json:"customer_id,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
TipMoney *sqMoney `json:"tip_money,omitempty"`
|
||||
VerificationToken string `json:"verification_token,omitempty"`
|
||||
BuyerEmailAddress string `json:"buyer_email_address,omitempty"`
|
||||
}
|
||||
|
||||
type sqCreatePaymentResponse struct {
|
||||
Payment sqPayment `json:"payment"`
|
||||
}
|
||||
|
||||
type sqPayment struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
TotalMoney sqMoney `json:"total_money"`
|
||||
TipMoney *sqMoney `json:"tip_money,omitempty"`
|
||||
SourceType string `json:"source_type"`
|
||||
CardDetails *sqCardDetails `json:"card_details,omitempty"`
|
||||
LocationID string `json:"location_id"`
|
||||
OrderID string `json:"order_id,omitempty"`
|
||||
ReferenceID string `json:"reference_id,omitempty"`
|
||||
CustomerID string `json:"customer_id,omitempty"`
|
||||
BuyerEmail string `json:"buyer_email_address,omitempty"`
|
||||
ReceiptNumber string `json:"receipt_number,omitempty"`
|
||||
ReceiptURL string `json:"receipt_url,omitempty"`
|
||||
ProcessingFee []sqFee `json:"processing_fee,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type sqCardDetails struct {
|
||||
Card sqCard `json:"card"`
|
||||
EntryMethod string `json:"entry_method"`
|
||||
CVVStatus string `json:"cvv_status,omitempty"`
|
||||
AVSStatus string `json:"avs_status,omitempty"`
|
||||
}
|
||||
|
||||
type sqCard struct {
|
||||
ID string `json:"id"`
|
||||
CardBrand string `json:"card_brand"`
|
||||
Last4 string `json:"last_4"`
|
||||
ExpMonth int `json:"exp_month"`
|
||||
ExpYear int `json:"exp_year"`
|
||||
CardholderName string `json:"cardholder_name,omitempty"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
CustomerID string `json:"customer_id,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type sqFee struct {
|
||||
Amount int64 `json:"amount"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// --- Terminal Checkout types ---
|
||||
|
||||
type sqTerminalCheckoutRequest struct {
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
Checkout sqTerminalCheckoutPayload `json:"checkout"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type sqTerminalCheckoutResponse struct {
|
||||
Checkout sqTerminalCheckout `json:"checkout"`
|
||||
}
|
||||
|
||||
type sqTerminalCheckout struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
AmountMoney sqMoney `json:"amount_money"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type sqGetPaymentResponse struct {
|
||||
Payment sqPayment `json:"payment"`
|
||||
}
|
||||
|
||||
// --- Refund types ---
|
||||
|
||||
type sqRefundPaymentRequest struct {
|
||||
PaymentID string `json:"payment_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
AmountMoney sqMoney `json:"amount_money"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type sqRefundPaymentResponse struct {
|
||||
Refund sqRefund `json:"refund"`
|
||||
}
|
||||
|
||||
type sqRefund struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
AmountMoney sqMoney `json:"amount_money"`
|
||||
PaymentID string `json:"payment_id"`
|
||||
LocationID string `json:"location_id"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// --- Card types ---
|
||||
|
||||
type sqCreateCardRequest struct {
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
SourceID string `json:"source_id"`
|
||||
Card sqCardPayload `json:"card"`
|
||||
}
|
||||
|
||||
type sqCardPayload struct {
|
||||
ExpMonth int `json:"exp_month"`
|
||||
ExpYear int `json:"exp_year"`
|
||||
CardholderName string `json:"cardholder_name,omitempty"`
|
||||
CustomerID string `json:"customer_id,omitempty"`
|
||||
}
|
||||
|
||||
type sqCreateCardResponse struct {
|
||||
Card sqCard `json:"card"`
|
||||
}
|
||||
|
||||
type sqListCardsResponse struct {
|
||||
Cards []sqCard `json:"cards"`
|
||||
}
|
||||
|
||||
type sqDisableCardResponse struct {
|
||||
Card sqCard `json:"card"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Package-level HTTP functions — shared by ProdClient and devProdClient.
|
||||
// Each builds a fresh httpClient from env vars and makes the Square API call.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func createPaymentHTTP(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
hc := newHTTPClient()
|
||||
body := sqCreatePaymentRequest{
|
||||
SourceID: req.SourceID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
AmountMoney: sqMoney{Amount: req.Amount, Currency: req.Currency},
|
||||
Autocomplete: req.Autocomplete,
|
||||
LocationID: firstNonEmpty(req.LocationID, hc.locationID),
|
||||
ReferenceID: req.ReferenceID,
|
||||
CustomerID: req.CustomerID,
|
||||
Note: req.Note,
|
||||
VerificationToken: req.VerificationToken,
|
||||
BuyerEmailAddress: req.BuyerEmail,
|
||||
}
|
||||
if req.TipMoney != nil {
|
||||
body.TipMoney = &sqMoney{Amount: *req.TipMoney, Currency: req.Currency}
|
||||
}
|
||||
var resp sqCreatePaymentResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/payments", body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paymentFromSquare(&resp.Payment), nil
|
||||
}
|
||||
|
||||
func createCheckoutHTTP(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
|
||||
hc := newHTTPClient()
|
||||
body := sqTerminalCheckoutRequest{
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Checkout: sqTerminalCheckoutPayload{
|
||||
AmountMoney: sqMoney{Amount: req.Amount, Currency: req.Currency},
|
||||
ReferenceID: req.ReferenceID,
|
||||
Note: req.Note,
|
||||
CustomerID: req.CustomerID,
|
||||
},
|
||||
DeviceID: req.DeviceID,
|
||||
}
|
||||
var resp sqTerminalCheckoutResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/terminals/checkouts", body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return checkoutFromSquare(&resp.Checkout), nil
|
||||
}
|
||||
|
||||
func getCheckoutHTTP(ctx context.Context, checkoutID string) (*PaymentResult, error) {
|
||||
hc := newHTTPClient()
|
||||
var tcResp sqTerminalCheckoutResponse
|
||||
if err := hc.doJSON(ctx, http.MethodGet, "/v2/terminals/checkouts/"+checkoutID, nil, &tcResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tc := tcResp.Checkout
|
||||
if tc.Status != "COMPLETED" {
|
||||
return nil, fmt.Errorf("square: checkout %s is %s (not COMPLETED)", checkoutID, tc.Status)
|
||||
}
|
||||
if len(tc.PaymentIDs) == 0 {
|
||||
return nil, fmt.Errorf("square: checkout %s has no payment IDs", checkoutID)
|
||||
}
|
||||
var payResp sqGetPaymentResponse
|
||||
if err := hc.doJSON(ctx, http.MethodGet, "/v2/payments/"+tc.PaymentIDs[0], nil, &payResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paymentFromSquare(&payResp.Payment), nil
|
||||
}
|
||||
|
||||
func refundPaymentHTTP(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
hc := newHTTPClient()
|
||||
body := sqRefundPaymentRequest{
|
||||
PaymentID: req.PaymentID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
AmountMoney: sqMoney{Amount: req.Amount, Currency: "GBP"},
|
||||
Reason: req.Reason,
|
||||
}
|
||||
var resp sqRefundPaymentResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/refunds", body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return refundFromSquare(&resp.Refund), nil
|
||||
}
|
||||
|
||||
func createCardOnFileHTTP(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
hc := newHTTPClient()
|
||||
body := sqCreateCardRequest{
|
||||
IdempotencyKey: fmt.Sprintf("create-card-%d", time.Now().UnixNano()),
|
||||
SourceID: cardToken,
|
||||
Card: sqCardPayload{
|
||||
CustomerID: userID,
|
||||
ExpMonth: 0,
|
||||
ExpYear: 0,
|
||||
},
|
||||
}
|
||||
var resp sqCreateCardResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/cards", body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cardFromSquare(&resp.Card, userID), nil
|
||||
}
|
||||
|
||||
func getCardsOnFileHTTP(ctx context.Context, userID string) ([]CardOnFile, error) {
|
||||
hc := newHTTPClient()
|
||||
var resp sqListCardsResponse
|
||||
if err := hc.doJSON(ctx, http.MethodGet, "/v2/cards?customer_id="+userID, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards := make([]CardOnFile, 0, len(resp.Cards))
|
||||
for i := range resp.Cards {
|
||||
cards = append(cards, *cardFromSquare(&resp.Cards[i], userID))
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func deleteCardOnFileHTTP(ctx context.Context, cardID string) error {
|
||||
hc := newHTTPClient()
|
||||
var resp sqDisableCardResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/cards/"+cardID+"/disable", nil, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversion helpers — Square JSON → domain types.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func paymentFromSquare(sq *sqPayment) *PaymentResult {
|
||||
r := &PaymentResult{
|
||||
ID: sq.ID,
|
||||
Status: sq.Status,
|
||||
Amount: sq.TotalMoney.Amount,
|
||||
ReceiptURL: sq.ReceiptURL,
|
||||
ReceiptNumber: sq.ReceiptNumber,
|
||||
SquarePayID: sq.ID,
|
||||
BuyerEmail: sq.BuyerEmail,
|
||||
CustomerID: sq.CustomerID,
|
||||
LocationID: sq.LocationID,
|
||||
CreatedAt: sq.CreatedAt,
|
||||
UpdatedAt: sq.UpdatedAt,
|
||||
OrderID: sq.OrderID,
|
||||
ReferenceID: sq.ReferenceID,
|
||||
}
|
||||
if sq.TipMoney != nil {
|
||||
r.TipAmount = sq.TipMoney.Amount
|
||||
}
|
||||
for _, f := range sq.ProcessingFee {
|
||||
r.Fees += f.Amount
|
||||
}
|
||||
if sq.CardDetails != nil {
|
||||
cd := sq.CardDetails
|
||||
r.EntryMethod = cd.EntryMethod
|
||||
r.CVVStatus = cd.CVVStatus
|
||||
r.AVSStatus = cd.AVSStatus
|
||||
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
|
||||
}
|
||||
}
|
||||
if r.CardBrand == "" {
|
||||
r.CardBrand = sq.SourceType
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func checkoutFromSquare(sq *sqTerminalCheckout) *CheckoutResult {
|
||||
return &CheckoutResult{
|
||||
ID: sq.ID,
|
||||
Status: sq.Status,
|
||||
AmountMoney: sq.AmountMoney.Amount,
|
||||
Currency: sq.AmountMoney.Currency,
|
||||
DeviceID: sq.DeviceID,
|
||||
ReferenceID: sq.ReferenceID,
|
||||
Note: sq.Note,
|
||||
PaymentIDs: sq.PaymentIDs,
|
||||
Deadline: sq.Deadline,
|
||||
CreatedAt: sq.CreatedAt,
|
||||
UpdatedAt: sq.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func refundFromSquare(sq *sqRefund) *RefundResult {
|
||||
return &RefundResult{
|
||||
ID: sq.ID,
|
||||
Status: sq.Status,
|
||||
Amount: sq.AmountMoney.Amount,
|
||||
PaymentID: sq.PaymentID,
|
||||
LocationID: sq.LocationID,
|
||||
Reason: sq.Reason,
|
||||
CreatedAt: sq.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func cardFromSquare(sq *sqCard, userID string) *CardOnFile {
|
||||
customerID := sq.CustomerID
|
||||
if customerID == "" {
|
||||
customerID = userID
|
||||
}
|
||||
return &CardOnFile{
|
||||
ID: sq.ID,
|
||||
CardID: sq.ID,
|
||||
Brand: sq.CardBrand,
|
||||
Last4: sq.Last4,
|
||||
ExpMonth: sq.ExpMonth,
|
||||
ExpYear: sq.ExpYear,
|
||||
Fingerprint: sq.Fingerprint,
|
||||
CardholderName: sq.CardholderName,
|
||||
CustomerID: customerID,
|
||||
Enabled: sq.Enabled,
|
||||
Version: sq.Version,
|
||||
CreatedAt: sq.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func realBaseURL(env string) string {
|
||||
if env == "production" {
|
||||
return squareProductionURL
|
||||
}
|
||||
return squareSandboxURL
|
||||
}
|
||||
@@ -2,64 +2,135 @@ package square
|
||||
|
||||
import "context"
|
||||
|
||||
// CreatePaymentReq maps to Square's CreatePayment endpoint (POST /v2/payments).
|
||||
// Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment
|
||||
type CreatePaymentReq struct {
|
||||
Amount int64 // in pence (GBP cents)
|
||||
Currency string // "GBP"
|
||||
SourceID string // card token or "cnon:xxx" nonce
|
||||
IdempotencyKey string
|
||||
ReferenceID string // booking ID
|
||||
Note string
|
||||
Amount int64 // in pence (GBP cents)
|
||||
Currency string // "GBP"
|
||||
SourceID string // card token ("cnon:xxx" nonce) or card-on-file ID
|
||||
IdempotencyKey string
|
||||
ReferenceID string // booking ID or other reference
|
||||
Note string
|
||||
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)
|
||||
VerificationToken string // 3DS / SCA verification token from buyer verification
|
||||
BuyerEmail string // buyer email for receipt
|
||||
}
|
||||
|
||||
// CreateCheckoutReq maps to Square's CreateTerminalCheckout endpoint
|
||||
// (POST /v2/terminals/checkouts). In production this sends a payment
|
||||
// request to a physical Square Terminal device.
|
||||
type CreateCheckoutReq struct {
|
||||
Amount int64
|
||||
Currency string
|
||||
IdempotencyKey string
|
||||
ReferenceID string
|
||||
TipEnabled bool
|
||||
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
|
||||
}
|
||||
|
||||
// RefundPaymentReq maps to Square's RefundPayment endpoint (POST /v2/refunds).
|
||||
type RefundPaymentReq struct {
|
||||
PaymentID string
|
||||
Amount int64 // in pence, 0 = full refund
|
||||
Amount int64 // in pence, 0 = full refund
|
||||
IdempotencyKey string
|
||||
Reason string
|
||||
LocationID string // Square location ID (required in production)
|
||||
}
|
||||
|
||||
// PaymentResult maps to the Square Payment object returned by
|
||||
// CreatePayment and GetPayment. It includes the most commonly used
|
||||
// fields from the real Square Payment JSON response.
|
||||
// Fields not used by this application are omitted for simplicity.
|
||||
//
|
||||
// Reference: https://developer.squareup.com/reference/square/objects/Payment
|
||||
type PaymentResult struct {
|
||||
ID string
|
||||
Status string // "COMPLETED", "FAILED", "PENDING"
|
||||
Amount int64
|
||||
CardBrand string
|
||||
CardLast4 string
|
||||
TipAmount int64
|
||||
ReceiptURL string
|
||||
SquarePayID string // Square's payment ID
|
||||
Fees int64 // processing fee in pence
|
||||
ID string // Square payment ID (e.g. "pay_xxx")
|
||||
Status string // "APPROVED", "COMPLETED", "FAILED", "CANCELED"
|
||||
Amount int64 // total amount charged in pence (including tip)
|
||||
CardBrand string // "VISA", "MASTERCARD", "AMERICAN_EXPRESS", "DISCOVER", etc.
|
||||
CardLast4 string
|
||||
CardFingerprint string // unique card fingerprint from Square
|
||||
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"
|
||||
TipAmount int64 // tip portion in pence
|
||||
ReceiptURL string // link to Square hosted receipt
|
||||
ReceiptNumber string // Square receipt number
|
||||
SquarePayID string // Square's payment ID (same as ID in production)
|
||||
Fees int64 // total processing fee in pence
|
||||
BuyerEmail string // buyer email (if provided)
|
||||
CustomerID string // Square customer ID (if linked)
|
||||
LocationID string // Square location ID where payment was processed
|
||||
CreatedAt string // ISO 8601 timestamp
|
||||
UpdatedAt string // ISO 8601 timestamp
|
||||
OrderID string // Square order ID (if linked to an order)
|
||||
ReferenceID string // client-specified reference (booking ID etc.)
|
||||
}
|
||||
|
||||
// CheckoutResult maps to Square's TerminalCheckout object.
|
||||
// Reference: https://developer.squareup.com/reference/square/objects/TerminalCheckout
|
||||
type CheckoutResult struct {
|
||||
ID string
|
||||
Status string // "PENDING", "COMPLETED", "FAILED"
|
||||
ID string // checkout ID (e.g. "chk_xxx")
|
||||
Status string // "PENDING", "IN_PROGRESS", "COMPLETED", "CANCELED", "FAILED"
|
||||
AmountMoney int64 // checkout amount in pence
|
||||
Currency string // "GBP"
|
||||
DeviceID string // terminal device ID
|
||||
ReferenceID string // client reference
|
||||
Note string // optional note
|
||||
PaymentIDs []string // payment ID(s) once completed
|
||||
CreatedAt string // ISO 8601 timestamp
|
||||
UpdatedAt string // ISO 8601 timestamp
|
||||
Deadline string // ISO 8601 deadline duration
|
||||
}
|
||||
|
||||
// CardOnFile maps to Square's Card object from the Cards API.
|
||||
// Reference: https://developer.squareup.com/reference/square/objects/Card
|
||||
type CardOnFile struct {
|
||||
ID string
|
||||
CardID string // Square's card-on-file token
|
||||
Brand string
|
||||
Last4 string
|
||||
ExpMonth int
|
||||
ExpYear int
|
||||
Fingerprint string
|
||||
IsDefault bool
|
||||
ID string // local ID
|
||||
CardID string // Square's card ID (e.g. "ccof:xxx")
|
||||
Brand string // "VISA", "MASTERCARD", etc.
|
||||
Last4 string
|
||||
ExpMonth int
|
||||
ExpYear int
|
||||
Fingerprint string // Square card fingerprint
|
||||
CardholderName string // cardholder name (if provided)
|
||||
CustomerID string // Square customer ID this card belongs to
|
||||
Enabled bool // whether the card is enabled (not disabled/expired)
|
||||
IsDefault bool // mock-only: first card saved for a user
|
||||
BillingAddress string // billing address (simplified)
|
||||
Version int64 // Square card version token for updates
|
||||
CreatedAt string // ISO 8601 timestamp
|
||||
}
|
||||
|
||||
// RefundResult maps to Square's Refund object.
|
||||
// Reference: https://developer.squareup.com/reference/square/objects/Refund
|
||||
type RefundResult struct {
|
||||
ID string
|
||||
Status string
|
||||
Amount int64
|
||||
ID string // Square refund ID (e.g. "ref_xxx")
|
||||
Status string // "PENDING", "COMPLETED", "FAILED"
|
||||
Amount int64 // refund amount in pence
|
||||
PaymentID string // original payment being refunded
|
||||
LocationID string // location where refund was processed
|
||||
Reason string // reason for refund
|
||||
CreatedAt string // ISO 8601 timestamp
|
||||
}
|
||||
|
||||
// SquareError matches the Square API error response format.
|
||||
type SquareError struct {
|
||||
Category string `json:"category"`
|
||||
Code string `json:"code"`
|
||||
Detail string `json:"detail"`
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
// SquareClient is the interface for all Square payment operations.
|
||||
// All implementations (mock, prod) must satisfy this interface.
|
||||
type SquareClient interface {
|
||||
CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error)
|
||||
CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error)
|
||||
|
||||
Reference in New Issue
Block a user