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:
2026-08-22 00:34:49 +01:00
parent f6caaab8a3
commit 4abcb324c9
17 changed files with 2171 additions and 358 deletions
-1
View File
@@ -1,7 +1,6 @@
package payments
import (
"context"
"database/sql"
"encoding/json"
"errors"
+116 -21
View File
@@ -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",
+13 -10
View File
@@ -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
+3 -2
View File
@@ -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
}
+9 -9
View File
@@ -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 productionuse 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)
}
+233 -76
View File
@@ -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 productionuse 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
}
+326 -162
View File
@@ -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
}
+101 -30
View File
@@ -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)
@@ -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<SavedCard[]>([]);
let tipLoadingCards = $state(false);
let tipSelectedCardId = $state<string | null>(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<string, unknown> = { 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 ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
}
}}
>
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)]">
<Modal.Content class="!z-[70] max-w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto">
<Modal.Header>
<Modal.Title>Leave a Tip</Modal.Title>
<Modal.Description>Show your appreciation for great service</Modal.Description>
@@ -1068,6 +1218,87 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
/>
</div>
</div>
<!-- Card Selection for Tip -->
<div class="space-y-3">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">Payment Method</span>
{#if tipLoadingCards}
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
{:else if tipSavedCards.length > 0}
<div class="space-y-2">
{#each tipSavedCards as card (card.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipSelectedCardId === card.id && !tipShowNewCard ? 'border-input bg-accent' : 'border-gray-200 hover:bg-gray-50'}"
onclick={() => { tipSelectedCardId = card.id; tipShowNewCard = false; }}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
<div class="text-sm">
<span class="font-mono">**** {card.last_4}</span>
<span class="ml-2 text-xs text-gray-400"
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
>
</div>
</div>
{#if tipSelectedCardId === card.id && !tipShowNewCard}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipShowNewCard ? 'border-input bg-accent' : 'border-gray-200 hover:bg-gray-50'}"
onclick={() => { tipSelectedCardId = null; tipShowNewCard = true; }}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
</div>
{#if tipShowNewCard}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{#if tipShowNewCard}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={tipNewCardNumber}
bind:cardExpiry={tipNewCardExpiry}
bind:cardCVC={tipNewCardCVC}
bind:saveCard={tipSaveCardFuture}
showSaveCard={canSaveCards}
onfieldblur={handleTipFieldBlur}
onfieldinput={handleTipFieldInput}
/>
{#if tipNewCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{tipNewCardError}</div>
{/if}
</div>
{/if}
{:else}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={tipNewCardNumber}
bind:cardExpiry={tipNewCardExpiry}
bind:cardCVC={tipNewCardCVC}
bind:saveCard={tipSaveCardFuture}
showSaveCard={canSaveCards}
onfieldblur={handleTipFieldBlur}
onfieldinput={handleTipFieldInput}
/>
{#if tipNewCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{tipNewCardError}</div>
{/if}
</div>
{/if}
</div>
</div>
<Modal.Footer>
@@ -1075,7 +1306,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<Button
class="hover:bg-fuchsia-50"
onclick={submitTip}
disabled={tipAmount <= 0 || tipProcessing}
disabled={tipAmount <= 0 || !isTipCardValid || tipProcessing}
loading={tipProcessing}
>
{tipProcessing ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
@@ -99,9 +99,56 @@
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let cardNumberTouched = $state(false);
let cardExpiryTouched = $state(false);
let cardCVCTouched = $state(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 };
}
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;
}
const expiryParts = $derived(parseExpiryParts(newCardExpiry));
// Payment flow state
let depositPaid = $state(false);
const cardError = $derived(
cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched && expiryParts !== null && (() => { const d = new SvelteDate(expiryParts.year, expiryParts.month); return d < new SvelteDate(); })()
? 'This card has expired'
: cardExpiryTouched && !/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiry.length > 0
? 'Enter expiry as MM/YY'
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(newCardNumber) && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3
? null
: newCardNumber.length === 0 && newCardExpiry.length === 0 && newCardCVC.length === 0
? null
: 'Please complete all card fields'
);
const depositCardFormValid = $derived(
selectedPaymentMethod !== null ||
(showNewCardForm &&
@@ -269,6 +316,18 @@
}
}
function handleFieldBlur(field: string) {
if (field === 'cardNumber') cardNumberTouched = true;
else if (field === 'cardExpiry') cardExpiryTouched = true;
else if (field === 'cardCVC') cardCVCTouched = true;
}
function handleFieldInput(field: string) {
if (field === 'cardNumber') cardNumberTouched = false;
else if (field === 'cardExpiry') cardExpiryTouched = false;
else if (field === 'cardCVC') cardCVCTouched = false;
}
async function processPayment(amount: number) {
// Synchronous double-click guard — set BEFORE any await so a rapid second
// click is rejected immediately, even before the reactive `disabled` has
@@ -2315,7 +2374,12 @@
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
disabled={isProcessingPayment}
onfieldblur={handleFieldBlur}
onfieldinput={handleFieldInput}
/>
{#if cardError}
<p class="mt-2 text-sm text-red-600">{cardError}</p>
{/if}
{/if}
<div class="flex items-center justify-between border-t pt-4">
@@ -0,0 +1,39 @@
<script lang="ts">
let { brand = '' }: { brand?: string } = $props();
const brandSvgs: Record<string, string> = {
VISA: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#1A1F71"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="11">VISA</text></svg>`,
MASTERCARD: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#222"/><circle cx="26" cy="12" r="7" fill="#EB001B" opacity="0.9"/><circle cx="34" cy="12" r="7" fill="#F79E1B" opacity="0.9"/></svg>`,
AMERICAN_EXPRESS: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#016FD0"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="8">AMEX</text></svg>`,
DISCOVER: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#000"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="7.5">DISCOVER</text></svg>`,
DINERS_CLUB: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#004A98"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="9">DC</text></svg>`,
JCB: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#0D4A2E"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="10">JCB</text></svg>`,
SQUARE_GIFT_CARD: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#E8F5E9"/><rect x="1" y="1" width="58" height="22" rx="2" stroke="#4CAF50" stroke-width="0.5" stroke-dasharray="2 1"/><text x="30" y="16" text-anchor="middle" fill="#2E7D32" font-family="Arial, sans-serif" font-weight="bold" font-size="7">GIFT</text></svg>`,
CHINA_UNION_PAY: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#D7001E"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="7.5">UNION</text></svg>`,
UNIONPAY: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#D7001E"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="7.5">UNION</text></svg>`,
INTERAC: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#074CA1"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="8">INTERAC</text></svg>`,
EFTPOS: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#1A237E"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="7.5">EFTPOS</text></svg>`
};
const normalizedBrand = $derived(brand.toUpperCase());
const normalizedSvg = $derived(brandSvgs[normalizedBrand] ?? '');
const showSvg = $derived(normalizedSvg !== '' && normalizedBrand.length >= 3);
</script>
{#if showSvg}
<div class="flex h-8 min-w-12 items-center justify-center rounded" role="img" aria-label={brand}>{@html normalizedSvg}</div>
{:else}
<div class="flex h-8 min-w-12 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium text-gray-700 uppercase" role="img" aria-label={brand}>
{brand}
</div>
{/if}
@@ -9,7 +9,9 @@
cardCVC = $bindable(''),
saveCard = $bindable(false),
showSaveCard = false,
disabled = false
disabled = false,
onfieldblur = (_field: string) => {},
onfieldinput = (_field: string) => {}
}: {
cardNumber?: string;
cardExpiry?: string;
@@ -17,6 +19,8 @@
saveCard?: boolean;
showSaveCard?: boolean;
disabled?: boolean;
onfieldblur?: (field: string) => void;
onfieldinput?: (field: string) => void;
} = $props();
function formatNumber(value: string): string {
@@ -44,7 +48,11 @@
type="text"
inputmode="numeric"
value={cardNumber}
oninput={(e) => (cardNumber = formatNumber((e.target as HTMLInputElement).value))}
oninput={(e) => {
cardNumber = formatNumber((e.target as HTMLInputElement).value);
onfieldinput('cardNumber');
}}
onblur={() => onfieldblur('cardNumber')}
placeholder="1234 5678 9012 3456"
maxlength={19}
{disabled}
@@ -58,7 +66,11 @@
type="text"
inputmode="numeric"
value={cardExpiry}
oninput={(e) => (cardExpiry = formatExpiry((e.target as HTMLInputElement).value))}
oninput={(e) => {
cardExpiry = formatExpiry((e.target as HTMLInputElement).value);
onfieldinput('cardExpiry');
}}
onblur={() => onfieldblur('cardExpiry')}
placeholder="MM/YY"
maxlength={5}
{disabled}
@@ -71,6 +83,8 @@
type="text"
inputmode="numeric"
bind:value={cardCVC}
oninput={() => onfieldinput('cardCVC')}
onblur={() => onfieldblur('cardCVC')}
placeholder="123"
maxlength={4}
{disabled}
@@ -61,6 +61,9 @@
let newCardExpiry = $state('');
let newCardCVC = $state('');
let saveCardForFuture = $state(false);
let cardNumberTouched = $state(false);
let cardExpiryTouched = $state(false);
let cardCVCTouched = $state(false);
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
@@ -99,6 +102,18 @@
return sum % 10 === 0 && s.length >= 13 && s.length <= 19;
}
function handleFieldBlur(field: string) {
if (field === 'cardNumber') cardNumberTouched = true;
else if (field === 'cardExpiry') cardExpiryTouched = true;
else if (field === 'cardCVC') cardCVCTouched = true;
}
function handleFieldInput(field: string) {
if (field === 'cardNumber') cardNumberTouched = false;
else if (field === 'cardExpiry') cardExpiryTouched = false;
else if (field === 'cardCVC') cardCVCTouched = false;
}
const cardFormValid = $derived(
isValidLuhn(newCardNumber) && expiryParts !== null && newCardCVC.length >= 3 && !isExpiryInPast
);
@@ -113,19 +128,21 @@
!cardSelected
? selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm
? 'Please select a card'
: !isValidLuhn(newCardNumber) && newCardNumber.length > 0
: cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: hasInvalidMonth
: cardExpiryTouched && hasInvalidMonth
? 'Invalid expiry month'
: isExpiryInPast
: cardExpiryTouched && isExpiryInPast
? 'Expiry date in the past'
: !/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiry.length > 0
: cardExpiryTouched && !/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiry.length > 0
? 'Enter expiry as MM/YY'
: newCardCVC.length < 3 && newCardCVC.length > 0
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: paymentMethods.length === 0 && !showNewCardForm && newCardNumber.length === 0
? 'Please enter card details'
: 'Please complete all card fields'
: isValidLuhn(newCardNumber) && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3
? null
: newCardNumber.length === 0 && newCardExpiry.length === 0 && newCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
);
@@ -842,6 +859,8 @@
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
disabled={false}
onfieldblur={handleFieldBlur}
onfieldinput={handleFieldInput}
/>
{/if}
+5 -12
View File
@@ -4,6 +4,7 @@
import { SvelteDate } from 'svelte/reactivity';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
@@ -2088,12 +2089,8 @@
<div class="space-y-3">
{#each savedCardsStore.cards as card (card.id)}
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium"
>
{card.brand}
</div>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
<div>
<div class="text-sm font-medium">
**** {card.last_4}
@@ -2389,12 +2386,8 @@
buySelectedCard = card.id;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium text-gray-700 uppercase"
>
{card.brand}
</div>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
<div class="text-sm">
<span class="font-mono">**** {card.last_4}</span>
<span class="ml-2 text-xs text-gray-400"
+253 -5
View File
@@ -11,6 +11,9 @@
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
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';
// Types
type Service = {
@@ -40,6 +43,21 @@
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
let pageState = $state<'loading' | 'authorized' | 'unauthorized' | 'admin'>('loading');
// Card selection state
let savedCards = $state<SavedCard[]>([]);
let loadingCards = $state(false);
let selectedCardId = $state<string | null>(null);
let showNewCardForm = $state(false);
// New card form state
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let saveCardForFuture = $state(false);
let cardNumberTouched = $state(false);
let cardExpiryTouched = $state(false);
let cardCVCTouched = $state(false);
// Tip selection state
let selectedTip = $state<number | null>(null);
let customTip = $state('');
@@ -60,6 +78,88 @@
];
});
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 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 newCardExpiryParts = $derived(parseExpiryParts(newCardExpiry));
const isNewCardExpiryPast = $derived(
newCardExpiryParts !== null &&
(() => {
const expiryDate = new SvelteDate(newCardExpiryParts.year, newCardExpiryParts.month);
return expiryDate < new SvelteDate();
})()
);
const hasNewCardInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null);
const newCardError = $derived(
showNewCardForm || savedCards.length === 0
? cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched && hasNewCardInvalidMonth
? 'Invalid expiry month'
: cardExpiryTouched && isNewCardExpiryPast
? 'This card has expired'
: cardExpiryTouched && newCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(newCardExpiry)
? 'Enter expiry as MM/YY'
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(newCardNumber) && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3
? null
: newCardNumber.length === 0 && newCardExpiry.length === 0 && newCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
);
const isCardValid = $derived(
selectedCardId !== null ||
(isValidLuhn(newCardNumber) &&
newCardExpiryParts !== null &&
!isNewCardExpiryPast &&
newCardCVC.length >= 3)
);
function handleFieldBlur(field: string) {
if (field === 'cardNumber') cardNumberTouched = true;
else if (field === 'cardExpiry') cardExpiryTouched = true;
else if (field === 'cardCVC') cardCVCTouched = true;
}
function handleFieldInput(field: string) {
if (field === 'cardNumber') cardNumberTouched = false;
else if (field === 'cardExpiry') cardExpiryTouched = false;
else if (field === 'cardCVC') cardCVCTouched = false;
}
const onfieldblur = handleFieldBlur;
const onfieldinput = handleFieldInput;
// Format functions
function formatDate(dateStr: string): string {
const date = new SvelteDate(dateStr);
@@ -125,6 +225,29 @@
}
}
// Load saved cards
async function loadSavedCards() {
if (savedCardsStore.loaded) {
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
}
return;
}
loadingCards = true;
try {
await savedCardsStore.fetch();
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
}
} catch {
// ignore
} finally {
loadingCards = false;
}
}
// Handle tip selection
function selectTip(amount: number) {
selectedTip = amount;
@@ -157,18 +280,43 @@
return;
}
if (savedCards.length > 0 && !selectedCardId && !showNewCardForm) {
toast.error('Please select a payment method');
return;
}
if ((showNewCardForm || savedCards.length === 0) && !newCardNumber.replace(/\s/g, '')) {
toast.error('Please enter your card number');
return;
}
paymentState = 'processing';
// Validate card details for new card payments
if (showNewCardForm || savedCards.length === 0) {
if (!isValidLuhn(newCardNumber) || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || isNewCardExpiryPast || newCardCVC.length < 3) {
paymentState = 'idle';
toast.error(newCardError || 'Please enter valid credit card details');
return;
}
}
try {
const amountInPence = Math.round(tipAmount * 100);
const body: Record<string, unknown> = { amount: amountInPence };
if (showNewCardForm || savedCards.length === 0) {
body.new_card_token = newCardNumber.replace(/\s/g, '');
body.card_expiry = newCardExpiry;
body.card_cvc = newCardCVC;
body.save_card = saveCardForFuture;
} else {
body.card_id = selectedCardId;
}
const response = await apiFetch(`/api/bookings/${bookingId}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: amountInPence,
card_token: 'placeholder'
})
body: JSON.stringify(body)
});
if (!response.ok) {
@@ -214,6 +362,7 @@
}
pageState = 'authorized';
loadSavedCards();
if (bookingId) {
fetchBookingData();
}
@@ -385,6 +534,105 @@
</Card.Content>
</Card.Root>
<!-- Payment Method -->
<Card.Root class="mb-6">
<Card.Header>
<Card.Title>Payment Method</Card.Title>
</Card.Header>
<Card.Content>
{#if savedCards.length > 0}
<div class="space-y-3">
<span
class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>
Payment Method
</span>
<div class="space-y-2">
{#each savedCards as card (card.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId === card.id && !showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = card.id;
showNewCardForm = false;
}}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
<div class="text-sm">
<span class="font-mono">**** {card.last_4}</span>
<span class="ml-2 text-xs text-gray-400"
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
>
</div>
</div>
{#if selectedCardId === card.id && !showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = null;
showNewCardForm = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700"
>Use a new card</span
>
</div>
{#if showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{#if showNewCardForm}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
{onfieldblur}
{onfieldinput}
/>
{#if newCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
{/if}
</div>
{/if}
</div>
{:else}
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
{onfieldblur}
{onfieldinput}
/>
{#if newCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
{/if}
{/if}
</Card.Content>
</Card.Root>
{#if paymentState === 'error'}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-red-700">Payment failed. Please try again.</p>
@@ -395,7 +643,7 @@
<Button
class="w-full"
size="lg"
disabled={tipAmount <= 0 || paymentState === 'processing'}
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing'}
loading={paymentState === 'processing'}
onclick={submitTip}
>
+238 -11
View File
@@ -6,6 +6,9 @@
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
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';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Card from '$lib/components/ui/card';
@@ -41,13 +44,26 @@
payments?: Payment[];
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let pageState = $state<'loading' | 'authorized' | 'unauthorized' | 'admin'>('loading');
let loading = $state(true);
let error = $state<string | null>(null);
let booking = $state<Booking | null>(null);
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
// Card selection state (same pattern as UserPaymentModal)
let savedCards = $state<SavedCard[]>([]);
let loadingCards = $state(false);
let selectedCardId = $state<string | null>(null);
let showNewCardForm = $state(false);
// New card form state
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let saveCardForFuture = $state(false);
let cardNumberTouched = $state(false);
let cardExpiryTouched = $state(false);
let cardCVCTouched = $state(false);
let selectedTip = $state<number | null>(null);
let customTip = $state('');
const tipAmount = $derived(
@@ -71,6 +87,89 @@
];
});
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 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 };
}
function handleFieldBlur(field: string) {
if (field === 'cardNumber') cardNumberTouched = true;
else if (field === 'cardExpiry') cardExpiryTouched = true;
else if (field === 'cardCVC') cardCVCTouched = true;
}
function handleFieldInput(field: string) {
if (field === 'cardNumber') cardNumberTouched = false;
else if (field === 'cardExpiry') cardExpiryTouched = false;
else if (field === 'cardCVC') cardCVCTouched = false;
}
const onfieldblur = handleFieldBlur;
const onfieldinput = handleFieldInput;
const newCardExpiryParts = $derived(parseExpiryParts(newCardExpiry));
const isNewCardExpiryPast = $derived(
newCardExpiryParts !== null &&
(() => {
const expiryDate = new SvelteDate(newCardExpiryParts.year, newCardExpiryParts.month);
return expiryDate < new SvelteDate();
})()
);
const hasNewCardInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiryParts === null);
const newCardError = $derived(
showNewCardForm || savedCards.length === 0
? cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched && hasNewCardInvalidMonth
? 'Invalid expiry month'
: cardExpiryTouched && isNewCardExpiryPast
? 'This card has expired'
: cardExpiryTouched && newCardExpiry.length > 0 && !/^\d{2}\/\d{2}$/.test(newCardExpiry)
? 'Enter expiry as MM/YY'
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(newCardNumber) && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3
? null
: newCardNumber.length === 0 && newCardExpiry.length === 0 && newCardCVC.length === 0
? null
: 'Please complete all card fields'
: null
);
const isCardValid = $derived(
selectedCardId !== null ||
(isValidLuhn(newCardNumber) &&
newCardExpiryParts !== null &&
!isNewCardExpiryPast &&
newCardCVC.length >= 3)
);
function formatDate(dateStr: string): string {
const date = new SvelteDate(dateStr);
return date.toLocaleDateString('en-GB', {
@@ -128,18 +227,43 @@
return;
}
if (savedCards.length > 0 && !selectedCardId && !showNewCardForm) {
toast.error('Please select a payment method');
return;
}
if ((showNewCardForm || savedCards.length === 0) && !newCardNumber.replace(/\s/g, '')) {
toast.error('Please enter your card number');
return;
}
paymentState = 'processing';
// Validate card details for new card payments
if (showNewCardForm || savedCards.length === 0) {
if (!isValidLuhn(newCardNumber) || !/^\d{2}\/\d{2}$/.test(newCardExpiry) || isNewCardExpiryPast || newCardCVC.length < 3) {
paymentState = 'idle';
toast.error(newCardError || 'Please enter valid credit card details');
return;
}
}
try {
const amountInPence = Math.round(tipAmount * 100);
const body: Record<string, unknown> = { amount: amountInPence };
if (showNewCardForm || savedCards.length === 0) {
body.new_card_token = newCardNumber.replace(/\s/g, '');
body.card_expiry = newCardExpiry;
body.card_cvc = newCardCVC;
body.save_card = saveCardForFuture;
} else {
body.card_id = selectedCardId;
}
const response = await apiFetch(`/api/bookings/${booking.id}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: amountInPence,
card_token: 'placeholder'
})
body: JSON.stringify(body)
});
if (!response.ok) {
@@ -164,25 +288,22 @@
if (!browser) return;
if (authStore.isLoading) {
pageState = 'loading';
return;
}
if (!authStore.isAuthenticated) {
pageState = 'unauthorized';
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto('/login', { replaceState: true });
return;
}
if (authStore.currentUser?.role === 'admin') {
pageState = 'admin';
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto('/admin', { replaceState: true });
return;
}
pageState = 'authorized';
loadSavedCards();
fetchMostRecentBooking();
});
@@ -239,6 +360,28 @@
loading = false;
}
}
async function loadSavedCards() {
if (savedCardsStore.loaded) {
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
}
return;
}
loadingCards = true;
try {
await savedCardsStore.fetch();
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
selectedCardId = savedCards.find(c => c.is_default)?.id || savedCards[0].id;
}
} catch {
// ignore — user can enter new card
} finally {
loadingCards = false;
}
}
</script>
<svelte:head>
@@ -412,6 +555,90 @@
</Card.Content>
</Card.Root>
<!-- Payment Method -->
<Card.Root class="mb-6">
<Card.Content class="space-y-4">
<div class="space-y-3">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">Payment Method</span>
{#if savedCards.length > 0}
<div class="space-y-2">
{#each savedCards as card (card.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId === card.id && !showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => { selectedCardId = card.id; showNewCardForm = false; }}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
<div class="text-sm">
<span class="font-mono">**** {card.last_4}</span>
<span class="ml-2 text-xs text-gray-400">Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span>
</div>
</div>
{#if selectedCardId === card.id && !showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => { selectedCardId = null; showNewCardForm = true; }}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
</div>
{#if showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{/if}
{#if showNewCardForm && savedCards.length > 0}
<div class="space-y-3 rounded-lg border bg-gray-50 p-3">
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
{onfieldblur}
{onfieldinput}
/>
{#if newCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
{/if}
</div>
{:else if savedCards.length === 0}
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
bind:saveCard={saveCardForFuture}
showSaveCard={canSaveCards}
{onfieldblur}
{onfieldinput}
/>
{#if newCardError}
<div class="mt-1 text-xs font-semibold text-red-500">{newCardError}</div>
{/if}
{/if}
</div>
</Card.Content>
</Card.Root>
{#if paymentState === 'error'}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-red-700">Payment failed. Please try again.</p>
@@ -422,7 +649,7 @@
<Button
class="w-full"
size="lg"
disabled={tipAmount <= 0 || paymentState === 'processing'}
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing'}
loading={paymentState === 'processing'}
onclick={submitTip}
>