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