Files
Crussell/backend/internal/square/square_http_client.go
T
popertots ae8735ba2f Close refund system and gate raw-PAN card entry
Refund system (Round 3 fixes + follow-up + alignment):
- Serialize cancellation refunds against the manual handler via
  per-payment advisory locks taken before the prior-refunds read
  (pg_advisory_xact_lock, ascending, same crussell:refund: key space)
- Aggregate pending cancellation refunds into ONE Square refund per
  charge (stable charge-level -square-agg key); atomic group UPDATE
  keeps crash-retry amounts identical for Square key-dedup
- Persist paymentID-square-amount idempotency keys on cancellation
  refunds; scheduler reads the stored key (legacy fallback for old rows)
- Add sweep-pending-square-refunds cron (*/5, concurrency 1) with
  refund_attempts cap; sweep retries stale manual pending refunds with
  each row's own stored idempotency key
- Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every
  terminal failed transition: tri-state result leaves rows pending on
  reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED
  resolves to completed
- Move over-refund guard inside the lock, counting completed + pending
  (excluding failed); ErrRefundDeclined distinguishes definitive vs
  ambiguous outcomes
- forgiveFees now executes a real full refund (forceFullRefund override)
  with admin_forgiven_fees reason threaded to Square
- Surface failed card refunds in the admin notification centre
  (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup)
- Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key)
  DO NOTHING without consuming refundRemaining

Frontend:
- Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token
  in request bodies; gate new-card entry behind CardEntryUnavailable
  notice + newCardDisabled prop across all 8 flows
- Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI
  and CardEntryUnavailable fallback
- Update cancellation-policy page to in-person cash pickup wording

Tests:
- Rewrite the two amount-blind dedup tests to assert real money movement
  (single call, aggregated amount, shared refund ID)
- Add coverage: manual refund vs cancellation serialization (concurrent
  goroutines), reconcile error vs no-match branches, stale manual retry,
  forgive-fees real refund row + reason, double-cancel dedup, mock refund
  key dedup, ListPaymentRefunds filtering
- Fix time-dependent booking flakes with fixtures.NextWorkingDayAt
- 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
2026-08-22 00:34:49 +01:00

568 lines
18 KiB
Go

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]
msg := fmt.Sprintf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, se.Detail, se.Field)
return &squareAPIError{Code: se.Code, Detail: se.Detail, err: errors.New(msg)}
}
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 sqListRefundsResponse struct {
Refunds []sqRefund `json:"refunds"`
Cursor string `json:"cursor"`
}
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
}
// squareAPIError wraps a formatted Square API error while exposing the
// structured Square error code so callers can classify definitive business
// rejections (e.g. ErrRefundDeclined) vs ambiguous transport/server errors.
type squareAPIError struct {
Code string
Detail string
err error
}
func (e *squareAPIError) Error() string { return e.err.Error() }
func (e *squareAPIError) Unwrap() error { return e.err }
// Definitive Square refund rejection codes — the refund was declined and can
// never succeed, so retrying is pointless and the refund record should be
// marked 'failed'. Anything else (transport errors, 5xx) is left ambiguous so
// callers leave the refund 'pending' for a scheduler retry. Note that
// PAYMENT_ALREADY_REFUNDED is intentionally absent — the money has already
// moved, so it maps to ErrRefundAlreadyProcessed instead of ErrRefundDeclined.
var definitiveRefundCodes = map[string]bool{
"REFUND_DECLINED": true,
"PAYMENT_REFUND_AMOUNT_EXCEEDED": true,
"INVALID_PAYMENT_ID": true,
}
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 {
var sqErr *squareAPIError
if errors.As(err, &sqErr) && definitiveRefundCodes[sqErr.Code] {
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
}
if errors.As(err, &sqErr) && sqErr.Code == "PAYMENT_ALREADY_REFUNDED" {
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
}
return nil, err
}
return refundFromSquare(&resp.Refund), nil
}
func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
hc := newHTTPClient()
base := "/v2/refunds?begin_time=" + url.QueryEscape(beginTime.UTC().Format(time.RFC3339)) + "&limit=100"
path := base
results := []RefundResult{}
for page := 0; page < 20; page++ {
var resp sqListRefundsResponse
if err := hc.doJSON(ctx, http.MethodGet, path, nil, &resp); err != nil {
return nil, err
}
for i := range resp.Refunds {
r := &resp.Refunds[i]
if r.PaymentID == paymentID {
results = append(results, *refundFromSquare(r))
}
}
if resp.Cursor == "" {
return results, nil
}
path = base + "&cursor=" + url.QueryEscape(resp.Cursor)
}
return nil, fmt.Errorf("square: list refunds exceeded 20 pages (infinite loop guard)")
}
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 ""
}