mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Auto-reload charges the org's card with nobody present, so the rule is its admins' to set — and a SuperAdmin's or a service's. The amount is a top-up's own range ($5 to $5,000) and the balance it reloads below is $0 to $5,000, refused with the range in the sentence. The sweep asks the processor to charge one org at most three times a UTC day, a decline included, and names the limit in the row it skips. The day's reload is reserved under compare-and-swap before the card is charged, against the rule as it stands, so a rule switched off mid-sweep is not charged or re-armed, and overlapping sweeps share the limit. A charge refused before the processor was asked, or a replay of one already counted, gives its reload back. An admin's save swaps the rule's own fields and keeps the day's count. Saving, changing, choosing the default of and removing the org's cards, and charging its card on file, are its admins' too. A member whose wallet is their own person account still manages that wallet's cards. A loaded rule is bound to its key, so an edit is the rule on file. Before, an update wrote a second, parentless row the next read never found. The first read now removes such a row, and honours it when it switched the rule off; it never arms a rule or raises what it charges. Co-authored-by: Hanzo Dev <[email protected]>
852 lines
33 KiB
Go
852 lines
33 KiB
Go
package billing
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/middleware"
|
|
"github.com/hanzoai/commerce/models/organization"
|
|
"github.com/hanzoai/commerce/models/paymentmethod"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
"github.com/hanzoai/commerce/payment"
|
|
"github.com/hanzoai/commerce/payment/processor"
|
|
"github.com/hanzoai/commerce/thirdparty/kms"
|
|
"github.com/hanzoai/commerce/types"
|
|
"github.com/hanzoai/commerce/util/json/http"
|
|
"github.com/hanzoai/commerce/util/nscontext"
|
|
)
|
|
|
|
// preAuthVerifier is implemented by processors that support pre-authorization
|
|
// cancellation (e.g. Square). Allows voiding a hold immediately after verifying
|
|
// the card is real.
|
|
type preAuthVerifier interface {
|
|
processor.PaymentProcessor
|
|
CancelAuthorization(ctx context.Context, paymentID string) error
|
|
}
|
|
|
|
// verifyCardWithPreAuth does a $1.00 Square pre-auth against the provided nonce
|
|
// to confirm the card is real and chargeable, then immediately voids it.
|
|
// Returns an error (with a user-facing message) if the card is declined.
|
|
//
|
|
// reg must be the per-org registry (payment.ProcessorsForOrg) so the Square
|
|
// processor carries the org's real credentials; the global registry holds an
|
|
// empty Square singleton and would silently skip verification.
|
|
func verifyCardWithPreAuth(ctx context.Context, reg *processor.Registry, nonce, customerID string) error {
|
|
p, err := reg.Get(processor.Square)
|
|
if err != nil {
|
|
// Square not configured — skip pre-auth (not an error, just log)
|
|
return nil
|
|
}
|
|
|
|
verifier, ok := p.(preAuthVerifier)
|
|
if !ok || !verifier.IsAvailable(ctx) {
|
|
return nil
|
|
}
|
|
|
|
// $1.00 pre-auth to verify the card is real and has available credit.
|
|
// Don't pass CustomerID — Square requires a Square-generated customer ID,
|
|
// not our IAM user ID. The nonce alone is sufficient for verification.
|
|
result, err := verifier.Authorize(ctx, processor.PaymentRequest{
|
|
Amount: currency.Cents(100), // $1.00
|
|
Currency: currency.USD,
|
|
Token: nonce,
|
|
Description: "Card verification hold (will be voided immediately)",
|
|
})
|
|
if err != nil || !result.Success {
|
|
// Return a clean, user-friendly error instead of raw API responses.
|
|
reason := parseCardDeclineReason(result, err)
|
|
return fmt.Errorf("%s", reason)
|
|
}
|
|
|
|
// Immediately void the authorization — we only needed to verify the card.
|
|
if voidErr := verifier.CancelAuthorization(ctx, result.TransactionID); voidErr != nil {
|
|
// Non-fatal: the hold will expire on its own (Square: ~7 days).
|
|
// Log but don't block the user.
|
|
log.Warn("Failed to void pre-auth %s: %v", result.TransactionID, voidErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseCardDeclineReason returns a single clean sentence explaining why the card was declined.
|
|
func parseCardDeclineReason(result *processor.PaymentResult, err error) string {
|
|
if result == nil && err != nil {
|
|
if strings.Contains(err.Error(), "timeout") {
|
|
return "Card verification timed out. Please try again."
|
|
}
|
|
return "Unable to verify card. Please try again or use a different card."
|
|
}
|
|
|
|
msg := ""
|
|
if result != nil {
|
|
msg = result.ErrorMessage
|
|
}
|
|
lower := strings.ToLower(msg)
|
|
|
|
switch {
|
|
case strings.Contains(lower, "insufficient_funds"):
|
|
return "Card declined — insufficient funds."
|
|
case strings.Contains(lower, "transaction_limit"):
|
|
return "Card declined — transaction limit reached. Please try a different card."
|
|
case strings.Contains(lower, "address_verification_failure") || strings.Contains(lower, "avs_rejected"):
|
|
return "Card declined — billing address does not match. Please check your address and try again."
|
|
case strings.Contains(lower, "cvv") || strings.Contains(lower, "cvc"):
|
|
return "Card declined — incorrect security code (CVV)."
|
|
case strings.Contains(lower, "expired"):
|
|
return "Card declined — card is expired."
|
|
case strings.Contains(lower, "invalid_card") || strings.Contains(lower, "invalid_account"):
|
|
return "Card declined — invalid card number."
|
|
case strings.Contains(lower, "stolen") || strings.Contains(lower, "lost"):
|
|
return "Card declined — please contact your bank."
|
|
case strings.Contains(lower, "do_not_honor") || strings.Contains(lower, "generic_decline"):
|
|
return "Card declined by your bank. Please try a different card or contact your bank."
|
|
case msg != "":
|
|
return "Card declined. Please try a different card."
|
|
default:
|
|
return "Unable to verify card. Please try again or use a different card."
|
|
}
|
|
}
|
|
|
|
// The refusals these cores make, as VALUES. A core has no request to answer on,
|
|
// so it says which KIND of refusal happened and the endpoint it was reached through
|
|
// picks the status — the same split subValidationError makes for subscriptions.
|
|
|
|
// errNoMethod is the single answer to "not there, or not yours". A row the
|
|
// caller's namespace does not hold and a row that belongs to another subject
|
|
// refuse identically, so the refusal can never be read as proof that a card id
|
|
// is real.
|
|
var errNoMethod = errors.New("payment method not found")
|
|
|
|
// IsMethodNotFound reports whether err is that one answer. A caller outside this
|
|
// package has to answer it exactly as the endpoint does — not found, no detail — and
|
|
// an unexported sentinel cannot be asked about.
|
|
func IsMethodNotFound(err error) bool { return errors.Is(err, errNoMethod) }
|
|
|
|
// methodValidationError marks a refusal of what the CALLER asked for: a request
|
|
// naming no customer, a card with nothing to charge, a body that is not the shape
|
|
// it claims.
|
|
type methodValidationError struct{ msg string }
|
|
|
|
func (e methodValidationError) Error() string { return e.msg }
|
|
|
|
// IsMethodRefused reports whether err is that refusal, as distinct from the store
|
|
// failing. The asker can fix the first and can do nothing about the second, which
|
|
// is why they carry different statuses — and why a caller that could not tell
|
|
// them apart would report every typo as an outage.
|
|
func IsMethodRefused(err error) bool {
|
|
var ve methodValidationError
|
|
return errors.As(err, &ve)
|
|
}
|
|
|
|
// declineError marks a card the processor refused. Its msg is already the
|
|
// customer-facing sentence parseCardDeclineReason produced, which is why the endpoint
|
|
// sends it verbatim rather than writing a second one.
|
|
type declineError struct{ msg string }
|
|
|
|
func (e declineError) Error() string { return e.msg }
|
|
|
|
// IsCardDeclined reports whether err is the bank's answer rather than ours —
|
|
// neither of the other two is true of it: the request was well formed and nothing
|
|
// here failed. The only useful thing to do with it is offer another card, so it
|
|
// must be distinguishable from both.
|
|
func IsCardDeclined(err error) bool {
|
|
var de declineError
|
|
return errors.As(err, &de)
|
|
}
|
|
|
|
// CreateMethodIn is the whole input of saving a payment method: the body a
|
|
// browser posts, and the same fields a peer fills in over the internal plane.
|
|
type CreateMethodIn struct {
|
|
CustomerId string `json:"customerId"`
|
|
Type string `json:"type"` // "card" | "bank_account" | "crypto" | "wire" | "paypal" | "balance"
|
|
Card *paymentmethod.CardDetails `json:"card,omitempty"`
|
|
BankAccount *paymentmethod.BankAccountDetails `json:"bankAccount,omitempty"`
|
|
Crypto *paymentmethod.CryptoDetails `json:"crypto,omitempty"`
|
|
Wire *paymentmethod.WireDetails `json:"wire,omitempty"`
|
|
PayPal *paymentmethod.PayPalDetails `json:"paypal,omitempty"`
|
|
BillingAddress *types.Address `json:"billingAddress,omitempty"`
|
|
ProviderRef string `json:"providerRef,omitempty"`
|
|
ProviderType string `json:"providerType,omitempty"`
|
|
// Metadata is the caller's free-form extras, carried raw so the input crosses
|
|
// the typed plane the same way the answer does.
|
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// CreateMethod saves a payment method for a subject and returns it, plus whether
|
|
// the row is NEW: saving a card that is already on file answers with the row that
|
|
// already holds it rather than stacking a duplicate, and the caller has to be
|
|
// able to tell which happened.
|
|
//
|
|
// It takes values rather than a request because the browser's endpoint is not the
|
|
// only place this is asked from — a peer holding no datastore asks it over the
|
|
// internal plane — and a second implementation of "vault a nonce and persist a
|
|
// method" is a second place for the card-on-file dedupe to go missing.
|
|
//
|
|
// email is the caller's own address, resolved at the endpoint from its credential: it
|
|
// names the Square customer profile, and a core must never read an identity it
|
|
// was not handed. creds hydrates the org's processor credentials; nil is
|
|
// legitimate (dev/tests, env-var creds).
|
|
func CreateMethod(ctx context.Context, org *organization.Organization, email string, creds *kms.CachedClient, in CreateMethodIn) (*Method, bool, error) {
|
|
if org == nil {
|
|
return nil, false, errors.New("payment methods: no organization")
|
|
}
|
|
|
|
// Hydrate payment credentials from KMS so the per-org Square processor
|
|
// used for card verification carries real credentials.
|
|
if creds != nil {
|
|
if err := kms.Hydrate(creds, org); err != nil {
|
|
log.Error("KMS hydration failed for org %q: %v", org.Name, err)
|
|
}
|
|
}
|
|
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
|
|
var meta map[string]interface{}
|
|
if len(in.Metadata) > 0 {
|
|
if err := json.Unmarshal(in.Metadata, &meta); err != nil {
|
|
return nil, false, methodValidationError{"invalid request body"}
|
|
}
|
|
}
|
|
|
|
if in.CustomerId == "" {
|
|
return nil, false, methodValidationError{"customerId is required"}
|
|
}
|
|
|
|
// When a Square nonce/sourceId is provided, vault it as a reusable
|
|
// card-on-file so the saved method can be charged later (top-ups, plan
|
|
// checkout, auto-recharge). saveCard is the ONE constructor: it validates
|
|
// by vaulting (the nonce is single-use — a separate pre-auth would consume
|
|
// it), stamps the card's brand/last4/expiry on the row, and returns the
|
|
// EXISTING row instead of stacking a duplicate when the same card is saved
|
|
// again. Use the per-org registry so Square carries the org's credentials.
|
|
if in.ProviderRef != "" {
|
|
reg := payment.ProcessorsForOrg(org)
|
|
if cp, ok := squareCustomerProcessorFrom(reg); ok {
|
|
pm, created, err := saveCard(ctx, db, cp, in.CustomerId, strings.TrimSpace(email), in.ProviderRef)
|
|
if err != nil {
|
|
return nil, false, declineError{parseCardDeclineReason(&processor.PaymentResult{ErrorMessage: err.Error()}, err)}
|
|
}
|
|
// Caller-supplied extras land on whichever row answered.
|
|
if in.BillingAddress != nil {
|
|
pm.BillingAddress = in.BillingAddress
|
|
}
|
|
for k, v := range meta {
|
|
if pm.Metadata == nil {
|
|
pm.Metadata = map[string]interface{}{}
|
|
}
|
|
pm.Metadata[k] = v
|
|
}
|
|
if err := pm.Update(); err != nil {
|
|
log.Error("Failed to update payment method extras: %v", err)
|
|
}
|
|
m := methodOf(pm)
|
|
return &m, created, nil
|
|
} else if err := verifyCardWithPreAuth(ctx, reg, in.ProviderRef, in.CustomerId); err != nil {
|
|
// Square not available as a customer processor — fall back to the
|
|
// legacy verify-only flow (the saved card is NOT reusable).
|
|
return nil, false, declineError{err.Error()}
|
|
}
|
|
}
|
|
|
|
// A CARD with nothing to charge is not a payment method — it is a row that
|
|
// looks like one. Without a providerRef there is no nonce to vault and no
|
|
// card-on-file id to store, so what lands is a `type:"card"` record with no
|
|
// brand, no last4 and no way to bill it: it shows up in the customer's saved
|
|
// cards, and any renewal that picks it fails on a method that was never real.
|
|
// Measured on the live portal route the moment it opened — a POST carrying
|
|
// only {"type":"card"} answered 201 and created exactly that.
|
|
//
|
|
// Refuse it here rather than at the edge, because every caller reaches this
|
|
// one constructor. Other method types (bank/wire/paypal/crypto) legitimately
|
|
// carry their details in the body instead, so the rule is scoped to cards.
|
|
if strings.EqualFold(strings.TrimSpace(in.Type), "card") &&
|
|
strings.TrimSpace(in.ProviderRef) == "" && in.Card == nil {
|
|
return nil, false, methodValidationError{"a card payment method requires a tokenized card (providerRef)"}
|
|
}
|
|
|
|
pm := paymentmethod.New(db)
|
|
pm.CustomerId = in.CustomerId
|
|
pm.UserId = in.CustomerId
|
|
if in.Type != "" {
|
|
pm.Type = in.Type
|
|
}
|
|
pm.Card = in.Card
|
|
pm.BankAccount = in.BankAccount
|
|
pm.Crypto = in.Crypto
|
|
pm.Wire = in.Wire
|
|
pm.PayPal = in.PayPal
|
|
pm.BillingAddress = in.BillingAddress
|
|
pm.ProviderRef = in.ProviderRef
|
|
pm.ProviderType = in.ProviderType
|
|
|
|
switch {
|
|
case in.Card != nil:
|
|
pm.Name = in.Card.Brand + " ending in " + in.Card.Last4
|
|
case in.Crypto != nil:
|
|
if in.Crypto.Label != "" {
|
|
pm.Name = in.Crypto.Label
|
|
} else if in.Crypto.Network != "" {
|
|
pm.Name = in.Crypto.Network + " wallet"
|
|
} else {
|
|
pm.Name = "Crypto wallet"
|
|
}
|
|
case in.Wire != nil:
|
|
if in.Wire.BankName != "" {
|
|
pm.Name = in.Wire.BankName + " wire"
|
|
} else {
|
|
pm.Name = "Bank wire"
|
|
}
|
|
case in.PayPal != nil:
|
|
pm.Name = strings.TrimSpace("PayPal " + in.PayPal.Email)
|
|
}
|
|
|
|
if meta == nil {
|
|
meta = make(map[string]interface{})
|
|
}
|
|
if in.ProviderRef != "" {
|
|
meta["squareVerified"] = true
|
|
}
|
|
pm.Metadata = meta
|
|
|
|
if err := pm.Create(); err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
m := methodOf(pm)
|
|
return &m, true, nil
|
|
}
|
|
|
|
// CreatePaymentMethod creates and attaches a payment method to a customer.
|
|
//
|
|
// POST /v1/billing/methods
|
|
func CreatePaymentMethod(c *zip.Ctx) error {
|
|
// The OK form: IAMTokenRequired falls through WITHOUT setting the
|
|
// "organization" local when the gateway named no principal, and the MustGet
|
|
// form panics there — a 500 with no body, after the money has moved. Refuse
|
|
// before touching anything. See SubscribeWithCard, where this cost a $99
|
|
// charge with no subscription behind it.
|
|
org, ok := middleware.GetOrganizationOK(c)
|
|
if !ok || org == nil {
|
|
return http.Fail(c, 401, "sign in to add a payment method", nil)
|
|
}
|
|
if !mayChangeWallet(c) {
|
|
return http.Fail(c, 403, askAnAdmin(c, "change its cards"), nil)
|
|
}
|
|
|
|
var in CreateMethodIn
|
|
if err := c.Bind(&in); err != nil {
|
|
return http.Fail(c, 400, "invalid request body", err)
|
|
}
|
|
|
|
// Both come off the request and neither may be re-derived in the core: the
|
|
// email is who the credential says the caller is, the KMS client is this
|
|
// request's cached one.
|
|
email, _ := c.Locals("iam_email").(string)
|
|
creds, _ := c.Locals("kms").(*kms.CachedClient)
|
|
|
|
method, created, err := CreateMethod(c.Context(), org, email, creds, in)
|
|
if err != nil {
|
|
switch {
|
|
case IsMethodRefused(err):
|
|
return http.Fail(c, 400, err.Error(), nil)
|
|
case IsCardDeclined(err):
|
|
return http.Fail(c, 402, err.Error(), nil)
|
|
}
|
|
log.Error("Failed to create payment method: %v", err, c)
|
|
return http.Fail(c, 500, "failed to create payment method", err)
|
|
}
|
|
|
|
if !created {
|
|
// The same card was already on file: the answer is that row, and 200
|
|
// states "nothing new was created" honestly.
|
|
return c.JSON(200, method)
|
|
}
|
|
return c.JSON(201, method)
|
|
}
|
|
|
|
// GetPaymentMethod retrieves a payment method by ID.
|
|
//
|
|
// GET /v1/billing/methods/:id
|
|
func GetPaymentMethod(c *zip.Ctx) error {
|
|
org := middleware.GetOrganization(c)
|
|
db := datastore.New(org.Namespaced(c.Context()))
|
|
|
|
pm := paymentmethod.New(db)
|
|
if err := pm.GetById(c.Param("id")); err != nil {
|
|
return http.Fail(c, 404, "payment method not found", err)
|
|
}
|
|
|
|
// Intra-org IDOR guard (#43a, per-user). This user-group route admits ANY
|
|
// authenticated org member (no Admin mask), and the :id path-param — which
|
|
// EdgeAuth does not pin — can name a DIFFERENT subject's saved card
|
|
// (last4/brand/billing address/providerRef/squareCardId) inside the caller's
|
|
// namespace. Non-privileged callers may read only their own subject's method;
|
|
// 404 (not 403) so card ids can't be probed.
|
|
if !callerMayReachBillingSubject(c, pm.CustomerId, pm.UserId) {
|
|
return http.Fail(c, 404, "payment method not found", nil)
|
|
}
|
|
|
|
return c.JSON(200, methodOf(pm))
|
|
}
|
|
|
|
// ListMethods is a subject's saved payment methods, healed and rendered — the
|
|
// QUERY, with no HTTP in it.
|
|
//
|
|
// It takes values rather than a request because two endpoints already ask it (a
|
|
// customer's own list, and the portal list a proxying host reads) and a peer
|
|
// holding no datastore asks it over the internal plane. Three copies of one query
|
|
// is how a saved-cards panel and a renewal come to disagree about which cards
|
|
// exist.
|
|
//
|
|
// An empty subject means "every method in the org" — what an absent customerId
|
|
// has always meant on the privileged path. WHO may ask that is settled at the
|
|
// endpoint, from the credential, and never re-derived here. An empty kind means "any
|
|
// type", which is what an absent ?type has always meant.
|
|
//
|
|
// creds hydrates the org's processor credentials before card facts are healed
|
|
// onto rows vaulted before the vault reported them; nil is legitimate (dev/tests,
|
|
// env-var creds). Nothing is hydrated when the subject holds no methods, because
|
|
// this read sits in front of a card field and a slow KMS must not stall it.
|
|
//
|
|
// The list is always a list, never nil: an org with no saved cards answers [].
|
|
func ListMethods(ctx context.Context, org *organization.Organization, subject, kind string, creds *kms.CachedClient) ([]Method, error) {
|
|
methods := make([]Method, 0)
|
|
if org == nil {
|
|
return methods, errors.New("payment methods: no organization")
|
|
}
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
|
|
q := paymentmethod.Query(db).Ancestor(db.NewKey("synckey", "", 1, nil))
|
|
if subject != "" {
|
|
q = q.Filter("CustomerId=", subject)
|
|
}
|
|
if kind != "" {
|
|
q = q.Filter("Type=", kind)
|
|
}
|
|
|
|
rows := make([]*paymentmethod.PaymentMethod, 0)
|
|
if _, err := q.Order("-Created").GetAll(&rows); err != nil {
|
|
return methods, err
|
|
}
|
|
|
|
// Backfill brand/last4/expiry onto vaulted rows saved before the vault
|
|
// reported card facts, so a customer can tell their saved cards apart.
|
|
// Best-effort — see heal.
|
|
if len(rows) > 0 {
|
|
if creds != nil {
|
|
if err := kms.Hydrate(creds, org); err != nil {
|
|
log.Error("KMS hydration failed for org %q: %v", org.Name, err)
|
|
}
|
|
}
|
|
heal(ctx, payment.ProcessorsForOrg(org), rows)
|
|
}
|
|
|
|
for _, pm := range rows {
|
|
methods = append(methods, methodOf(pm))
|
|
}
|
|
return methods, nil
|
|
}
|
|
|
|
// ListPaymentMethods lists payment methods for a customer.
|
|
//
|
|
// GET /v1/billing/methods?customerId=...&type=...
|
|
func ListPaymentMethods(c *zip.Ctx) error {
|
|
org := middleware.GetOrganization(c)
|
|
|
|
// Intra-org IDOR guard (#43a, enumeration). A non-privileged org member may
|
|
// only ever list its OWN subject's methods. EdgeAuth pins a PRESENT
|
|
// customerId/user query param to the org slug, but an ABSENT filter would
|
|
// return EVERY method in the namespace (incl. any service-token-created
|
|
// per-user records) — so force the subject filter for non-privileged callers,
|
|
// failing closed when no subject resolves. Privileged callers (service token /
|
|
// admin / global admin) keep the explicit client-supplied filter.
|
|
var subject string
|
|
if isPrivilegedBillingCaller(c) {
|
|
if customerId := c.Query("customerId"); customerId != "" {
|
|
subject = customerId
|
|
} else if user := c.Query("user"); user != "" {
|
|
subject = user
|
|
}
|
|
} else if subject = orgBillingKey(c); subject == "" {
|
|
return c.JSON(200, []Method{})
|
|
}
|
|
|
|
creds, _ := c.Locals("kms").(*kms.CachedClient)
|
|
methods, err := ListMethods(c.Context(), org, subject, c.Query("type"), creds)
|
|
if err != nil {
|
|
// A store that cannot be read has always answered an honest empty list
|
|
// here rather than a 500: the saved-cards panel renders empty instead of
|
|
// breaking the page around it.
|
|
log.Error("Failed to list payment methods: %v", err, c)
|
|
}
|
|
return c.JSON(200, methods)
|
|
}
|
|
|
|
type updatePaymentMethodRequest struct {
|
|
BillingAddress *types.Address `json:"billingAddress,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// UpdatePaymentMethod updates a payment method.
|
|
//
|
|
// PATCH /v1/billing/methods/:id
|
|
func UpdatePaymentMethod(c *zip.Ctx) error {
|
|
org := middleware.GetOrganization(c)
|
|
if !mayChangeWallet(c) {
|
|
return http.Fail(c, 403, askAnAdmin(c, "change its cards"), nil)
|
|
}
|
|
db := datastore.New(org.Namespaced(c.Context()))
|
|
|
|
pm := paymentmethod.New(db)
|
|
if err := pm.GetById(c.Param("id")); err != nil {
|
|
return http.Fail(c, 404, "payment method not found", err)
|
|
}
|
|
|
|
// Intra-org IDOR guard (#43a, per-user). Mutating another subject's card
|
|
// (billing address / metadata — including the squareCustomerId/squareCardId
|
|
// that route later charges) is a cross-subject tamper the unpinned :id can
|
|
// reach inside the caller's namespace. Non-privileged callers may update only
|
|
// their own subject's method; 404 so ids can't be probed.
|
|
if !callerMayReachBillingSubject(c, pm.CustomerId, pm.UserId) {
|
|
return http.Fail(c, 404, "payment method not found", nil)
|
|
}
|
|
|
|
var req updatePaymentMethodRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return http.Fail(c, 400, "invalid request body", err)
|
|
}
|
|
|
|
if req.BillingAddress != nil {
|
|
pm.BillingAddress = req.BillingAddress
|
|
}
|
|
if req.Metadata != nil {
|
|
pm.Metadata = req.Metadata
|
|
}
|
|
|
|
if err := pm.Update(); err != nil {
|
|
log.Error("Failed to update payment method: %v", err, c)
|
|
return http.Fail(c, 500, "failed to update payment method", err)
|
|
}
|
|
|
|
return c.JSON(200, methodOf(pm))
|
|
}
|
|
|
|
// Detachment is the answer to removing a saved method: the id that is now gone.
|
|
type Detachment struct {
|
|
Deleted bool `json:"deleted"`
|
|
Id string `json:"id"`
|
|
}
|
|
|
|
// DetachMethod removes a subject's saved method: the Square card-on-file first,
|
|
// best-effort so the vault stays in step, then the local row.
|
|
//
|
|
// It takes values rather than a request so the endpoints that publish this removal
|
|
// and a peer on the internal plane share one implementation of it. What it must
|
|
// NOT take is the caller: subject is the billing key the endpoint already proved the
|
|
// caller owns, and privileged is the service or admin caller that may act on any
|
|
// subject inside the org. Authority decided twice is authority that eventually
|
|
// disagrees with itself.
|
|
//
|
|
// A method the org's namespace does not hold and a method belonging to another
|
|
// subject both answer errNoMethod, so the refusal cannot be used to prove a card
|
|
// id exists.
|
|
//
|
|
// creds hydrates the org's processor credentials, and only when the row actually
|
|
// carries a Square card to detach; nil is legitimate (dev/tests, env-var creds).
|
|
func DetachMethod(ctx context.Context, org *organization.Organization, id, subject string, privileged bool, creds *kms.CachedClient) (*Detachment, error) {
|
|
if org == nil {
|
|
return nil, errors.New("payment methods: no organization")
|
|
}
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
|
|
pm := paymentmethod.New(db)
|
|
if err := pm.GetById(id); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", errNoMethod, err)
|
|
}
|
|
|
|
// Intra-org IDOR guard (#43a, per-user). Detaching another subject's card
|
|
// (soft-delete + Square card-on-file removal) breaks their saved-card charges
|
|
// and auto-recharge — a cross-subject mutation the unpinned :id can reach in
|
|
// the caller's namespace. A caller with no subject at all reaches nothing.
|
|
if !privileged && (subject == "" ||
|
|
(!strings.EqualFold(strings.TrimSpace(pm.CustomerId), subject) &&
|
|
!strings.EqualFold(strings.TrimSpace(pm.UserId), subject))) {
|
|
return nil, errNoMethod
|
|
}
|
|
|
|
// Best-effort: detach the card-on-file from Square so its vault stays in
|
|
// sync. Non-fatal — the local record is still soft-deleted on failure.
|
|
if pm.Metadata != nil {
|
|
custID, _ := pm.Metadata["squareCustomerId"].(string)
|
|
cardID, _ := pm.Metadata["squareCardId"].(string)
|
|
if custID != "" && cardID != "" {
|
|
if creds != nil {
|
|
if err := kms.Hydrate(creds, org); err != nil {
|
|
log.Error("KMS hydration failed for org %q: %v", org.Name, err)
|
|
}
|
|
}
|
|
reg := payment.ProcessorsForOrg(org)
|
|
if cp, ok := squareCustomerProcessorFrom(reg); ok {
|
|
if err := cp.RemovePaymentMethod(ctx, custID, cardID); err != nil {
|
|
log.Warn("Failed to remove Square card %s for customer %s: %v", cardID, custID, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := pm.Delete(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Detachment{Deleted: true, Id: pm.Id()}, nil
|
|
}
|
|
|
|
// DetachPaymentMethod detaches (soft-deletes) a payment method.
|
|
//
|
|
// DELETE /v1/billing/methods/:id
|
|
func DetachPaymentMethod(c *zip.Ctx) error {
|
|
// The OK form: IAMTokenRequired falls through WITHOUT setting the
|
|
// "organization" local when the gateway named no principal, and the MustGet
|
|
// form panics there — a 500 with no body, after the money has moved. Refuse
|
|
// before touching anything. See SubscribeWithCard, where this cost a $99
|
|
// charge with no subscription behind it.
|
|
org, ok := middleware.GetOrganizationOK(c)
|
|
if !ok || org == nil {
|
|
return http.Fail(c, 401, "sign in to remove a payment method", nil)
|
|
}
|
|
if !mayChangeWallet(c) {
|
|
return http.Fail(c, 403, askAnAdmin(c, "remove its cards"), nil)
|
|
}
|
|
|
|
creds, _ := c.Locals("kms").(*kms.CachedClient)
|
|
detached, err := DetachMethod(c.Context(), org, c.Param("id"),
|
|
orgBillingKey(c), isPrivilegedBillingCaller(c), creds)
|
|
if err != nil {
|
|
if IsMethodNotFound(err) {
|
|
return http.Fail(c, 404, "payment method not found", err)
|
|
}
|
|
log.Error("Failed to detach payment method: %v", err, c)
|
|
return http.Fail(c, 500, "failed to detach payment method", err)
|
|
}
|
|
|
|
return c.JSON(200, detached)
|
|
}
|
|
|
|
// defaultLocks serializes every change to which card is a subject's default —
|
|
// the first card's automatic default and an explicit choice — per (namespace,
|
|
// subject), so two of them at once leave exactly one default. An org's store is
|
|
// written by one process (replicas:1, or the shard router's one owner per org),
|
|
// so an in-process lock is the serialization point, as redeemLocks is in
|
|
// api/coupon.
|
|
var defaultLocks sync.Map // map[string]*sync.Mutex
|
|
|
|
func defaultLock(db *datastore.Datastore, subject string) *sync.Mutex {
|
|
m, _ := defaultLocks.LoadOrStore(nscontext.GetNamespace(db.Context)+"\x00"+subject, &sync.Mutex{})
|
|
return m.(*sync.Mutex)
|
|
}
|
|
|
|
// defaultsOf is the subject's default cards, as auto-recharge finds them. More
|
|
// than one is possible only from a write interrupted between marking one card
|
|
// and clearing another; the next choice clears it.
|
|
func defaultsOf(db *datastore.Datastore, subject string) ([]*paymentmethod.PaymentMethod, error) {
|
|
rows := make([]*paymentmethod.PaymentMethod, 0, 1)
|
|
_, err := paymentmethod.Query(db).Ancestor(db.NewKey("synckey", "", 1, nil)).
|
|
Filter("CustomerId=", subject).
|
|
Filter("IsDefault=", true).
|
|
GetAll(&rows)
|
|
return rows, err
|
|
}
|
|
|
|
// rewrite persists a method loaded by id under the synckey root every method
|
|
// query reads by ancestor. A row loaded by id carries a key with no parent, and
|
|
// written back as loaded it would drop out of every list and lookup.
|
|
func rewrite(db *datastore.Datastore, pm *paymentmethod.PaymentMethod) error {
|
|
k := pm.Key()
|
|
if err := pm.SetKey(db.NewKey(k.Kind(), k.StringID(), k.IntID(), db.NewKey("synckey", "", 1, nil))); err != nil {
|
|
return err
|
|
}
|
|
return pm.Update()
|
|
}
|
|
|
|
// DefaultChange is what choosing a default card answers with.
|
|
type DefaultChange struct {
|
|
// Method is the card that is the default now.
|
|
Method Method `json:"method"`
|
|
// Previous names the default before the choice: one card, none when there was
|
|
// no default, and the chosen card itself when it already was.
|
|
Previous []string `json:"previous"`
|
|
}
|
|
|
|
// SetDefaultMethod makes one of a subject's saved cards its default — the card
|
|
// auto-recharge charges.
|
|
//
|
|
// It takes values rather than a request: subject is the billing key the
|
|
// endpoint already resolved from the caller's credential, and the card must be
|
|
// that subject's own. A card the org's namespace does not hold and a card of
|
|
// another subject both answer errNoMethod, so the refusal cannot prove a card id
|
|
// exists. Only a vaulted card can be the default: a method with nothing to
|
|
// charge answers errMethodUnchargeable. A refusal changes nothing.
|
|
//
|
|
// The chosen card is marked before the others are cleared, so a write that
|
|
// fails between the two leaves two of this subject's own cards marked, which
|
|
// the next choice clears, rather than none.
|
|
func SetDefaultMethod(ctx context.Context, org *organization.Organization, id, subject string) (*DefaultChange, error) {
|
|
if org == nil {
|
|
return nil, errors.New("payment methods: no organization")
|
|
}
|
|
if subject == "" {
|
|
return nil, errNoMethod
|
|
}
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
|
|
mu := defaultLock(db, subject)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
pm := paymentmethod.New(db)
|
|
if err := pm.GetById(id); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", errNoMethod, err)
|
|
}
|
|
if pm.CustomerId != subject && pm.UserId != subject {
|
|
return nil, errNoMethod
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(pm.Type), "card") || strings.TrimSpace(pm.ProviderRef) == "" {
|
|
return nil, errMethodUnchargeable
|
|
}
|
|
|
|
held, err := defaultsOf(db, subject)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
previous := make([]string, 0, len(held))
|
|
for _, h := range held {
|
|
previous = append(previous, h.Id())
|
|
}
|
|
|
|
if !pm.IsDefault {
|
|
pm.IsDefault = true
|
|
if err := rewrite(db, pm); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
for _, h := range held {
|
|
if h.Id() == pm.Id() {
|
|
continue
|
|
}
|
|
was := paymentmethod.New(db)
|
|
if err := was.GetById(h.Id()); err != nil {
|
|
return nil, err
|
|
}
|
|
was.IsDefault = false
|
|
if err := rewrite(db, was); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &DefaultChange{Method: methodOf(pm), Previous: previous}, nil
|
|
}
|
|
|
|
type setDefaultRequest struct {
|
|
PaymentMethodId string `json:"paymentMethodId"`
|
|
}
|
|
|
|
// SetDefaultPaymentMethod sets the default payment method for a customer.
|
|
//
|
|
// POST /v1/billing/customers/:id/default-payment-method
|
|
func SetDefaultPaymentMethod(c *zip.Ctx) error {
|
|
org, ok := middleware.GetOrganizationOK(c)
|
|
if !ok || org == nil {
|
|
return http.Fail(c, 401, "sign in to choose a default payment method", nil)
|
|
}
|
|
if !mayChangeWallet(c) {
|
|
return http.Fail(c, 403, askAnAdmin(c, "change its default card"), nil)
|
|
}
|
|
customerId := c.Param("id")
|
|
|
|
// Intra-org IDOR guard (#43a, per-user). The :id customer path-param names
|
|
// whose default changes; a non-privileged caller reaches only its own. 404
|
|
// keeps customer ids unprobeable.
|
|
if !callerMayReachBillingSubject(c, customerId) {
|
|
return http.Fail(c, 404, "payment method not found", nil)
|
|
}
|
|
|
|
var req setDefaultRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return http.Fail(c, 400, "invalid request body", err)
|
|
}
|
|
|
|
ch, err := SetDefaultMethod(c.Context(), org, req.PaymentMethodId, customerId)
|
|
switch {
|
|
case IsMethodNotFound(err):
|
|
return http.Fail(c, 404, "payment method not found", nil)
|
|
case IsMethodUnchargeable(err):
|
|
return http.Fail(c, 422, err.Error(), nil)
|
|
case err != nil:
|
|
log.Error("Failed to set default payment method: %v", err, c)
|
|
return http.Fail(c, 500, "failed to set default", err)
|
|
}
|
|
return c.JSON(200, ch.Method)
|
|
}
|
|
|
|
// Method is a saved payment instrument as every billing surface states it: the
|
|
// row's identity, what kind of instrument it is, and only those details the
|
|
// instrument actually carries.
|
|
//
|
|
// The json tags ARE the wire contract, key for key with the hand-built map they
|
|
// replace — a detail a row does not hold stays absent rather than arriving null,
|
|
// which is what lets a client test presence instead of emptiness. Being a type
|
|
// rather than a map is what lets the same answer cross the internal plane.
|
|
type Method struct {
|
|
Id string `json:"id"`
|
|
CustomerId string `json:"customerId"`
|
|
Type string `json:"type"`
|
|
IsDefault bool `json:"isDefault"`
|
|
Created time.Time `json:"created"`
|
|
|
|
Name string `json:"name,omitempty"`
|
|
Card *paymentmethod.CardDetails `json:"card,omitempty"`
|
|
BankAccount *paymentmethod.BankAccountDetails `json:"bankAccount,omitempty"`
|
|
Crypto *paymentmethod.CryptoDetails `json:"crypto,omitempty"`
|
|
Wire *paymentmethod.WireDetails `json:"wire,omitempty"`
|
|
PayPal *paymentmethod.PayPalDetails `json:"paypal,omitempty"`
|
|
BillingAddress *types.Address `json:"billingAddress,omitempty"`
|
|
ProviderRef string `json:"providerRef,omitempty"`
|
|
// Metadata is the row's free-form extras, carried raw: it renders exactly as
|
|
// the map did, including an empty object for a row that holds an empty one,
|
|
// and raw JSON is the one free-form shape the typed plane can carry.
|
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// methodOf states a stored row as the Method every endpoint answers with.
|
|
func methodOf(pm *paymentmethod.PaymentMethod) Method {
|
|
m := Method{
|
|
Id: pm.Id(),
|
|
CustomerId: pm.CustomerId,
|
|
Type: pm.Type,
|
|
IsDefault: pm.IsDefault,
|
|
Created: pm.Created,
|
|
Name: pm.Name,
|
|
Card: pm.Card,
|
|
BankAccount: pm.BankAccount,
|
|
Crypto: pm.Crypto,
|
|
Wire: pm.Wire,
|
|
PayPal: pm.PayPal,
|
|
BillingAddress: pm.BillingAddress,
|
|
ProviderRef: pm.ProviderRef,
|
|
}
|
|
if pm.Metadata != nil {
|
|
if raw, err := json.Marshal(pm.Metadata); err == nil {
|
|
m.Metadata = raw
|
|
}
|
|
}
|
|
return m
|
|
}
|