The app does not provision Square customers, so sending the local user ID as customer_id in Create Card was rejected with CUSTOMER_NOT_FOUND, and filtering List Cards by it returned nothing. reference_id is Square's free-form client reference — max 128 chars, no uniqueness constraint — and is echoed in both Create and List responses. - Create Card payload: reference_id = local user ID (customer_id absent) - List Cards: native ?reference_id=<userID> filter (no limit/customer_id, no client-side filter, no cursor handling needed) - Mock parity: CreateCardOnFile stores ReferenceID; GetCardsOnFile unchanged - Regression guards: TestCreateCardOnFileHTTP_IdempotencyKey asserts reference_id=user_1 and customer_id ABSENT; new TestGetCardsOnFileHTTP_ReferenceIDFilter asserts the query shape
594 lines
20 KiB
Go
594 lines
20 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"`
|
|
ReferenceID string `json:"reference_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"`
|
|
ReferenceID string `json:"reference_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) {
|
|
return createPaymentHTTPWithClient(ctx, req, newHTTPClient())
|
|
}
|
|
|
|
func createPaymentHTTPWithClient(ctx context.Context, req CreatePaymentReq, hc *httpClient) (*PaymentResult, error) {
|
|
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) {
|
|
return getCheckoutHTTPWithClient(ctx, checkoutID, newHTTPClient())
|
|
}
|
|
|
|
func getCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpClient) (*PaymentResult, error) {
|
|
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) {
|
|
return refundPaymentHTTPWithClient(ctx, req, newHTTPClient())
|
|
}
|
|
|
|
func refundPaymentHTTPWithClient(ctx context.Context, req RefundPaymentReq, hc *httpClient) (*RefundResult, error) {
|
|
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) {
|
|
return listRefundsHTTPWithClient(ctx, paymentID, beginTime, newHTTPClient())
|
|
}
|
|
|
|
func listRefundsHTTPWithClient(ctx context.Context, paymentID string, beginTime time.Time, hc *httpClient) ([]RefundResult, error) {
|
|
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) {
|
|
return createCardOnFileHTTPWithClient(ctx, userID, cardToken, newHTTPClient())
|
|
}
|
|
|
|
func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken string, hc *httpClient) (*CardOnFile, error) {
|
|
|
|
// 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{
|
|
// The app does not provision Square customers, so the local user
|
|
// ID must NOT be sent as customer_id (Square would reject it).
|
|
// reference_id is Square's free-form client reference, used to link
|
|
// the card to the local user for client-side filtering.
|
|
ReferenceID: 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) {
|
|
return getCardsOnFileHTTPWithClient(ctx, userID, newHTTPClient())
|
|
}
|
|
|
|
func getCardsOnFileHTTPWithClient(ctx context.Context, userID string, hc *httpClient) ([]CardOnFile, error) {
|
|
// Filter by reference_id natively: Square's List Cards API supports the
|
|
// reference_id query param, and cards are created with reference_id = the
|
|
// local user ID (the app has no Square customers, so customer_id cannot be
|
|
// used). This avoids both the invalid customer_id filter and a client-side
|
|
// filter across a cursor-paginated list.
|
|
var resp sqListCardsResponse
|
|
if err := hc.doJSON(ctx, http.MethodGet, "/v2/cards?reference_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 {
|
|
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: sq.CustomerID,
|
|
ReferenceID: sq.ReferenceID,
|
|
Enabled: sq.Enabled,
|
|
Version: sq.Version,
|
|
CreatedAt: sq.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|