mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
1002 lines
45 KiB
Go
1002 lines
45 KiB
Go
package billing
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/zap-proto/zip"
|
||
|
||
"github.com/hanzoai/commerce/api/promo"
|
||
"github.com/hanzoai/commerce/billing/engine"
|
||
"github.com/hanzoai/commerce/datastore"
|
||
"github.com/hanzoai/commerce/events"
|
||
"github.com/hanzoai/commerce/log"
|
||
"github.com/hanzoai/commerce/middleware"
|
||
"github.com/hanzoai/commerce/models/billinginvoice"
|
||
"github.com/hanzoai/commerce/models/idempotencykey"
|
||
"github.com/hanzoai/commerce/models/organization"
|
||
"github.com/hanzoai/commerce/models/paymentmethod"
|
||
storemodel "github.com/hanzoai/commerce/models/store"
|
||
"github.com/hanzoai/commerce/models/subscription"
|
||
"github.com/hanzoai/commerce/models/types/currency"
|
||
"github.com/hanzoai/commerce/payment"
|
||
"github.com/hanzoai/commerce/payment/processor"
|
||
"github.com/hanzoai/commerce/thirdparty/kms"
|
||
types "github.com/hanzoai/commerce/types"
|
||
"github.com/hanzoai/commerce/util/json/http"
|
||
)
|
||
|
||
// processorsForOrg resolves the per-org payment processor registry — the ONE seam
|
||
// through which saved-card subscription code reaches the payment provider. It is a
|
||
// package var so tests can inject a registry with a fake Square processor;
|
||
// production is payment.ProcessorsForOrg (built from the org's KMS-hydrated
|
||
// credentials). Callers MUST hydrate the org's creds first (hydratePaymentCreds).
|
||
var processorsForOrg = payment.ProcessorsForOrg
|
||
|
||
// hydratePaymentCreds loads the org's payment-provider credentials from KMS (via
|
||
// the request-scoped cached client in c.Locals) so processorsForOrg sees real
|
||
// Square credentials. Best-effort + non-fatal — mirrors TopupWithToken /
|
||
// CreatePaymentMethod. A no-op when KMS is not wired (dev/tests / env-var creds).
|
||
func hydratePaymentCreds(c *zip.Ctx, org *organization.Organization) {
|
||
if v := c.Locals("kms"); v != nil {
|
||
if kmsClient, ok := v.(*kms.CachedClient); ok {
|
||
if err := kms.Hydrate(kmsClient, org); err != nil {
|
||
log.Error("KMS hydration failed for org %q: %v", org.Name, err, c)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// inOrgSubject narrows a requested billing subject to the caller's OWN org: it
|
||
// returns the requested subject only when it is the org slug or a <org>/<user>
|
||
// child of it, else the org slug — fail-secure, so a subject (and the money keyed
|
||
// to it) can never be steered outside the caller's org. The in-org bound used by
|
||
// subscribeSubject. (The top-up endpoints no longer take a requested subject at all —
|
||
// they credit the caller's own payer identity, userBillingKey.)
|
||
func inOrgSubject(org, requested string) string {
|
||
s := strings.ToLower(strings.TrimSpace(requested))
|
||
if s == org || strings.HasPrefix(s, org+"/") {
|
||
return s
|
||
}
|
||
return org
|
||
}
|
||
|
||
// subscribeSubject resolves the billing subject a card subscription belongs to:
|
||
// the org slug, or a finer in-org <org>/<user> subject when the requested userId
|
||
// is provably within the caller's own org (the subject arrives in the request
|
||
// BODY). Returns "" when no org resolves (caller 401s).
|
||
//
|
||
// NOTE: unlike the top-up endpoints (which credit the caller's own payer identity via
|
||
// userBillingKey), a subscription still honors a body userId within the org bound.
|
||
// A subscription is a durable object an org admin may create ON BEHALF OF a member,
|
||
// so its owner is not necessarily the caller. If that ever needs to become the
|
||
// signed-identity rule too, it is a deliberate change to subscription ownership,
|
||
// not a drive-by — hence it is called out here rather than silently aligned.
|
||
func subscribeSubject(c *zip.Ctx, requested string) string {
|
||
org := orgBillingKey(c)
|
||
if org == "" {
|
||
return ""
|
||
}
|
||
return inOrgSubject(org, requested)
|
||
}
|
||
|
||
type subscribeCardRequest struct {
|
||
// Exactly one of SourceID | MethodID names the card. SourceID is a fresh
|
||
// Square Web Payments SDK nonce (single-use, vaulted before charging);
|
||
// MethodID (wire: paymentMethodId) is a payment method the subject already
|
||
// saved — its vaulted card is charged directly, nothing is re-entered and
|
||
// no new row is created.
|
||
SourceID string `json:"sourceId,omitempty"`
|
||
MethodID string `json:"paymentMethodId,omitempty"`
|
||
PlanID string `json:"planId"`
|
||
UserID string `json:"userId,omitempty"`
|
||
StoreID string `json:"storeId,omitempty"`
|
||
Quantity int `json:"quantity,omitempty"`
|
||
Currency string `json:"currency,omitempty"`
|
||
// Level picks which of the plan's published prices to buy at — an INDEX into
|
||
// the catalog's `prices`, never an amount. 0 (the default) is the plan's base
|
||
// price, so every client that predates levels keeps buying exactly what it
|
||
// bought before. plan.LevelPrice says why the wire carries a choice: there is
|
||
// no field here an amount could be written in, so underpaying is not a check
|
||
// that can be forgotten — it is a request that cannot be expressed.
|
||
Level int `json:"level,omitempty"`
|
||
// Interval picks which of the plan's published billing periods to buy at.
|
||
// Empty (the default) and "month" both buy the recurring price, so every
|
||
// client that predates annual billing keeps buying exactly what it bought
|
||
// before; "year" buys the catalog's annual price, charged for the whole year
|
||
// at once. planAtInterval says what that amounts to.
|
||
Interval types.Interval `json:"interval,omitempty"`
|
||
}
|
||
|
||
// SubscribeIn is the whole input of a card subscription. Every field is a VALUE
|
||
// the caller resolved — nothing is read from a request, a header or the
|
||
// environment inside the core.
|
||
type SubscribeIn struct {
|
||
// Exactly one of SourceID | MethodID names the card. SourceID is a fresh
|
||
// Square Web Payments SDK nonce (single-use, vaulted before charging);
|
||
// MethodID is a card the subject already saved, charged directly.
|
||
SourceID string
|
||
MethodID string
|
||
// PlanID is the plan to buy. Its CATALOG price is what gets charged; there is
|
||
// no amount on this input, so underpaying is not a check that can be
|
||
// forgotten — it is a request that cannot be expressed.
|
||
PlanID string
|
||
// Subject is the billing subject this subscription belongs to, ALREADY bounded
|
||
// to the caller's own org by the endpoint that resolved it (subscribeSubject).
|
||
Subject string
|
||
// StoreID scopes the sale to one store, when the seller has several.
|
||
StoreID string
|
||
// Quantity is the billable seat count for a per-seat plan, floored at the
|
||
// catalog's minSeats. A flat plan always bills once.
|
||
Quantity int
|
||
// Level picks which of the plan's published prices to buy at — an INDEX into
|
||
// the catalog's prices, never an amount.
|
||
Level int
|
||
// Interval picks which of the plan's published billing periods to buy at.
|
||
// Empty means the plan's recurring price; types.Yearly buys the year at once.
|
||
Interval types.Interval
|
||
// Currency is honored only when it matches the plan's own; a mismatch is
|
||
// refused rather than charged in a weaker unit.
|
||
Currency string
|
||
// Email is stamped on the Square customer a fresh vault creates.
|
||
Email string
|
||
// IdempotencyKey is the caller's explicit retry key. Empty falls back to the
|
||
// windowed derivation over the stable facts (store, plan, level).
|
||
IdempotencyKey string
|
||
// Promo is the platform's active offer, resolved by the caller because
|
||
// reading it needs a request. It can only ever REDUCE the charge, and a
|
||
// charge that falls to nothing is refused rather than sold — so the price
|
||
// this core charges is still the catalog's, discounted by an offer, never a
|
||
// number the caller chose.
|
||
Promo *promo.Promo
|
||
// KMS is the request-scoped cached client used to hydrate the org's payment
|
||
// credentials. Nil is legitimate and non-fatal (dev/tests, env-var creds).
|
||
KMS *kms.CachedClient
|
||
// Events is the analytics collector the money alarm fires on when a settled
|
||
// charge leaves no subscription behind. Nil is a no-op.
|
||
Events *events.Client
|
||
}
|
||
|
||
// Sale is the receipt for a card subscription: what was opened, what was
|
||
// charged, and what paid for it. The same fields the browser has always
|
||
// received, so the endpoint renders it directly and a typed caller reads the same
|
||
// answer.
|
||
type Sale struct {
|
||
// SubscriptionID is the subscription this sale opened.
|
||
SubscriptionID string `json:"subscriptionId"`
|
||
// InvoiceID is the PAID first-period invoice. Empty when the charge settled
|
||
// and the invoice record did not — the entitlement stands either way.
|
||
InvoiceID string `json:"invoiceId"`
|
||
// PlanID is the plan bought.
|
||
PlanID string `json:"planId"`
|
||
// Level is which of the plan's published prices it was bought at.
|
||
Level int `json:"level"`
|
||
// Interval is the period bought, and the period this subscription renews on:
|
||
// "month" for the recurring price, "year" when AmountCents is the whole year.
|
||
Interval types.Interval `json:"interval"`
|
||
// PaymentMethodID is the vaulted card that paid, and that renewals charge.
|
||
PaymentMethodID string `json:"paymentMethodId"`
|
||
// AmountCents is what the card was actually charged for the first period —
|
||
// price x seats, less any promotion.
|
||
AmountCents int64 `json:"amountCents"`
|
||
// Currency is the ISO code, the plan's own.
|
||
Currency string `json:"currency"`
|
||
// Status is "ok" on a settled sale. A failure is an error, never a status.
|
||
Status string `json:"status"`
|
||
}
|
||
|
||
// The kinds of no a sale can end in. They are separate because each is a
|
||
// different thing for the buyer to do — fix the request, name something that
|
||
// exists, wait for the sale already in flight, use another card, try a different
|
||
// card — and the endpoint turns each into the status it has always answered. The
|
||
// reason lives here; the number lives at the endpoint.
|
||
type saleKind int
|
||
|
||
const (
|
||
saleRefused saleKind = iota // the request asks for something that cannot be sold
|
||
saleMissing // it named a plan or a store that is not there
|
||
saleHeld // an identical sale is in flight, or this account already pays
|
||
saleUnchargeable // no card rail can take this org's money
|
||
saleDeclined // the card said no
|
||
)
|
||
|
||
// saleRefusal is a card subscription that did not happen, carrying which kind of
|
||
// no it was and the sentence the buyer should read.
|
||
type saleRefusal struct {
|
||
kind saleKind
|
||
msg string
|
||
}
|
||
|
||
func (e saleRefusal) Error() string { return e.msg }
|
||
|
||
// saleDecline is a declined sale that also carries the processor's classified
|
||
// refusal, so a caller can answer with the processor's code as well as the sentence.
|
||
// Both are in its chain: errors.As finds the saleRefusal for [IsSaleDeclined] and the
|
||
// processor.Decline for [DeclineOf].
|
||
type saleDecline struct {
|
||
saleRefusal
|
||
decline *processor.Decline
|
||
}
|
||
|
||
func (e saleDecline) Unwrap() []error { return []error{e.saleRefusal, e.decline} }
|
||
|
||
// declinedSale is the refusal of a sale whose card said no, or a sale held because
|
||
// its payment is still processing (an in-flight sale, answered as one).
|
||
func declinedSale(d *processor.Decline) error {
|
||
kind := saleDeclined
|
||
if d.Processing() {
|
||
kind = saleHeld
|
||
}
|
||
return saleDecline{saleRefusal{kind, d.Sentence()}, d}
|
||
}
|
||
|
||
func isSale(err error, k saleKind) bool {
|
||
var e saleRefusal
|
||
return errors.As(err, &e) && e.kind == k
|
||
}
|
||
|
||
// IsSaleRefused reports whether the sale cannot be made as asked: no card named
|
||
// or two, no plan, a level the plan does not publish, seats below its floor, a
|
||
// currency it is not priced in, or a price of nothing.
|
||
func IsSaleRefused(err error) bool { return isSale(err, saleRefused) }
|
||
|
||
// IsSaleNotFound reports whether the request named a plan or store that is not
|
||
// on sale here. A retired tier answers the same as one that never existed, so
|
||
// archiving a plan cannot be detected by trying to buy it.
|
||
func IsSaleNotFound(err error) bool { return isSale(err, saleMissing) }
|
||
|
||
// IsSaleConflict reports whether something already holds this sale: an identical
|
||
// attempt is in flight, or the account already pays for a plan. Neither is
|
||
// retryable — the answer is to wait, or to change the subscription that exists.
|
||
func IsSaleConflict(err error) bool { return isSale(err, saleHeld) }
|
||
|
||
// IsSaleUnchargeable reports whether this organization has no card-on-file rail
|
||
// able to take the money — a configuration state, not a decline.
|
||
func IsSaleUnchargeable(err error) bool { return isSale(err, saleUnchargeable) }
|
||
|
||
// IsSaleDeclined reports whether the processor refused the card. The message is
|
||
// already the customer-facing sentence parseCardDeclineReason produced, which is
|
||
// why the endpoint sends it verbatim rather than writing a second one.
|
||
func IsSaleDeclined(err error) bool { return isSale(err, saleDeclined) }
|
||
|
||
// saleOutcome is what the subscribe worker learned, carried by VALUE.
|
||
//
|
||
// It exists because a sale has two shapes of answer — a fresh one and a replay
|
||
// of a sealed one — and the endpoint needs to tell them apart to answer 201 or 200.
|
||
// It carries the ROWS as well as the receipt so the endpoint can fire its analytics
|
||
// off the models, exactly as it always has.
|
||
type saleOutcome struct {
|
||
// Sale is the receipt for a fresh sale. Nil when Replayed is set.
|
||
Sale *Sale
|
||
// Sub is the subscription that was opened.
|
||
Sub *subscription.Subscription
|
||
// Inv is the paid first-period invoice, or nil when recording it failed.
|
||
Inv *billinginvoice.BillingInvoice
|
||
// Replayed is the sealed body of an earlier identical sale, VERBATIM. Raw
|
||
// JSON rather than a map: it is the bytes the first answer was, so a retry
|
||
// gets that answer back byte for byte instead of one built again.
|
||
Replayed json.RawMessage `json:"replayed,omitempty"`
|
||
}
|
||
|
||
// Sold is what a sale answered with, and it keeps the two shapes APART.
|
||
//
|
||
// A sale has two: a fresh receipt, and the sealed body of an identical earlier
|
||
// one replayed verbatim. A caller has to tell them apart — the endpoint answers 201
|
||
// for the first and 200 for the second, and a retry that got 201 would read as a
|
||
// second subscription having been opened. Exactly one field is ever set.
|
||
type Sold struct {
|
||
// Sale is the receipt for a fresh sale. Nil on a replay.
|
||
Sale *Sale `json:"sale,omitempty"`
|
||
// Replayed is the sealed body of the earlier sale, VERBATIM — the bytes the
|
||
// first answer was, not a second rendering of them.
|
||
Replayed json.RawMessage `json:"replayed,omitempty"`
|
||
}
|
||
|
||
// SubscribeCard is Subscribe with the two answers kept apart, and with the
|
||
// sale's own analytics fired from the context rather than from a request.
|
||
//
|
||
// It exists because Subscribe flattens a replay into a receipt, which is right
|
||
// for a caller that only wants to know what was bought and wrong for an ENDPOINT,
|
||
// which owes the status and the events. A peer serving this address needs both,
|
||
// so both are answered here rather than re-derived on the other side.
|
||
func SubscribeCard(ctx context.Context, org *organization.Organization, in SubscribeIn) (*Sold, error) {
|
||
out, err := subscribe(ctx, org, in)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(out.Replayed) > 0 {
|
||
return &Sold{Replayed: out.Replayed}, nil
|
||
}
|
||
emitSale(ctx, in.Events, org.Name, out.Sub, out.Inv)
|
||
return &Sold{Sale: out.Sale}, nil
|
||
}
|
||
|
||
// Subscribe vaults or reuses a card, charges it for the plan's FIRST period at
|
||
// the SERVER-AUTHORITATIVE catalog price, and opens the subscription — one act,
|
||
// all of it server-side.
|
||
//
|
||
// The settled charge IS the mint authority here, which is why this path creates
|
||
// a paid-tier subscription without the C1-a gate CreateBillingSubscription
|
||
// applies: that gate exists to stop a paid tier being minted for FREE, and money
|
||
// that arrived is the thing it was asking for.
|
||
//
|
||
// It takes values rather than a request so the sale is reachable by a peer that
|
||
// holds no ledger. What it will NOT do is resolve who is buying: Subject arrives
|
||
// already bounded to the caller's own org, because a core that resolved identity
|
||
// from its own input would let any endpoint hand it a subject nobody proved.
|
||
func Subscribe(ctx context.Context, org *organization.Organization, in SubscribeIn) (*Sale, error) {
|
||
out, err := subscribe(ctx, org, in)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(out.Replayed) > 0 {
|
||
var prev Sale
|
||
if err := json.Unmarshal(out.Replayed, &prev); err != nil {
|
||
return nil, fmt.Errorf("could not read the completed sale: %w", err)
|
||
}
|
||
return &prev, nil
|
||
}
|
||
return out.Sale, nil
|
||
}
|
||
|
||
// subscribe is the worker: it returns the ROWS alongside the receipt, so the
|
||
// HTTP endpoint emits from the models and answers 201 or 200 as it always has, while
|
||
// the typed caller reads Sale. Two projections of one result, never two
|
||
// implementations.
|
||
//
|
||
// The order is the money order and is not incidental: everything that can refuse
|
||
// refuses BEFORE the card is touched, the idempotency guard is taken before the
|
||
// charge, the one-paid-subscription check sits AFTER the guard so a lost-response
|
||
// retry replays its receipt instead of being told it already pays, and the
|
||
// subscription is created only once the charge has settled.
|
||
func subscribe(ctx context.Context, org *organization.Organization, in SubscribeIn) (*saleOutcome, error) {
|
||
if org == nil {
|
||
return nil, errors.New("subscribe: no organization")
|
||
}
|
||
if in.Subject == "" {
|
||
return nil, errors.New("subscribe: no subject")
|
||
}
|
||
|
||
// Hydrate the org's payment credentials so the per-org Square processor used
|
||
// to vault + charge the card carries real credentials. Best-effort: a missing
|
||
// KMS client is dev/test with env-var creds, not a failure.
|
||
if in.KMS != nil {
|
||
if err := kms.Hydrate(in.KMS, org); err != nil {
|
||
log.Error("KMS hydration failed for org %q: %v", org.Name, err)
|
||
}
|
||
}
|
||
db := datastore.New(org.Namespaced(ctx))
|
||
|
||
sourceID := strings.TrimSpace(in.SourceID)
|
||
methodID := strings.TrimSpace(in.MethodID)
|
||
planID := strings.TrimSpace(in.PlanID)
|
||
isEco := IsEcosystemAccount(org, in.Subject)
|
||
if (sourceID == "") == (methodID == "") && !isEco && sourceID != "credits" && sourceID != "balance" {
|
||
return nil, saleRefusal{saleRefused, "send exactly one of sourceId (a new card) or paymentMethodId (a saved card)"}
|
||
}
|
||
if planID == "" {
|
||
return nil, saleRefusal{saleRefused, "planId is required"}
|
||
}
|
||
if in.StoreID != "" {
|
||
s := storemodel.New(db)
|
||
if err := s.GetById(in.StoreID); err != nil {
|
||
return nil, saleRefusal{saleMissing, "store not found"}
|
||
}
|
||
}
|
||
|
||
// Server-authoritative plan + price. A client amount is NEVER consulted — the
|
||
// charge is the plan's catalog price × billable seats, so a scripted request
|
||
// cannot underpay.
|
||
p, err := resolveSubscriptionPlan(db, planID)
|
||
if err != nil {
|
||
return nil, saleRefusal{saleMissing, "plan not found"}
|
||
}
|
||
if !p.Listed() {
|
||
// Refused BEFORE the card is charged — a retired tier must never take money.
|
||
return nil, saleRefusal{saleMissing, "plan not found"}
|
||
}
|
||
// The price this card is charged, chosen from what the catalog publishes. A
|
||
// level or a period the plan does not publish is refused here — before the card
|
||
// is touched, before the idempotency guard is taken, and without ever
|
||
// consulting an amount the client sent, because the client sends none.
|
||
p, err = planAt(p, in.Level, in.Interval)
|
||
if err != nil {
|
||
return nil, saleRefusal{saleRefused, err.Error()}
|
||
}
|
||
if int64(p.Price) <= 0 {
|
||
// A $0 plan needs no card — the free tier is self-serve via POST
|
||
// /v1/billing/subscriptions. This endpoint's contract is a PAID card sub.
|
||
return nil, saleRefusal{saleRefused,
|
||
fmt.Sprintf("plan %q is free — no card charge required; use POST /v1/billing/subscriptions", planID)}
|
||
}
|
||
// Billable seats: a per-seat plan charges Price × quantity (floored at the
|
||
// catalog's minSeats); a flat plan is always ×1. This MUST match
|
||
// buildPeriodInvoice's seat math so the first charge equals the invoice's
|
||
// AmountDue — never under-collect a per-seat plan yet record it paid in full.
|
||
qty := in.Quantity
|
||
if qty < 1 {
|
||
qty = 1
|
||
}
|
||
seatMult := int64(1)
|
||
if perSeat(planID) {
|
||
if min := minSeats(planID); qty < min {
|
||
return nil, saleRefusal{saleRefused,
|
||
fmt.Sprintf("plan %q requires at least %d seats (got %d)", planID, min, qty)}
|
||
}
|
||
seatMult = int64(qty)
|
||
}
|
||
chargeCents := int64(p.Price) * seatMult
|
||
// The advertised promo, actually applied. GET /v1/billing/plans annotates each
|
||
// plan with promoPercent off the SAME gate (active + inside its window +
|
||
// AppliesTo), and until now nothing subtracted it here: the page could say
|
||
// "50% off" while this line charged full price and the receipt agreed with the
|
||
// charge, not the offer. Resolved once, carried on the subscription, so every
|
||
// renewal prices identically — and computed through engine.DiscountCents, the
|
||
// same function that discounts the invoice, so charge == AmountDue by
|
||
// construction rather than by two implementations agreeing.
|
||
promoPercent, promoName := 0, ""
|
||
if pr := in.Promo; pr != nil && pr.AppliesTo(planID) {
|
||
promoPercent, promoName = pr.PercentOff, pr.Name()
|
||
chargeCents -= engine.DiscountCents(chargeCents, promoPercent)
|
||
}
|
||
if chargeCents <= 0 {
|
||
// A 100%-off promo leaves nothing to charge, and a card sale of $0 is not a
|
||
// sale — Square rejects a zero charge, so this would surface as a decline.
|
||
// Refuse BEFORE the card is touched and name the real reason.
|
||
return nil, saleRefusal{saleRefused,
|
||
fmt.Sprintf("plan %q costs nothing after the current promotion — no card charge required; use POST /v1/billing/subscriptions", planID)}
|
||
}
|
||
// Currency is SERVER-AUTHORITATIVE: the plan's own currency (default USD). A
|
||
// client currency is honored ONLY when it matches the plan's; a mismatched
|
||
// currency (e.g. "jpy" for a USD-priced plan) is rejected — otherwise the
|
||
// price's minor units would be charged in a weaker unit (2000 JPY ~ $13 for a
|
||
// $20 plan), an underpayment.
|
||
cur := p.Currency
|
||
if cur == "" {
|
||
cur = currency.USD
|
||
}
|
||
if reqCur := currency.Type(strings.ToLower(strings.TrimSpace(in.Currency))); reqCur != "" && reqCur != cur {
|
||
return nil, saleRefusal{saleRefused,
|
||
fmt.Sprintf("plan priced in %s; currency %s not accepted", cur, reqCur)}
|
||
}
|
||
|
||
// Idempotency (money-critical). The client SHOULD send a STABLE X-Idempotency-Key
|
||
// per checkout attempt (the SPA does), so a lost-response retry — even with a
|
||
// FRESH single-use nonce — replays instead of vaulting + charging again. Absent a
|
||
// key, fall back to the STABLE (store, plan, level): the single-use nonce is NOT a
|
||
// stable key (a re-tokenized retry mints a new nonce), which is exactly the
|
||
// double-charge to avoid. Scoped to the subject so keys never collide across tenants.
|
||
//
|
||
// The LEVEL is part of those facts, for the same reason the seat count is: a
|
||
// different level is a different purchase. Keyed on the plan alone, a customer
|
||
// who came back and moved Max from $99 to $299 inside the window would reuse
|
||
// the first attempt's key, and the server would REPLAY the $99 charge and hand
|
||
// back its receipt — selling the wrong thing quietly. Scoping by level keeps
|
||
// the retry guard doing its job without letting it mask a genuine change.
|
||
//
|
||
// The PERIOD is one of those facts too, and the same size of one: a buyer who
|
||
// switched Dev from monthly to annual inside the window would replay the $19
|
||
// charge and be handed a receipt for a year. It is the RESOLVED period that
|
||
// scopes the key, not the field as sent, because an absent interval and "month"
|
||
// are the same purchase and must share a key rather than charge twice.
|
||
guard := in.IdempotencyKey
|
||
if guard == "" {
|
||
guard = windowKey("store:" + in.StoreID + ":plan:" + planID +
|
||
":level:" + strconv.Itoa(in.Level) + ":interval:" + string(p.Interval))
|
||
}
|
||
// The Square idempotency key is derived from the SAME stable guard key (never the
|
||
// single-use nonce), so Square itself de-dups the money move even if the local
|
||
// guard store is unavailable OR two submits race — the definitive backstop.
|
||
base := gatewayKey("subscribe", in.Subject, guard)
|
||
|
||
rec, replay, gerr := idempotencykey.Begin(db, "billing-subscribe:"+in.Subject+":"+in.StoreID, guard)
|
||
if gerr != nil {
|
||
// Guard store unavailable. Proceed WITHOUT the local guard: the stable Square
|
||
// idempotency key above still makes the CHARGE exactly-once at the processor,
|
||
// so a retry can't double-charge (at worst a duplicate sub row, far less
|
||
// severe than a double charge) rather than block a legitimate first subscribe.
|
||
log.Error("subscribe idempotency Begin failed (subject=%s): %v", in.Subject, gerr)
|
||
rec = nil
|
||
} else if replay {
|
||
if rec.Status == idempotencykey.StatusCompleted && rec.Response != "" {
|
||
return &saleOutcome{Replayed: json.RawMessage(rec.Response)}, nil
|
||
}
|
||
return nil, saleRefusal{saleHeld, "subscription already in progress"}
|
||
}
|
||
// abandon releases the guard on pre-charge failures where no money moved, so a
|
||
// later attempt is not wedged.
|
||
abandon := func() {
|
||
if rec != nil {
|
||
_ = rec.Delete()
|
||
}
|
||
}
|
||
|
||
// ONE PAID SUBSCRIPTION PER SUBJECT — refused BEFORE the card is touched.
|
||
//
|
||
// This endpoint only ever STARTED a subscription: it charged, then called
|
||
// createSubscription unconditionally, and nothing looked for one the subject
|
||
// already had. The idempotency guard did not help — it keys on (subject, store,
|
||
// plan), so a DIFFERENT plan is a different key and replays nothing. A paying
|
||
// Pro customer clicking Upgrade on Max was therefore charged $99 in full, got a
|
||
// SECOND active subscription, and kept renewing the first one. billingSubscription
|
||
// explains why a second row also shadows the first's allotment.
|
||
//
|
||
// Refusing is the honest answer rather than the complete one: MOVING the
|
||
// subscription is what the customer asked for, and doing that correctly means
|
||
// pricing a mid-period change (what is owed today versus at renewal), which is a
|
||
// policy decision this act cannot invent. PATCH /v1/billing/subscriptions/:id is
|
||
// where a move belongs, and it currently refuses an allotment increase without
|
||
// mint credentials precisely because engine.ChangePlan swaps the plan for free.
|
||
// Until that endpoint can charge, a customer who wants a different tier is told so by
|
||
// name — which is strictly better than being billed twice for it.
|
||
//
|
||
// It sits AFTER the idempotency guard on purpose. A retry carrying the SAME key
|
||
// is a lost-response retry of the attempt that created the subscription, and it
|
||
// must replay that receipt — asking first would answer a conflict to the customer
|
||
// whose payment actually went through.
|
||
if held := billingSubscription(db, in.Subject, org.TestMode()); held != nil {
|
||
abandon()
|
||
heldSlug := held.Plan.Slug
|
||
if heldSlug == "" {
|
||
heldSlug = held.PlanId
|
||
}
|
||
return nil, saleRefusal{saleHeld, fmt.Sprintf(
|
||
"this account already pays for the %q plan (subscription %s); change that subscription instead of buying a second one",
|
||
heldSlug, held.Id())}
|
||
}
|
||
|
||
payWithCredits := sourceID == "credits" || sourceID == "balance" || (sourceID == "" && methodID == "" && isEco)
|
||
if payWithCredits {
|
||
// The first period is paid from prepaid money, all or nothing, before the
|
||
// row exists. A partner ecosystem org holds an operating allowance rather
|
||
// than a balance, so for one a short draw opens the plan undrawn.
|
||
drawn, err := prepaidFor(ctx, org).Draw(ctx, in.Subject, cur, chargeCents, "subscribe:"+in.Subject+":"+guard)
|
||
switch {
|
||
case err == nil:
|
||
case isEco:
|
||
drawn = engine.Drawn{} // the draw moved nothing; the allowance opens the plan
|
||
case errors.Is(err, errShort):
|
||
abandon()
|
||
return nil, saleRefusal{saleDeclined, fmt.Sprintf("insufficient credits/balance to subscribe to %s: %v", p.Name, err)}
|
||
default:
|
||
abandon()
|
||
return nil, err
|
||
}
|
||
|
||
sub, inv, err := openPaid(db, p, &createSubscriptionRequest{
|
||
UserId: in.Subject,
|
||
PlanId: planID,
|
||
StoreId: in.StoreID,
|
||
DefaultPaymentMethod: "credits",
|
||
Quantity: qty,
|
||
Metadata: map[string]interface{}{"source": "subscribe/credits"},
|
||
Test: org.TestMode(),
|
||
}, promoPercent, promoName, drawn)
|
||
if err != nil {
|
||
// The money moved and no subscription holds it. Leave the guard
|
||
// STARTED so a retry replays rather than drawing again.
|
||
uncredited(ctx, in.Events, org.Name, in.Subject, drawn.Ref,
|
||
"the first period was drawn from prepaid money and no subscription was opened: "+err.Error(),
|
||
chargeCents, false)
|
||
return nil, err
|
||
}
|
||
|
||
invoiceID := ""
|
||
if inv != nil {
|
||
invoiceID = inv.Id()
|
||
}
|
||
sale := &Sale{
|
||
SubscriptionID: sub.Id(),
|
||
InvoiceID: invoiceID,
|
||
PlanID: planID,
|
||
Level: in.Level,
|
||
Interval: p.Interval,
|
||
PaymentMethodID: "credits",
|
||
AmountCents: chargeCents,
|
||
Currency: string(cur),
|
||
Status: "ok",
|
||
}
|
||
if rec != nil {
|
||
if body, mErr := json.Marshal(sale); mErr == nil {
|
||
_ = idempotencykey.Complete(rec, string(body))
|
||
}
|
||
}
|
||
return &saleOutcome{Sale: sale, Sub: sub, Inv: inv}, nil
|
||
}
|
||
|
||
// Resolve the card to charge. A fresh nonce is vaulted through saveCard (the
|
||
// ONE constructor — validates by vaulting, stamps brand/last4, and returns
|
||
// the existing row instead of stacking a duplicate); a paymentMethodId names
|
||
// a card the subject already saved, charged directly with nothing re-entered.
|
||
reg := processorsForOrg(org)
|
||
cp, ok := squareCustomerProcessorFrom(reg)
|
||
if !ok {
|
||
abandon()
|
||
return nil, saleRefusal{saleUnchargeable, "no card-on-file payment processor available for this organization"}
|
||
}
|
||
release, err := attempt(db, org.Name, in.Subject)
|
||
if err != nil {
|
||
abandon()
|
||
return nil, err
|
||
}
|
||
defer release()
|
||
var pm *paymentmethod.PaymentMethod
|
||
fresh := false // whether THIS request vaulted a new card (decline cleanup)
|
||
if methodID != "" {
|
||
pm = paymentmethod.New(db)
|
||
if err := pm.GetById(methodID); err != nil {
|
||
abandon()
|
||
return nil, fmt.Errorf("%w: %v", errNoMethod, err)
|
||
}
|
||
// The subject is the pinned caller; another subject's method inside the org
|
||
// is unreachable — the same answer as one that does not exist, so nothing
|
||
// here is an existence oracle.
|
||
if pm.CustomerId != in.Subject && pm.UserId != in.Subject {
|
||
abandon()
|
||
return nil, errNoMethod
|
||
}
|
||
if strings.TrimSpace(pm.ProviderRef) == "" || squareCustomerIDOf(pm) == "" {
|
||
abandon()
|
||
return nil, errMethodUnchargeable
|
||
}
|
||
} else {
|
||
var err error
|
||
pm, fresh, err = saveCard(ctx, db, cp, in.Subject, strings.TrimSpace(in.Email), sourceID)
|
||
if err != nil {
|
||
abandon()
|
||
// Vaulting validates the card, so a refusal here is the card's.
|
||
if d := refusalOf(nil, err); d != nil {
|
||
refused(db, "plan sale", in.Subject, "", d)
|
||
return nil, declinedSale(d)
|
||
}
|
||
return nil, failedAttempt(db, "plan sale", in.Subject, err)
|
||
}
|
||
}
|
||
|
||
// Charge the SAVED card (card-on-file id + Square customer id) for the first
|
||
// period at the server-authoritative price × seats, with the stable Square
|
||
// idempotency key. All-or-nothing: on a decline no subscription is created.
|
||
res, err := chargeSavedCard(ctx, reg, pm.ProviderRef, squareCustomerIDOf(pm), chargeCents, cur, attemptKey(db, base),
|
||
fmt.Sprintf("Subscription %s — first period", p.Name))
|
||
if err != nil || res == nil || !res.Success {
|
||
// A card vaulted BY THIS REQUEST is removed again so a declined attempt
|
||
// leaves no orphan — but a card the customer already had on file (saved
|
||
// earlier, or the dedupe match) is theirs and stays.
|
||
if fresh {
|
||
_ = cp.RemovePaymentMethod(ctx, squareCustomerIDOf(pm), pm.ProviderRef)
|
||
_ = pm.Delete()
|
||
}
|
||
abandon()
|
||
if d := refusalOf(res, err); d != nil {
|
||
refused(db, "plan sale", in.Subject, base, d)
|
||
return nil, declinedSale(d)
|
||
}
|
||
return nil, failedAttempt(db, "plan sale", in.Subject, err)
|
||
}
|
||
|
||
// The charged card is the subscription's default from here on.
|
||
if !pm.IsDefault {
|
||
pm.IsDefault = true
|
||
if err := pm.Update(); err != nil {
|
||
log.Error("subscribe: failed to mark method %s default (subject=%s): %v", pm.Id(), in.Subject, err)
|
||
}
|
||
}
|
||
|
||
// Create the subscription, authorized because the payment settled (mint
|
||
// authority = the settled charge — the CreateBillingSubscription C1-a gate is
|
||
// deliberately not on this path). No trial: the customer is paying now, so the
|
||
// first period is Active immediately.
|
||
p.TrialPeriodDays = 0
|
||
sub, err := createSubscription(db, p, &createSubscriptionRequest{
|
||
UserId: in.Subject,
|
||
PlanId: planID,
|
||
StoreId: in.StoreID,
|
||
DefaultPaymentMethod: pm.Id(),
|
||
Quantity: qty,
|
||
Metadata: map[string]interface{}{"source": "subscribe/card"},
|
||
Test: org.TestMode(),
|
||
})
|
||
if err != nil {
|
||
// Money moved + card saved; surface the failure. Leave the guard STARTED so a
|
||
// retry replays rather than re-charging.
|
||
uncredited(ctx, in.Events, org.Name, in.Subject, res.ProcessorRef,
|
||
"the first-period charge settled and no subscription was created: "+err.Error(),
|
||
chargeCents, false)
|
||
return nil, err
|
||
}
|
||
|
||
// Mark the FIRST period PAID by the card charge — a paid BillingInvoice
|
||
// referencing the processor ref — and keep the row on that period. This also makes
|
||
// the subscription payment-backed (Square provider + a linked invoice), so its
|
||
// INCLUDED monthly allotment flows (subscriptionPaymentBacked).
|
||
sub.ProviderType = string(processor.Square)
|
||
// Stamp the promo BEFORE the invoice is built — buildPeriodInvoice prices the
|
||
// discount off the subscription, so setting it after would invoice the first
|
||
// period at full price against a discounted charge.
|
||
sub.DiscountPercent, sub.DiscountName = promoPercent, promoName
|
||
inv, err := engine.CreatePaidFirstInvoice(db, sub, "card", res.ProcessorRef)
|
||
if err != nil {
|
||
// The subscription exists and the money is collected; a first-invoice record
|
||
// failure is non-fatal to the entitlement. Log for reconciliation.
|
||
log.Error("subscribe: failed to record paid first invoice (subject=%s, ref=%s): %v", in.Subject, res.ProcessorRef, err)
|
||
}
|
||
if err := sub.Update(); err != nil {
|
||
log.Error("subscribe: failed to update subscription after first invoice (subject=%s): %v", in.Subject, err)
|
||
}
|
||
|
||
invoiceID := ""
|
||
if inv != nil {
|
||
invoiceID = inv.Id()
|
||
}
|
||
sale := &Sale{
|
||
SubscriptionID: sub.Id(),
|
||
InvoiceID: invoiceID,
|
||
PlanID: planID,
|
||
Level: in.Level,
|
||
Interval: p.Interval,
|
||
PaymentMethodID: pm.Id(),
|
||
AmountCents: chargeCents,
|
||
Currency: string(cur),
|
||
Status: "ok",
|
||
}
|
||
// Seal the idempotency guard with the exact success body so a retry replays it
|
||
// verbatim (no second vault/charge/subscribe, identical response).
|
||
if rec != nil {
|
||
if body, mErr := json.Marshal(sale); mErr == nil {
|
||
_ = idempotencykey.Complete(rec, string(body))
|
||
}
|
||
}
|
||
return &saleOutcome{Sale: sale, Sub: sub, Inv: inv}, nil
|
||
}
|
||
|
||
// SubscribeWithCard vaults a Square card nonce as a reusable card-on-file, charges
|
||
// it for the plan's FIRST period at the SERVER-AUTHORITATIVE catalog price, and
|
||
// creates the subscription — all server-side, in one transaction of intent.
|
||
//
|
||
// POST /v1/billing/subscribe/card
|
||
//
|
||
// Body: { sourceId | paymentMethodId, planId, userId?, quantity?, level?, currency? }
|
||
// — NO client amount: the price is the plan's catalog price, always.
|
||
// Header (optional): X-Idempotency-Key — a retry/double-submit with the same key
|
||
// (or, absent a key, the same store/plan/level inside a window) never
|
||
// double-charges: it replays the first result verbatim.
|
||
// Returns: { subscriptionId, invoiceId, planId, level, paymentMethodId, amountCents, currency, status }
|
||
//
|
||
// Everything below the bind is reading values off the request and handing them to
|
||
// Subscribe: the buyer bounded to the caller's own org, the retry key from the
|
||
// header, the buyer's email and the KMS and analytics clients from the request
|
||
// locals, and the platform promo, which needs a request to read and so is
|
||
// resolved here rather than inside the act.
|
||
func SubscribeWithCard(c *zip.Ctx) error {
|
||
// The OK form, for the reason orgBillingKey states: GetOrganization
|
||
// MustGet-PANICS on a missing org, and this handler is reachable without one.
|
||
// IAMTokenRequired deliberately FALLS THROUGH without setting the local when
|
||
// the gateway named no principal (`ownerID == "" || userID == ""`), so legacy
|
||
// auth still gets its turn — every other billing read tolerates that; this
|
||
// endpoint dereferenced it and took the whole request down at line one.
|
||
//
|
||
// Measured in production 2026-08-06: the panic recovered as a bare 500 with no
|
||
// body, so the console could not say what went wrong, and `zero subscriptions
|
||
// exist platform-wide` was the visible symptom. It also starves the serving
|
||
// plane — ai's funding gate requires a confirmed paying subscriber for any
|
||
// prepaid SKU and fails closed, so with no subscription anywhere every prepaid
|
||
// model is refused for everyone.
|
||
org, ok := middleware.GetOrganizationOK(c)
|
||
if !ok || org == nil {
|
||
return http.Fail(c, 401, "sign in to subscribe", nil)
|
||
}
|
||
|
||
if !sold(c) {
|
||
return http.Fail(c, 404, "plan not found", nil)
|
||
}
|
||
|
||
var req subscribeCardRequest
|
||
if err := c.Bind(&req); err != nil {
|
||
return http.Fail(c, 400, "invalid request body", err)
|
||
}
|
||
|
||
// Billing subject: the org slug, or an in-org <org>/<user> ONLY when provably
|
||
// within the caller's own org (userId is honored only inside that bound).
|
||
subject := subscribeSubject(c, req.UserID)
|
||
if subject == "" {
|
||
return http.Fail(c, 401, "missing identity headers", nil)
|
||
}
|
||
// A member subscribes, and pays from, their own account whatever userId names.
|
||
// The org's own account, and another member's, are the org's to subscribe: its
|
||
// admins, a platform SuperAdmin or a service.
|
||
if !mayActForOrg(c) {
|
||
own := userBillingKey(c)
|
||
if own == "" || own == orgBillingKey(c) {
|
||
return http.Fail(c, 403, "only an admin of this organization may subscribe it", nil)
|
||
}
|
||
subject = own
|
||
}
|
||
|
||
iamEmail, _ := c.Locals("iam_email").(string)
|
||
out, err := subscribe(c.Context(), org, SubscribeIn{
|
||
SourceID: req.SourceID,
|
||
MethodID: req.MethodID,
|
||
PlanID: req.PlanID,
|
||
Subject: subject,
|
||
StoreID: req.StoreID,
|
||
Quantity: req.Quantity,
|
||
Level: req.Level,
|
||
Interval: req.Interval,
|
||
Currency: req.Currency,
|
||
Email: iamEmail,
|
||
IdempotencyKey: strings.TrimSpace(c.Header("X-Idempotency-Key")),
|
||
Promo: promo.Active(c),
|
||
KMS: kmsOf(c),
|
||
Events: eventsOf(c),
|
||
})
|
||
if err != nil {
|
||
switch {
|
||
case IsSaleRefused(err):
|
||
return http.Fail(c, 400, err.Error(), nil)
|
||
case IsSaleNotFound(err):
|
||
return http.Fail(c, 404, err.Error(), nil)
|
||
case IsSaleConflict(err):
|
||
return http.Fail(c, 409, err.Error(), nil)
|
||
case IsSaleUnchargeable(err):
|
||
return http.Fail(c, 422, err.Error(), nil)
|
||
case IsSaleDeclined(err):
|
||
return http.Fail(c, 402, err.Error(), nil)
|
||
case IsDeclineCeiling(err):
|
||
return http.Fail(c, 429, ceilingSentence, nil)
|
||
case IsAttemptInFlight(err):
|
||
return http.Fail(c, 409, attemptSentence, nil)
|
||
case IsProcessorFailed(err):
|
||
return http.Fail(c, 502, processorSentence, nil)
|
||
case IsMethodNotFound(err):
|
||
return http.Fail(c, 404, "payment method not found", err)
|
||
case IsMethodUnchargeable(err):
|
||
return http.Fail(c, 422, "saved payment method has no chargeable card — add the card again", nil)
|
||
}
|
||
// What is left is the subscription create itself failing after the money
|
||
// settled: a seat refusal is the caller's 400, anything else a 500.
|
||
return subscriptionCreateError(c, err)
|
||
}
|
||
|
||
if len(out.Replayed) > 0 {
|
||
// The sealed body IS the answer, verbatim — the same bytes the first
|
||
// attempt sent, not a second rendering of them.
|
||
c.SetHeader("Content-Type", "application/json")
|
||
return c.Bytes(200, out.Replayed)
|
||
}
|
||
|
||
emitSubscriptionCreated(c, org.Name, out.Sub)
|
||
if out.Inv != nil {
|
||
emitInvoicePaid(c, org.Name, out.Inv)
|
||
}
|
||
return c.JSON(201, out.Sale)
|
||
}
|
||
|
||
// chargeSavedCard charges an already-vaulted card-on-file (cardID owned by the
|
||
// Square customerID) for amountCents via the org's processor registry. It is the
|
||
// ONE saved-card charge path — shared by SubscribeWithCard's first charge and the
|
||
// renewal ProviderCharger. reg MUST be a per-org registry (processorsForOrg(org))
|
||
// whose org has hydrated Square credentials.
|
||
func chargeSavedCard(ctx context.Context, reg *processor.Registry, cardID, customerID string, amountCents int64, cur currency.Type, idempotencyKey, desc string) (*processor.PaymentResult, error) {
|
||
req := processor.PaymentRequest{
|
||
Token: cardID, // Square card-on-file id as the SourceID
|
||
CustomerID: customerID, // the Square customer that owns the card (required for card-on-file)
|
||
Amount: currency.Cents(amountCents),
|
||
Currency: cur,
|
||
IdempotencyKey: idempotencyKey, // Square de-dups the money move on this key
|
||
Description: desc,
|
||
}
|
||
proc, err := reg.SelectProcessor(ctx, req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("no payment processor for saved-card charge: %w", err)
|
||
}
|
||
return proc.Charge(ctx, req)
|
||
}
|
||
|
||
// chargeProviderForOrg returns an engine.ProviderCharger bound to org: given an
|
||
// open invoice, it resolves the invoice's subscription → DefaultPaymentMethod →
|
||
// the vaulted card (paymentmethod.ProviderRef = card-on-file id, Metadata
|
||
// squareCustomerId) and charges the remaining amount on it via the org's Square
|
||
// processor, returning the processor ref. org MUST already be KMS-hydrated by the
|
||
// caller (as TopupWithToken / auto-recharge do) so processorsForOrg sees real
|
||
// credentials. A resolution/charge failure returns an error → CollectInvoice
|
||
// leaves the invoice OPEN (no double-charge on retry).
|
||
func chargeProviderForOrg(org *organization.Organization) engine.ProviderCharger {
|
||
return func(ctx context.Context, db *datastore.Datastore, inv *billinginvoice.BillingInvoice, amountCents int64) (string, error) {
|
||
if amountCents <= 0 {
|
||
return "", fmt.Errorf("nothing to charge")
|
||
}
|
||
if inv.SubscriptionId == "" {
|
||
return "", fmt.Errorf("invoice %s has no subscription; no card on file to charge", inv.Id())
|
||
}
|
||
cardID, customerID, err := renewalCard(db, inv)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
cur := inv.Currency
|
||
if cur == "" {
|
||
cur = currency.USD
|
||
}
|
||
// Square idempotency key scoped to (subscription, period, attempt). The PERIOD
|
||
// (not the invoice id) is the shared axis: concurrent renewals each build their
|
||
// OWN invoice row for the same period, so an invoice-id key would NOT de-dup them
|
||
// — the period does. AttemptCount makes each dunning RETRY a FRESH key so the
|
||
// processor lets the re-collection through (a period-only key would make the
|
||
// gateway replay the first decline forever and wedge dunning).
|
||
squareKey := "collect:sub:" + inv.SubscriptionId +
|
||
":period:" + strconv.FormatInt(inv.PeriodStart.Unix(), 10) +
|
||
":attempt:" + strconv.Itoa(inv.AttemptCount)
|
||
reg := processorsForOrg(org)
|
||
res, err := chargeSavedCard(ctx, reg, cardID, customerID, amountCents, cur, squareKey,
|
||
fmt.Sprintf("Subscription renewal invoice %s", inv.NumberStr))
|
||
if err != nil || res == nil || !res.Success {
|
||
// A refusal keeps the processor's Decline beneath the sentence dunning
|
||
// records; anything else is the processor failing to answer or rejecting
|
||
// the request. The key already rotates per attempt (AttemptCount), and a
|
||
// renewal is off-session, so it is neither counted against the wallet nor
|
||
// held by its ceiling.
|
||
var failure error
|
||
if d := refusalOf(res, err); d != nil {
|
||
answered(db, "renewal "+inv.Id(), inv.UserId, "", d)
|
||
failure = declinedCard{d}
|
||
} else {
|
||
failure = processorFailure("renewal", inv.UserId, err)
|
||
}
|
||
// Until Square states an outcome, the card this key was sent with stays on
|
||
// the invoice and the next attempt resends it. A refusal, a payment that
|
||
// failed and a rejected request (a card since deleted: no payment exists
|
||
// under the key) are outcomes, and the next attempt charges the
|
||
// subscription's card as it is then.
|
||
if errors.Is(failure, processor.ErrUnknownOutcome) {
|
||
inv.UnresolvedCard, inv.UnresolvedCustomer = cardID, customerID
|
||
} else {
|
||
inv.UnresolvedCard, inv.UnresolvedCustomer = "", ""
|
||
}
|
||
return "", failure
|
||
}
|
||
inv.UnresolvedCard, inv.UnresolvedCustomer = "", ""
|
||
return res.ProcessorRef, nil
|
||
}
|
||
}
|
||
|
||
// renewalCard is the card a renewal of inv charges: the one its unresolved attempt
|
||
// was sent with, so the retry under the same key is the same request, else the
|
||
// subscription's default payment method.
|
||
func renewalCard(db *datastore.Datastore, inv *billinginvoice.BillingInvoice) (card, customer string, err error) {
|
||
if inv.UnresolvedCard != "" {
|
||
return inv.UnresolvedCard, inv.UnresolvedCustomer, nil
|
||
}
|
||
sub := subscription.New(db)
|
||
if err := sub.GetById(inv.SubscriptionId); err != nil {
|
||
return "", "", fmt.Errorf("subscription %s not found: %w", inv.SubscriptionId, err)
|
||
}
|
||
if strings.TrimSpace(sub.DefaultPaymentMethod) == "" {
|
||
return "", "", fmt.Errorf("subscription %s has no default payment method", sub.Id())
|
||
}
|
||
pm := paymentmethod.New(db)
|
||
if err := pm.GetById(sub.DefaultPaymentMethod); err != nil {
|
||
return "", "", fmt.Errorf("payment method %s not found: %w", sub.DefaultPaymentMethod, err)
|
||
}
|
||
card = strings.TrimSpace(pm.ProviderRef)
|
||
if card == "" {
|
||
return "", "", fmt.Errorf("payment method %s has no card-on-file token", pm.Id())
|
||
}
|
||
return card, squareCustomerIDOf(pm), nil
|
||
}
|
||
|
||
// squareCustomerIDOf reads the Square customer id stored on a vaulted payment
|
||
// method (the CreatePaymentMethod / subscribe-card convention:
|
||
// Metadata["squareCustomerId"]). A card-on-file is only chargeable in its Square
|
||
// customer's context, so this is required to charge a saved card.
|
||
func squareCustomerIDOf(pm *paymentmethod.PaymentMethod) string {
|
||
if pm.Metadata != nil {
|
||
if v, ok := pm.Metadata["squareCustomerId"].(string); ok {
|
||
return strings.TrimSpace(v)
|
||
}
|
||
}
|
||
return ""
|
||
}
|