mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
823 lines
31 KiB
Go
823 lines
31 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/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, "pan_failure") || strings.Contains(lower, "pan") || 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.
|
|
func CreateMethod(ctx context.Context, org *organization.Organization, email string, in CreateMethodIn) (*Method, bool, error) {
|
|
if org == nil {
|
|
return nil, false, errors.New("payment methods: no organization")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// The email comes off the request and may not be re-derived in the core: it
|
|
// is who the credential says the caller is.
|
|
email, _ := c.Locals("iam_email").(string)
|
|
|
|
method, created, err := CreateMethod(c.Context(), org, email, 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.
|
|
//
|
|
// Card facts are healed onto rows vaulted before the vault reported them, and only
|
|
// when the subject holds methods, because this read sits in front of a card field.
|
|
//
|
|
// 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) ([]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 {
|
|
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 / SuperAdmin) 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{})
|
|
}
|
|
|
|
methods, err := ListMethods(c.Context(), org, subject, c.Query("type"))
|
|
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.
|
|
func DetachMethod(ctx context.Context, org *organization.Organization, id, subject string, privileged bool) (*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 != "" {
|
|
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)
|
|
}
|
|
|
|
detached, err := DetachMethod(c.Context(), org, c.Param("id"),
|
|
orgBillingKey(c), isPrivilegedBillingCaller(c))
|
|
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
|
|
}
|