From 4abcb324c9f7e7bd259d6b8312967be321893204 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 31 Jul 2026 00:08:45 +0100 Subject: [PATCH] Square payment integration: real HTTP client, tip flow rewrite, card UI/validation overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/handlers/payments/giftcards.go | 1 - backend/handlers/payments/handlers.go | 137 ++++- backend/handlers/payments/payments_test.go | 23 +- backend/handlers/payments/validators.go | 5 +- backend/internal/square/square.go | 18 +- backend/internal/square/square_dev.go | 309 ++++++++--- backend/internal/square/square_dev_test.go | 488 ++++++++++++------ backend/internal/square/square_http_client.go | 488 ++++++++++++++++++ backend/internal/square/types.go | 131 +++-- .../account/UserBookingModal.svelte | 247 ++++++++- .../lib/components/booking/BookingFlow.svelte | 64 +++ .../components/payments/CardBrandIcon.svelte | 39 ++ .../lib/components/payments/CardInput.svelte | 20 +- .../payments/UserPaymentModal.svelte | 35 +- frontend/src/routes/account/+page.svelte | 17 +- frontend/src/routes/pay-tip/[id]/+page.svelte | 258 ++++++++- frontend/src/routes/tip/+page.svelte | 249 ++++++++- 17 files changed, 2171 insertions(+), 358 deletions(-) create mode 100644 backend/internal/square/square_http_client.go create mode 100644 frontend/src/lib/components/payments/CardBrandIcon.svelte diff --git a/backend/handlers/payments/giftcards.go b/backend/handlers/payments/giftcards.go index 999fa57..d77324c 100644 --- a/backend/handlers/payments/giftcards.go +++ b/backend/handlers/payments/giftcards.go @@ -1,7 +1,6 @@ package payments import ( - "context" "database/sql" "encoding/json" "errors" diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index f987c70..8368017 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -47,8 +47,10 @@ type RefundRequest struct { } type CreateTipPaymentRequest struct { - Amount int64 `json:"amount" validate:"required,gt=0"` - CardToken string `json:"card_token" validate:"required"` + Amount int64 `json:"amount" validate:"required,gt=0"` + CardID *string `json:"card_id,omitempty"` + NewCardToken *string `json:"new_card_token,omitempty"` + SaveCard bool `json:"save_card"` } type CheckoutResponse struct { @@ -1757,17 +1759,15 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { return } - // M8 - // L5 - if err := ValidateAmount(req.Amount); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } - if req.CardToken == "" { - http.Error(w, "Card token is required", http.StatusBadRequest) + if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil { + log.Printf("Failed to process request: %v", err) + http.Error(w, "Invalid request", http.StatusBadRequest) return } @@ -1803,21 +1803,75 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { idempotencyKey := bookingID + "-tip-" + strconv.FormatInt(req.Amount, 10) - // Step 1: Insert payment record in 'pending' state inside a DB transaction. - // Square is NOT called yet — if the tx fails, no harm done. - record := PaymentRecord{ - BookingID: bookingID, - PaymentType: "tip", - PaymentMethod: "online_square", - Status: "pending", - Amount: float64(req.Amount) / 100.0, - IdempotencyKey: &idempotencyKey, - Fees: 0, - CreatedAt: clock.Now(), - UpdatedAt: clock.Now(), - CreatedBy: &userID, + // Resolve the card source ID — same pattern as CreateBookingPayment. + var sourceID string + var savedCardID *string + + if req.NewCardToken != nil && *req.NewCardToken != "" { + cardOnFile, err := SquareClient.CreateCardOnFile(r.Context(), userID, *req.NewCardToken) + if err != nil { + log.Printf("Failed to create card on file: %v", err) + http.Error(w, "Failed to process card", http.StatusInternalServerError) + return + } + + sourceID = cardOnFile.CardID + + if req.SaveCard { + cardID, err := service.SaveCardForUser(r.Context(), userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint) + if err != nil { + log.Printf("Failed to save card: %v", err) + } else { + savedCardID = &cardID + } + } + if savedCardID == nil && req.SaveCard { + log.Printf("Card was not saved despite save_card=true for user %s", userID) + } + } else if req.CardID != nil { + card, err := service.GetCardByID(r.Context(), *req.CardID, userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Card not found", http.StatusNotFound) + return + } + log.Printf("Failed to get card: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + sourceID = card.SquareCardID + savedCardID = req.CardID } + // Serialize tip attempts for this booking to prevent concurrent duplicate + // tip payments across browser tabs or retries. Uses a PostgreSQL session-level + // advisory lock scoped to the booking ID. + // See CreateBookingPayment lines 815-846 for the same pattern. + pinConn, err := db.Conn.Acquire(r.Context()) + if err != nil { + log.Printf("Failed to acquire connection for tip lock: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer pinConn.Release() + + if _, err := pinConn.Exec(r.Context(), ` + SELECT pg_advisory_lock(hashtext('crussell:tip:' || $1)) + `, bookingID); err != nil { + log.Printf("Failed to acquire tip serialization lock for %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer func() { + if _, err := pinConn.Exec(context.Background(), ` + SELECT pg_advisory_unlock(hashtext('crussell:tip:' || $1)) + `, bookingID); err != nil { + log.Printf("Failed to release tip serialization lock for %s: %v", bookingID, err) + } + }() + + // Step 1: Insert payment record in 'pending' state inside a DB transaction. + // Square is NOT called yet — if the tx fails, no harm done. tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to begin transaction: %v", err) @@ -1830,6 +1884,47 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { } }() + // Check idempotency inside the transaction — same pattern as CreateBookingPayment. + var existingID sql.NullString + var existingBookingID sql.NullString + var existingPaymentType sql.NullString + var existingStatus sql.NullString + var existingAmount sql.NullFloat64 + var existingCreatedAt sql.NullTime + if err := tx.QueryRow(r.Context(), ` + SELECT id, booking_id, payment_type, status, amount, created_at + FROM payments + WHERE booking_id = $1 AND idempotency_key = $2 + `, bookingID, idempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt); err == nil { + if err := json.NewEncoder(w).Encode(PaymentResponse{ + ID: existingID.String, + BookingID: existingBookingID.String, + PaymentType: existingPaymentType.String, + Status: existingStatus.String, + Amount: int64(existingAmount.Float64 * 100), + CreatedAt: existingCreatedAt.Time.Format(time.RFC3339), + }); err != nil { + log.Printf("Failed to encode JSON response: %v", err) + } + return + } else if !errors.Is(err, pgx.ErrNoRows) { + log.Printf("Failed to check tip idempotency: %v", err) + } + + record := PaymentRecord{ + BookingID: bookingID, + PaymentType: "tip", + PaymentMethod: "online_square", + Status: "pending", + Amount: float64(req.Amount) / 100.0, + IdempotencyKey: &idempotencyKey, + Fees: 0, + UserSavedCardID: savedCardID, + CreatedAt: clock.Now(), + UpdatedAt: clock.Now(), + CreatedBy: &userID, + } + paymentID, err := service.CreatePaymentRecordTx(r.Context(), tx, record, nil) if err != nil { log.Printf("Failed to create payment record: %v", err) @@ -1849,7 +1944,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { paymentReq := square.CreatePaymentReq{ Amount: req.Amount, Currency: "GBP", - SourceID: req.CardToken, + SourceID: sourceID, IdempotencyKey: idempotencyKey, ReferenceID: bookingID, Note: "tip", diff --git a/backend/handlers/payments/payments_test.go b/backend/handlers/payments/payments_test.go index c98394f..44494ff 100644 --- a/backend/handlers/payments/payments_test.go +++ b/backend/handlers/payments/payments_test.go @@ -699,8 +699,8 @@ func TestTipPayment_HappyPath(t *testing.T) { cardToken := "cnon:tip-card" req := CreateTipPaymentRequest{ - Amount: 500, - CardToken: cardToken, + Amount: 500, + NewCardToken: &cardToken, } handler := CreateTipPayment @@ -734,8 +734,8 @@ func TestTipPayment_NoPriorPayment(t *testing.T) { cardToken := "cnon:tip-card" req := CreateTipPaymentRequest{ - Amount: 500, - CardToken: cardToken, + Amount: 500, + NewCardToken: &cardToken, } handler := CreateTipPayment @@ -1806,9 +1806,10 @@ func TestTipPayment_WrongOwnerRejected(t *testing.T) { otherToken := jwt.GenerateUserToken(otherUserID) + cardToken := "cnon:wrong-owner-tip" req := CreateTipPaymentRequest{ - Amount: 500, - CardToken: "cnon:wrong-owner-tip", + Amount: 500, + NewCardToken: &cardToken, } handler := CreateTipPayment @@ -1833,9 +1834,10 @@ func TestTipPayment_MultipleTipsAllowed(t *testing.T) { userToken := jwt.GenerateUserToken(userID) for i := 0; i < 3; i++ { + cardToken := fmt.Sprintf("cnon:multi-tip-%d", i) req := CreateTipPaymentRequest{ - Amount: int64(200 + i*100), - CardToken: fmt.Sprintf("cnon:multi-tip-%d", i), + Amount: int64(200 + i*100), + NewCardToken: &cardToken, } handler := CreateTipPayment @@ -1872,9 +1874,10 @@ func TestTipPayment_TransactionFailure_SkipsSquare(t *testing.T) { cancelCtx, cancel := context.WithCancel(ctx) cancel() + cardToken := "cnon:tip-card" req := CreateTipPaymentRequest{ - Amount: 500, - CardToken: "cnon:tip-card", + Amount: 500, + NewCardToken: &cardToken, } handler := CreateTipPayment diff --git a/backend/handlers/payments/validators.go b/backend/handlers/payments/validators.go index ee6b273..e1ddaa0 100644 --- a/backend/handlers/payments/validators.go +++ b/backend/handlers/payments/validators.go @@ -19,8 +19,9 @@ func ValidateAmount(amount int64) error { if amount <= 0 { return errors.New("amount must be greater than 0") } - // Amount is in pence (integer), so no precision issues possible at this level - // The frontend must ensure the input has max 2 decimal places before converting to pence + if amount > 1_000_000 { // £10,000 in pence + return errors.New("amount exceeds maximum (£10,000)") + } return nil } diff --git a/backend/internal/square/square.go b/backend/internal/square/square.go index 4da1a17..d5fd15c 100644 --- a/backend/internal/square/square.go +++ b/backend/internal/square/square.go @@ -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) } diff --git a/backend/internal/square/square_dev.go b/backend/internal/square/square_dev.go index 9eed2db..1952b07 100644 --- a/backend/internal/square/square_dev.go +++ b/backend/internal/square/square_dev.go @@ -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 +} diff --git a/backend/internal/square/square_dev_test.go b/backend/internal/square/square_dev_test.go index 64ac54c..2b6065a 100644 --- a/backend/internal/square/square_dev_test.go +++ b/backend/internal/square/square_dev_test.go @@ -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) + }) } } diff --git a/backend/internal/square/square_http_client.go b/backend/internal/square/square_http_client.go new file mode 100644 index 0000000..e492acd --- /dev/null +++ b/backend/internal/square/square_http_client.go @@ -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 +} diff --git a/backend/internal/square/types.go b/backend/internal/square/types.go index f87bdfc..1e868ce 100644 --- a/backend/internal/square/types.go +++ b/backend/internal/square/types.go @@ -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) diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index cd76e3c..879719e 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -15,6 +15,9 @@ import { computeBalanceDue } from '$lib/utils/booking'; import { parseWallClockDate } from '$lib/utils/timeSlots'; import type { Booking, BookingDiscount, Payment } from '$lib/types/booking'; + import CardInput from '$lib/components/payments/CardInput.svelte'; +import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte'; + import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte'; interface Props { open: boolean; bookingId: string; @@ -135,10 +138,101 @@ let customTipInput = $state(''); let tipProcessing = $state(false); + // Card selection state for tips + let tipSavedCards = $state([]); + let tipLoadingCards = $state(false); + let tipSelectedCardId = $state(null); + let tipShowNewCard = $state(false); + + // New card form state for tips + let tipNewCardNumber = $state(''); + let tipNewCardExpiry = $state(''); + let tipNewCardCVC = $state(''); + let tipSaveCardFuture = $state(false); + let tipCardNumberTouched = $state(false); + let tipCardExpiryTouched = $state(false); + let tipCVCTouched = $state(false); + const canSaveCards = $derived( authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate' ); + // Card validation (matching UserPaymentModal pattern) + function isValidLuhn(cardNumber: string): boolean { + const s = cardNumber.replace(/\D/g, ''); + let sum = 0; + let alternate = false; + for (let i = s.length - 1; i >= 0; i--) { + let n = parseInt(s[i], 10); + if (alternate) { + n *= 2; + if (n > 9) n -= 9; + } + sum += n; + alternate = !alternate; + } + return sum % 10 === 0 && s.length >= 13 && s.length <= 19; + } + + function handleTipFieldBlur(field: string) { + if (field === 'cardNumber') tipCardNumberTouched = true; + else if (field === 'cardExpiry') tipCardExpiryTouched = true; + else if (field === 'cardCVC') tipCVCTouched = true; + } + + function handleTipFieldInput(field: string) { + if (field === 'cardNumber') tipCardNumberTouched = false; + else if (field === 'cardExpiry') tipCardExpiryTouched = false; + else if (field === 'cardCVC') tipCVCTouched = false; + } + + function parseExpiryParts(value: string): { month: number; year: number } | null { + if (!/^\d{2}\/\d{2}$/.test(value)) return null; + const [monthStr, yearStr] = value.split('/'); + const month = parseInt(monthStr, 10); + const year = 2000 + parseInt(yearStr, 10); + if (month < 1 || month > 12) return null; + return { month, year }; + } + + const tipNewCardExpiryParts = $derived(parseExpiryParts(tipNewCardExpiry)); + const isTipNewCardExpiryPast = $derived( + tipNewCardExpiryParts !== null && + (() => { + const expiryDate = new SvelteDate(tipNewCardExpiryParts.year, tipNewCardExpiryParts.month); + return expiryDate < new SvelteDate(); + })() + ); + const hasTipNewCardInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) && tipNewCardExpiryParts === null); + + const tipNewCardError = $derived( + tipShowNewCard || tipSavedCards.length === 0 + ? tipCardNumberTouched && !isValidLuhn(tipNewCardNumber) && tipNewCardNumber.length > 0 + ? 'Invalid card number' + : tipCardExpiryTouched && hasTipNewCardInvalidMonth + ? 'Invalid expiry month' + : tipCardExpiryTouched && isTipNewCardExpiryPast + ? 'This card has expired' + : tipCardExpiryTouched && tipNewCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) + ? 'Enter expiry as MM/YY' + : tipCVCTouched && tipNewCardCVC.length < 3 && tipNewCardCVC.length > 0 + ? 'Enter your CVC number' + : isValidLuhn(tipNewCardNumber) && /^\d{2}\/\d{2}$/.test(tipNewCardExpiry) && tipNewCardCVC.length >= 3 + ? null + : tipNewCardNumber.length === 0 && tipNewCardExpiry.length === 0 && tipNewCardCVC.length === 0 + ? null + : 'Please complete all card fields' + : null + ); + + const isTipCardValid = $derived( + tipSelectedCardId !== null || + (isValidLuhn(tipNewCardNumber) && + tipNewCardExpiryParts !== null && + !isTipNewCardExpiryPast && + tipNewCardCVC.length >= 3) + ); + const tipPresets = $derived( selectedBooking ? [ @@ -169,9 +263,31 @@ } if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') { customTipInput = sanitized; + selectedTipPreset = null; + tipAmount = parseFloat(sanitized) || 0; + } + } + + async function loadTipSavedCards() { + if (savedCardsStore.loaded) { + tipSavedCards = savedCardsStore.cards; + if (tipSavedCards.length > 0 && !tipSelectedCardId) { + tipSelectedCardId = tipSavedCards.find((c) => c.is_default)?.id || tipSavedCards[0].id; + } + return; + } + tipLoadingCards = true; + try { + await savedCardsStore.fetch(); + tipSavedCards = savedCardsStore.cards; + if (tipSavedCards.length > 0 && !tipSelectedCardId) { + tipSelectedCardId = tipSavedCards.find((c) => c.is_default)?.id || tipSavedCards[0].id; + } + } catch { + // ignore + } finally { + tipLoadingCards = false; } - selectedTipPreset = null; - tipAmount = parseFloat(customTipInput) || 0; } async function submitTip() { @@ -180,15 +296,43 @@ toast.error('Please select a tip amount'); return; } + + if (tipSavedCards.length > 0 && !tipSelectedCardId && !tipShowNewCard) { + toast.error('Please select a payment method'); + return; + } + if ((tipShowNewCard || tipSavedCards.length === 0) && !tipNewCardNumber.replace(/\s/g, '')) { + toast.error('Please enter your card number'); + return; + } + tipProcessing = true; + + // Validate card details for new card payments + if (tipShowNewCard || tipSavedCards.length === 0) { + if (!isValidLuhn(tipNewCardNumber) || !/^\d{2}\/\d{2}$/.test(tipNewCardExpiry) || isTipNewCardExpiryPast || tipNewCardCVC.length < 3) { + tipProcessing = false; + toast.error(tipNewCardError || 'Please enter valid credit card details'); + return; + } + } + try { + const body: Record = { amount: Math.round(tipAmount * 100) }; + + if (tipShowNewCard || tipSavedCards.length === 0) { + body.new_card_token = tipNewCardNumber.replace(/\s/g, ''); + body.card_expiry = tipNewCardExpiry; + body.card_cvc = tipNewCardCVC; + body.save_card = tipSaveCardFuture; + } else { + body.card_id = tipSelectedCardId; + } + const response = await apiFetch(`/api/bookings/${selectedBooking.id}/tip`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - amount: Math.round(tipAmount * 100), - card_token: 'placeholder' - }) + body: JSON.stringify(body) }); if (!response.ok) { const errorText = await response.text(); @@ -258,6 +402,12 @@ } }); + $effect(() => { + if (showTipModal) { + loadTipSavedCards(); + } + }); + function printReceipt() { if (!selectedBooking) { toast.error('No booking data to print'); @@ -1026,7 +1176,7 @@ ${hasVAT ? `

VAT is included at ${biz?.default_vat_rate ?? 20} } }} > - + Leave a Tip Show your appreciation for great service @@ -1068,6 +1218,87 @@ ${hasVAT ? `

VAT is included at ${biz?.default_vat_rate ?? 20} /> + + +

+ Payment Method + + {#if tipLoadingCards} +
Loading payment methods...
+ {:else if tipSavedCards.length > 0} +
+ {#each tipSavedCards as card (card.id)} + + {/each} + +
+ + {#if tipShowNewCard} +
+ + {#if tipNewCardError} +
{tipNewCardError}
+ {/if} +
+ {/if} + {:else} +
+ + {#if tipNewCardError} +
{tipNewCardError}
+ {/if} +
+ {/if} +
@@ -1075,7 +1306,7 @@ ${hasVAT ? `

VAT is included at ${biz?.default_vat_rate ?? 20} + {/each} + + + + {#if showNewCardForm} +

+ + {#if newCardError} +
{newCardError}
+ {/if} +
+ {/if} + + {:else} + + {#if newCardError} +
{newCardError}
+ {/if} + {/if} + + + {#if paymentState === 'error'}

Payment failed. Please try again.

@@ -395,7 +643,7 @@ + {/each} + + +
+ {/if} + + {#if showNewCardForm && savedCards.length > 0} +
+ + {#if newCardError} +
{newCardError}
+ {/if} +
+ {:else if savedCards.length === 0} + + {#if newCardError} +
{newCardError}
+ {/if} + {/if} + + + + {#if paymentState === 'error'}

Payment failed. Please try again.

@@ -422,7 +649,7 @@