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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user