package square import ( "bytes" "context" "crypto/sha256" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "time" ) // ErrCheckoutPending is returned by GetCheckout when a terminal checkout has // not yet completed. Handlers use errors.Is(err, ErrCheckoutPending) rather // than string comparison, so behaviour is identical across the mock and the // real HTTP client. var ErrCheckoutPending = errors.New("checkout pending") // --------------------------------------------------------------------------- // 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"` } 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"` DeviceOptions *sqDeviceOptions `json:"device_options,omitempty"` } type sqDeviceOptions struct { DeviceID string `json:"device_id"` } 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,omitempty"` ExpYear *int `json:"exp_year,omitempty"` 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) { return createCheckoutHTTPWithClient(ctx, req, newHTTPClient()) } func createCheckoutHTTPWithClient(ctx context.Context, req CreateCheckoutReq, hc *httpClient) (*CheckoutResult, error) { body := sqTerminalCheckoutRequest{ IdempotencyKey: req.IdempotencyKey, Checkout: sqTerminalCheckoutPayload{ AmountMoney: sqMoney{Amount: req.Amount, Currency: req.Currency}, ReferenceID: req.ReferenceID, Note: req.Note, CustomerID: req.CustomerID, }, } if req.DeviceID != "" { body.Checkout.DeviceOptions = &sqDeviceOptions{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" { if tc.Status == "PENDING" || tc.Status == "IN_PROGRESS" { return nil, ErrCheckoutPending } 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() // Deterministic idempotency key derived from user + card (not time-based) // so that retries with the same details don't create duplicate cards. // SHA-256 hash prevents recovering the card token from the key itself. ikHash := sha256.Sum256([]byte(userID + "|" + cardToken)) body := sqCreateCardRequest{ IdempotencyKey: fmt.Sprintf("create-card-%x", ikHash), SourceID: cardToken, Card: sqCardPayload{ CustomerID: userID, }, } 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="+url.QueryEscape(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 } else { // Card details present but no card ID — still surface the brand/last4. r.CardBrand = cd.Card.CardBrand r.CardLast4 = cd.Card.Last4 } } 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 "" }