Implement full Square payment review fixes + frontend polish
Implement every finding from the deep payment review (P0-P2, minors, nitpicks), then close the post-implementation re-review items, then align card-form typography and roll out the Square trust badge. Backend - Square API alignment: - tip_settings.allow_tipping nested under device_options (was top-level: terminal tips were silently lost in prod) - CreateCardOnFile now accepts customerID and sends card.customer_id; saved-card (ccof:) charges forward square_customer_id as CustomerID - New SquareClient methods GetPayment, CreateCustomer, CancelCheckout - SCA verification_token accepted + forwarded in all charge paths - ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/ ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts emails, ForceRefundPending hook Backend - money safety: - sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id instead of stranding them forever - SweepStalePendingPayments reconciles at Square before failing (tri-state: leave pending on transport error, rescue completed, fail definitively) - GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution; SweepStaleTerminalCheckouts covers terminal_checkouts table - till gift-card clawback on definitive failure incl. retry path + INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT - cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id)) - customer provisioning (lazy, save-only); one-off/guest mint no customer - discount preview/apply unified in discounts.go (global-milestone visible in preview, N+1 eliminated, redemption counter preserved on failures) - webhook event_id dedup; refund loop dedup; stale comment fixes - test-isolation t.Cleanup on committed sweep tests Frontend: - SCA tokenizeWithVerification across all charge flows (amount as major-units decimal), 5-min token-expiry re-tokenize, verification_token in request bodies - PaymentModal synchronous double-click + zero/negative-amount guards - till online-card UI wired to /api/admin/till/sale - policyPopover generalised; new /privacy-policy route; consent checkbox copy + Square privacy link - Square card iframe styled to app typography (Inter 14px, oklch tokens); mock form md:text-sm parity - 'Secure payment powered by Square' badge on all 8 card-payment flows Schema/docs: terminal_checkouts + square_customer_id + per-user card constraint in init-script.sql; README migrations; P14 plan + backlog + Technical Manual updated. Includes 39 modified/new test files; full backend suite (25 pkgs), -race on payments+square, and frontend build are green.
This commit is contained in:
@@ -8,9 +8,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -192,15 +194,27 @@ type sqTerminalCheckoutRequest struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// sqTipSettings maps to Square's DeviceCheckoutOptions.tip_settings object
|
||||
// (nested INSIDE device_options — a top-level tip_settings is silently ignored
|
||||
// by Square's TerminalCheckout API, losing terminal tip revenue). Only
|
||||
// allow_tipping is emitted — Square's wire field for enabling terminal tips.
|
||||
type sqTipSettings struct {
|
||||
AllowTipping bool `json:"allow_tipping"`
|
||||
}
|
||||
|
||||
// sqDeviceOptions maps to Square's DeviceCheckoutOptions object inside the
|
||||
// TerminalCheckout payload. device_id is REQUIRED; tip_settings lives here
|
||||
// (not at the checkout top level) so terminal tips are actually collected.
|
||||
type sqDeviceOptions struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
TipSettings *sqTipSettings `json:"tip_settings,omitempty"`
|
||||
}
|
||||
|
||||
type sqTerminalCheckoutResponse struct {
|
||||
@@ -214,9 +228,11 @@ type sqTerminalCheckout struct {
|
||||
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"`
|
||||
// Deadline (deadline_duration) is deprecated in the TerminalCheckout API —
|
||||
// retained read-only for informational purposes; harmless when set.
|
||||
Deadline string `json:"deadline_duration,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type sqGetPaymentResponse struct {
|
||||
@@ -280,11 +296,49 @@ type sqDisableCardResponse struct {
|
||||
Card sqCard `json:"card"`
|
||||
}
|
||||
|
||||
// --- Customer types ---
|
||||
|
||||
type sqCreateCustomerRequest struct {
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
}
|
||||
|
||||
type sqCreateCustomerResponse struct {
|
||||
Customer sqCustomer `json:"customer"`
|
||||
}
|
||||
|
||||
// sqCustomer maps to Square's Customer object. Only fields this application
|
||||
// consumes are included.
|
||||
type sqCustomer struct {
|
||||
ID string `json:"id"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
GivenName string `json:"given_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Package-level HTTP functions — shared by ProdClient and devProdClient.
|
||||
// Each builds a fresh httpClient from env vars and makes the Square API call.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// validSquareID reports whether id is safe to embed in a Square REST URL path
|
||||
// segment. Square IDs are alphanumeric plus '_' and '-' and well under 64
|
||||
// characters; anything else could produce a malformed URL or enable path
|
||||
// traversal in a future caller.
|
||||
func validSquareID(id string) bool {
|
||||
if len(id) == 0 || len(id) > 64 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(id); i++ {
|
||||
c := id[i]
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func createPaymentHTTP(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
return createPaymentHTTPWithClient(ctx, req, newHTTPClient())
|
||||
}
|
||||
@@ -336,6 +390,12 @@ func createCheckoutHTTPWithClient(ctx context.Context, req CreateCheckoutReq, hc
|
||||
},
|
||||
},
|
||||
}
|
||||
// AllowTipping must reach Square as device_options.tip_settings.allow_tipping
|
||||
// — without it the terminal never prompts for a tip and tip revenue is
|
||||
// silently lost. A top-level tip_settings would be ignored by Square.
|
||||
if req.AllowTipping {
|
||||
body.Checkout.DeviceOptions.TipSettings = &sqTipSettings{AllowTipping: true}
|
||||
}
|
||||
var resp sqTerminalCheckoutResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/terminals/checkouts", body, &resp); err != nil {
|
||||
return nil, err
|
||||
@@ -348,6 +408,9 @@ func getCheckoutHTTP(ctx context.Context, checkoutID string) (*PaymentResult, er
|
||||
}
|
||||
|
||||
func getCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpClient) (*PaymentResult, error) {
|
||||
if !validSquareID(checkoutID) {
|
||||
return nil, fmt.Errorf("square: invalid checkout id %q", checkoutID)
|
||||
}
|
||||
var tcResp sqTerminalCheckoutResponse
|
||||
if err := hc.doJSON(ctx, http.MethodGet, "/v2/terminals/checkouts/"+checkoutID, nil, &tcResp); err != nil {
|
||||
return nil, err
|
||||
@@ -373,6 +436,21 @@ func getCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpC
|
||||
return paymentFromSquare(&payResp.Payment), nil
|
||||
}
|
||||
|
||||
func getPaymentHTTP(ctx context.Context, paymentID string) (*PaymentResult, error) {
|
||||
return getPaymentHTTPWithClient(ctx, paymentID, newHTTPClient())
|
||||
}
|
||||
|
||||
func getPaymentHTTPWithClient(ctx context.Context, paymentID string, hc *httpClient) (*PaymentResult, error) {
|
||||
if !validSquareID(paymentID) {
|
||||
return nil, fmt.Errorf("square: invalid payment id %q", paymentID)
|
||||
}
|
||||
var resp sqGetPaymentResponse
|
||||
if err := hc.doJSON(ctx, http.MethodGet, "/v2/payments/"+paymentID, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paymentFromSquare(&resp.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.
|
||||
@@ -385,6 +463,29 @@ type squareAPIError struct {
|
||||
func (e *squareAPIError) Error() string { return e.err.Error() }
|
||||
func (e *squareAPIError) Unwrap() error { return e.err }
|
||||
|
||||
// ErrorCode returns the Square error Code carried by err when err (or any
|
||||
// error it wraps) is a *squareAPIError — i.e. a structured error parsed from
|
||||
// Square's error response body. It returns "" for non-Square errors so callers
|
||||
// can classify charge failures structurally instead of substring-matching the
|
||||
// message.
|
||||
func ErrorCode(err error) string {
|
||||
var sqErr *squareAPIError
|
||||
if errors.As(err, &sqErr) {
|
||||
return sqErr.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ErrorDetail returns the Square error Detail carried by err when err (or any
|
||||
// error it wraps) is a *squareAPIError, and "" otherwise.
|
||||
func ErrorDetail(err error) string {
|
||||
var sqErr *squareAPIError
|
||||
if errors.As(err, &sqErr) {
|
||||
return sqErr.Detail
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -448,14 +549,18 @@ func listRefundsHTTPWithClient(ctx context.Context, paymentID string, beginTime
|
||||
}
|
||||
path = base + "&cursor=" + url.QueryEscape(resp.Cursor)
|
||||
}
|
||||
return nil, fmt.Errorf("square: list refunds exceeded 20 pages (infinite loop guard)")
|
||||
// 20 pages fetched and a cursor is still present — return what we
|
||||
// collected rather than discarding partial results (the previous
|
||||
// infinite-loop guard dropped everything and returned an error).
|
||||
log.Printf("[SQUARE] list refunds exceeded 20 pages (infinite-loop guard) — returning partial results: %d refunds for %s", len(results), paymentID)
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func createCardOnFileHTTP(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
||||
return createCardOnFileHTTPWithClient(ctx, userID, cardToken, newHTTPClient())
|
||||
func createCardOnFileHTTP(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||
return createCardOnFileHTTPWithClient(ctx, userID, cardToken, customerID, newHTTPClient())
|
||||
}
|
||||
|
||||
func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken string, hc *httpClient) (*CardOnFile, error) {
|
||||
func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken, customerID 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.
|
||||
@@ -467,11 +572,13 @@ func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken strin
|
||||
IdempotencyKey: "card-" + fmt.Sprintf("%x", ikHash)[:38],
|
||||
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.
|
||||
// the card to the local user for client-side filtering. customer_id
|
||||
// is sent when the app has provisioned a Square customer for the
|
||||
// user (Square marks customer_id Required on the Card object for
|
||||
// saved-card flows) and omitted otherwise.
|
||||
ReferenceID: userID,
|
||||
CustomerID: customerID,
|
||||
},
|
||||
}
|
||||
var resp sqCreateCardResponse
|
||||
@@ -488,9 +595,10 @@ func getCardsOnFileHTTP(ctx context.Context, userID string) ([]CardOnFile, error
|
||||
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). List Cards pages at 25 cards, so loop on the cursor to avoid
|
||||
// silently truncating a large saved-card list (N-10).
|
||||
// local user ID. customer_id is not used for the filter because a user may
|
||||
// have no provisioned Square customer. List Cards pages at 25 cards, so
|
||||
// loop on the cursor to avoid silently truncating a large saved-card list
|
||||
// (N-10).
|
||||
var cards []CardOnFile
|
||||
path := "/v2/cards?reference_id=" + url.QueryEscape(userID)
|
||||
for page := 0; page < 20; page++ {
|
||||
@@ -521,6 +629,56 @@ func deleteCardOnFileHTTP(ctx context.Context, cardID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createCustomerHTTP(ctx context.Context, name, email string) (*CustomerResult, error) {
|
||||
return createCustomerHTTPWithClient(ctx, name, email, newHTTPClient())
|
||||
}
|
||||
|
||||
func createCustomerHTTPWithClient(ctx context.Context, name, email string, hc *httpClient) (*CustomerResult, error) {
|
||||
// Deterministic idempotency key derived from the email (not time-based)
|
||||
// so retries with the same email don't create duplicate customers. SHA-256
|
||||
// prevents recovering the email from the key. Truncated to ≤45 chars —
|
||||
// Square's documented idempotency-key limit.
|
||||
ikHash := sha256.Sum256([]byte(email))
|
||||
body := sqCreateCustomerRequest{
|
||||
IdempotencyKey: "customer-" + fmt.Sprintf("%x", ikHash)[:35],
|
||||
EmailAddress: email,
|
||||
GivenName: name,
|
||||
}
|
||||
var resp sqCreateCustomerResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/customers", body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CustomerResult{
|
||||
ID: resp.Customer.ID,
|
||||
Email: resp.Customer.EmailAddress,
|
||||
CreatedAt: resp.Customer.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cancelCheckoutHTTP(ctx context.Context, checkoutID string) error {
|
||||
return cancelCheckoutHTTPWithClient(ctx, checkoutID, newHTTPClient())
|
||||
}
|
||||
|
||||
func cancelCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpClient) error {
|
||||
if !validSquareID(checkoutID) {
|
||||
return fmt.Errorf("square: invalid checkout id %q", checkoutID)
|
||||
}
|
||||
var resp sqTerminalCheckoutResponse
|
||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/terminals/checkouts/"+checkoutID+"/cancel", nil, &resp); err != nil {
|
||||
// Square returns 404 / NOT_FOUND when the checkout is already
|
||||
// completed or canceled — that is a no-op, not a failure.
|
||||
var sqErr *squareAPIError
|
||||
if errors.As(err, &sqErr) && sqErr.Code == "NOT_FOUND" {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "HTTP 404") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversion helpers — Square JSON → domain types.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -552,16 +710,15 @@ func paymentFromSquare(sq *sqPayment) *PaymentResult {
|
||||
r.EntryMethod = cd.EntryMethod
|
||||
r.CVVStatus = cd.CVVStatus
|
||||
r.AVSStatus = cd.AVSStatus
|
||||
r.CardBrand = cd.Card.CardBrand
|
||||
r.CardLast4 = cd.Card.Last4
|
||||
// exp_month/exp_year ride on the card object — pointer set when present.
|
||||
expMonth := cd.Card.ExpMonth
|
||||
expYear := cd.Card.ExpYear
|
||||
r.ExpMonth = &expMonth
|
||||
r.ExpYear = &expYear
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user