Fix silent tip/gift-card money loss on pending retry; terminal checkout wire; sentinel error; docs

CRITICAL — same-amount tip retry silently never charged:
- CreateTipPayment idempotency check now only short-circuits when the
  existing record is 'completed'. A 'pending' record (previous Square call
  failed) is REUSED and the charge re-attempted with the same key (Square
  dedups safely), instead of returning the stale pending record as 200 with
  a success toast and no charge.
- Same fix in BuyGiftCard: pending records trigger a re-attempt, not a
  false-success response. Unique idempotency_key constraint means the
  pending record must be reused, not re-inserted.
- Fixes the savepoint/rollback interaction: the nested tx (savepoint) is
  now committed in the reuse path so the deferred rollback doesn't undo the
  later status UPDATE on the same connection.
- Regression test: TestTipPayment_RetryPending_ReattemptsCharge verifies a
  pending record + same-key retry re-attempts and completes, reusing the
  record (count stays 1).

MAJOR — terminal checkout wire contract:
- device_id now sent as checkout.device_options.device_id (Square's required
  shape), not a top-level field which Square rejects with 400.
- 'checkout pending' detection now uses typed sentinel ErrCheckoutPending
  with errors.Is in both handlers, matching mock and real HTTP client.

MAJOR — exp_month/exp_year omitted from card creation payload when unset
(now *int with omitempty) — Square would 400 on 0/0; expiry comes from the
tokenized source.

Docs:
- README payments/infrastructure sections corrected (Web Payments SDK claim
  replaced with accurate P11-backlog note; dev mock parity described)
- Future Work P11 updated to reflect raw-PAN rejection is now enforced in
  both mock and prod (new-card flows are a documented dead end)
- Added plans/p11-square-web-payments-sdk.md: full implementation plan +
  handoff prompt for the agent picking up P11 (Web Payments SDK nonces)
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent bbb55dae82
commit 3db8b54923
9 changed files with 342 additions and 76 deletions
+24 -10
View File
@@ -5,6 +5,7 @@ import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -13,6 +14,12 @@ import (
"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.
// ---------------------------------------------------------------------------
@@ -175,14 +182,18 @@ type sqFee struct {
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"`
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 {
@@ -238,8 +249,8 @@ type sqCreateCardRequest struct {
}
type sqCardPayload struct {
ExpMonth int `json:"exp_month"`
ExpYear int `json:"exp_year"`
ExpMonth *int `json:"exp_month,omitempty"`
ExpYear *int `json:"exp_year,omitempty"`
CardholderName string `json:"cardholder_name,omitempty"`
CustomerID string `json:"customer_id,omitempty"`
}
@@ -295,7 +306,9 @@ func createCheckoutHTTP(ctx context.Context, req CreateCheckoutReq) (*CheckoutRe
Note: req.Note,
CustomerID: req.CustomerID,
},
DeviceID: req.DeviceID,
}
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 {
@@ -312,6 +325,9 @@ func getCheckoutHTTP(ctx context.Context, checkoutID string) (*PaymentResult, er
}
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 {
@@ -351,8 +367,6 @@ func createCardOnFileHTTP(ctx context.Context, userID, cardToken string) (*CardO
SourceID: cardToken,
Card: sqCardPayload{
CustomerID: userID,
ExpMonth: 0,
ExpYear: 0,
},
}
var resp sqCreateCardResponse