mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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.
264 lines
8.3 KiB
Go
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
|
|
}
|