Files
zeekay 73b7290e7f square: one webhook route and one verifier; secrets come from the host's KMS
Square keeps an event's created_at across every retry, and the receiver
refused anything older than five minutes, so every retry of an event
first sent while cloud was down (Recreate, ten rollouts a day) was refused
until Square gave up. The verifier now lives in payment/providers/square
and needs only the key and the URL; thirdparty/square is the API client
and no longer registers itself or verifies anything, so every registry
holds one Square type.

Replay is stopped by the event id instead: each verified event is
recorded once for the whole deployment, keyed on the verifying processor
and the event id with an insert-if-absent swap, so a repeat is a
duplicate under any path or org. The path names the one processor that
verifies, and only one that holds its webhook secret may: an unknown name,
or a verifier that needs no secret, is refused rather than tried.
The books an event lands in are the verifying account's, else the
deployment's brand — never a header or session, which on a replay are the
replayer's. An effect that did not land (a store or the ledger did not
answer) leaves the delivery open with a 503, so the provider redelivers;
it used to be acknowledged and never retried. A settlement reads the
payment index before the org's books, because an in-session charge's
receipt is in the payer's org.

POST /v1/commerce/webhooks/:provider is declared once, in setupRoutes, so
standalone and embedded commerce serve the same address; the
/v1/billing/webhooks spelling is gone.

thirdparty/kms read /v1/kms/orgs/<org>/secrets/..., which cloud no longer
serves, and was off in production anyway, so per-org payment credentials
never loaded. It is deleted. secrets.Org reads orgs/<org>/<name> through
the host's reader, the caller re-pointed at that org with zip.ActingAs so
the KMS tenancy check admits it, and payment.Hydrate loads an org's
credentials where the org receives the money: storefront checkout,
storefront subscriptions and wire, and the brand's bank for the billing
wire rail. Hanzo's own billing no longer reads the paying org's
credentials; it charges the deployment's account, as it did. The
standalone binary reads through hanzoai/kms/sdk/go.
2026-10-19 09:12:56 -07:00

264 lines
8.3 KiB
Go

package square
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
square "github.com/square/square-go-sdk/v3"
"github.com/square/square-go-sdk/v3/customers"
"github.com/hanzoai/commerce/payment/processor"
)
// CreateCustomer creates a customer profile in Square.
// Returns the Square customer ID on success.
//
// metadata["reference"] names what the customer is for: it is stored as the
// customer's reference_id, the field FindCustomer searches. The request carries
// an idempotency key digested from its own body, so a create sent again with
// the same inputs answers with the customer the first one made, and one key
// never travels with two different bodies.
func (sp *SquareProcessor) CreateCustomer(ctx context.Context, email, name string, metadata map[string]interface{}) (string, error) {
req := &square.CreateCustomerRequest{
EmailAddress: square.String(email),
}
// Split name into given/family; Square stores them separately.
if name != "" {
parts := strings.SplitN(name, " ", 2)
req.GivenName = square.String(parts[0])
if len(parts) == 2 {
req.FamilyName = square.String(parts[1])
}
}
// Attach metadata as a note (Square has no generic metadata field).
if note, ok := metadata["note"].(string); ok && note != "" {
req.Note = square.String(note)
}
if ref, ok := metadata["reference"].(string); ok && ref != "" {
req.ReferenceID = square.String(fit(ref, refMax))
}
body, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("square create customer: %w", err)
}
sum := sha256.Sum256(body)
req.IdempotencyKey = square.String(hex.EncodeToString(sum[:])[:keyMax])
resp, err := sp.customersClient.Create(ctx, req)
if err != nil {
return "", fmt.Errorf("square create customer: %w", err)
}
if resp.Customer == nil || resp.Customer.ID == nil {
return "", fmt.Errorf("square create customer: empty response")
}
return *resp.Customer.ID, nil
}
// FindCustomer returns the id of the oldest Square customer whose reference_id
// is reference, or "" when there is none. Square indexes customers for search
// eventually, so one created seconds ago may not be found yet.
func (sp *SquareProcessor) FindCustomer(ctx context.Context, reference string) (string, error) {
resp, err := sp.customersClient.Search(ctx, &square.SearchCustomersRequest{
Limit: square.Int64(1),
Query: &square.CustomerQuery{
Filter: &square.CustomerFilter{
ReferenceID: &square.CustomerTextFilter{Exact: square.String(fit(reference, refMax))},
},
Sort: &square.CustomerSort{Field: square.CustomerSortFieldCreatedAt.Ptr()},
},
})
if err != nil {
return "", fmt.Errorf("square search customers: %w", err)
}
for _, c := range resp.Customers {
if c != nil && c.ID != nil {
return *c.ID, nil
}
}
return "", nil
}
// GetCustomer retrieves a Square customer profile by ID.
func (sp *SquareProcessor) GetCustomer(ctx context.Context, customerID string) (map[string]interface{}, error) {
resp, err := sp.customersClient.Get(ctx, &square.GetCustomersRequest{
CustomerID: customerID,
})
if err != nil {
return nil, fmt.Errorf("square get customer: %w", err)
}
if resp.Customer == nil {
return nil, fmt.Errorf("square get customer: not found")
}
c := resp.Customer
result := map[string]interface{}{
"id": safeStr(c.ID),
}
if c.EmailAddress != nil {
result["email"] = *c.EmailAddress
}
if c.GivenName != nil || c.FamilyName != nil {
result["name"] = strings.TrimSpace(safeStr(c.GivenName) + " " + safeStr(c.FamilyName))
}
if c.Note != nil {
result["note"] = *c.Note
}
if c.CreatedAt != nil {
result["created_at"] = *c.CreatedAt
}
if c.UpdatedAt != nil {
result["updated_at"] = *c.UpdatedAt
}
return result, nil
}
// UpdateCustomer updates mutable fields on a Square customer profile.
// Recognised keys: email, name, note.
func (sp *SquareProcessor) UpdateCustomer(ctx context.Context, customerID string, updates map[string]interface{}) error {
req := &square.UpdateCustomerRequest{
CustomerID: customerID,
}
if v, ok := updates["email"].(string); ok {
req.EmailAddress = square.String(v)
}
if v, ok := updates["name"].(string); ok && v != "" {
parts := strings.SplitN(v, " ", 2)
req.GivenName = square.String(parts[0])
if len(parts) == 2 {
req.FamilyName = square.String(parts[1])
}
}
if v, ok := updates["note"].(string); ok {
req.Note = square.String(v)
}
_, err := sp.customersClient.Update(ctx, req)
if err != nil {
return fmt.Errorf("square update customer: %w", err)
}
return nil
}
// DeleteCustomer removes a Square customer profile.
func (sp *SquareProcessor) DeleteCustomer(ctx context.Context, customerID string) error {
_, err := sp.customersClient.Delete(ctx, &square.DeleteCustomersRequest{
CustomerID: customerID,
})
if err != nil {
return fmt.Errorf("square delete customer: %w", err)
}
return nil
}
// AddPaymentMethod attaches a card nonce (token) to an existing Square customer.
// Returns the card-on-file ID. It is Vault reduced to the id — one vault call
// underneath, so the two can never disagree about what was saved.
func (sp *SquareProcessor) AddPaymentMethod(ctx context.Context, customerID, token string) (string, error) {
card, err := sp.Vault(ctx, customerID, token)
if err != nil {
return "", err
}
return card.ID, nil
}
// Vault attaches a card nonce to a Square customer and returns the card as
// Square reports it — the durable id plus brand/last4/expiry (what a customer
// recognizes a card by) and the fingerprint (what identifies the card NUMBER
// across vaults, the dedupe key). Vaulting also validates the card, so a
// declined card errors here and nothing is saved.
func (sp *SquareProcessor) Vault(ctx context.Context, customerID, token string) (processor.Card, error) {
resp, err := sp.customersClient.Cards.Create(ctx, &customers.CreateCustomerCardRequest{
CustomerID: customerID,
CardNonce: token,
})
if err != nil {
return processor.Card{}, fmt.Errorf("square add payment method: %w", err)
}
if resp.Card == nil || resp.Card.ID == nil {
return processor.Card{}, fmt.Errorf("square add payment method: empty response")
}
card := cardOf(resp.Card)
if card.CustomerID == "" {
card.CustomerID = customerID
}
return card, nil
}
// Cards lists the cards vaulted for a Square customer (first page — Square caps
// at 25 per page and a billing customer holds a handful). Used to heal stored
// payment-method rows that predate Vault returning card details.
func (sp *SquareProcessor) Cards(ctx context.Context, customerID string) ([]processor.Card, error) {
page, err := sp.cardsClient.List(ctx, &square.ListCardsRequest{
CustomerID: square.String(customerID),
})
if err != nil {
return nil, fmt.Errorf("square list cards: %w", err)
}
out := make([]processor.Card, 0, len(page.Results))
for _, c := range page.Results {
if c == nil || c.ID == nil {
continue
}
out = append(out, cardOf(c))
}
return out, nil
}
// cardOf maps Square's wire Card onto the processor's value. One mapping, used
// by Vault and Cards, so a vaulted card and a listed card read identically.
func cardOf(c *square.Card) processor.Card {
card := processor.Card{ID: safeStr(c.ID), Last4: safeStr(c.Last4), Fingerprint: safeStr(c.Fingerprint), CustomerID: safeStr(c.CustomerID)}
if c.CardBrand != nil {
card.Brand = brandName(string(*c.CardBrand))
}
if c.ExpMonth != nil {
card.ExpMonth = int(*c.ExpMonth)
}
if c.ExpYear != nil {
card.ExpYear = int(*c.ExpYear)
}
return card
}
// brandName renders Square's SCREAMING_SNAKE brand enum ("VISA",
// "AMERICAN_EXPRESS") as the display name a receipt prints ("Visa",
// "American Express").
func brandName(b string) string {
words := strings.Split(strings.ToLower(b), "_")
for i, w := range words {
if w == "" {
continue
}
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
return strings.Join(words, " ")
}
// RemovePaymentMethod disables (deletes) a card on file from a Square customer.
func (sp *SquareProcessor) RemovePaymentMethod(ctx context.Context, customerID, paymentMethodID string) error {
_, err := sp.customersClient.Cards.Delete(ctx, &customers.DeleteCardsRequest{
CustomerID: customerID,
CardID: paymentMethodID,
})
if err != nil {
return fmt.Errorf("square remove payment method: %w", err)
}
return nil
}
// safeStr dereferences a *string safely, returning "" for nil.
func safeStr(s *string) string {
if s == nil {
return ""
}
return *s
}