Files
commerce/api/billing/subscribe_card.go
zooqueenandHanzo Dev cb3a32adc1 feat(billing): tell the operator about every paid plan sale
Every sale through subscribe/card, card or prepaid, coupon or not, sends a
plain-text notice to the addresses in BILLING_SALE_NOTIFY_TO: org, customer
email, plan, interval, amount charged, list price, promo and coupon
("50OFF — 50% off first month"), time, subscription, invoice and mode. It
is sent after the sale is sealed, on its own goroutine with a 30s bound,
never for a replay, and a failure is logged, never the sale's.

Mail goes through the host: EmbedConfig.Mail installs a mail.Sender (the
cloud binary passes its notify rail, credentials in KMS). Unset, nothing is
sent and mail.ErrNoSender says why.


Co-authored-by: Hanzo Dev <[email protected]>
2026-10-09 12:18:05 -07:00

1081 lines
49 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package billing
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"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"
"github.com/hanzoai/commerce/models/paymentorg"
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"`
// Coupon is a plan coupon CODE (case-insensitive) for a percent off the first
// month — a code, never an amount: the percent is the stored coupon's.
Coupon string `json:"coupon,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, and named
// in the operator's sale notice.
Email string
// Coupon is a plan coupon code the buyer typed. Empty buys at the price the
// catalog and promo set. A code that does not stand, does not cover the plan,
// or was redeemed by this org or card REFUSES the sale before the card is
// charged — it never silently sells at full price.
Coupon 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 and coupon.
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"`
// Coupon and CouponPercent name the coupon the first period was bought with,
// absent when none was. Renewals bill without it.
Coupon string `json:"coupon,omitempty"`
CouponPercent int `json:"couponPercent,omitempty"`
}
// 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 }
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)
if (sourceID == "") == (methodID == "") && 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.Discounted, 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.Discounted(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)}
}
// A coupon's request shape is checked here, before anything is held. Whether
// the coupon stands, covers this plan and is still this org's to redeem is
// state, and is asked after the idempotency guard, for the reason the
// one-paid-subscription check is: a lost-response retry of a sale that used
// the last redemption must replay its receipt, not be told the coupon is
// exhausted.
couponCode := ""
if raw := strings.TrimSpace(in.Coupon); raw != "" {
couponCode = promo.CouponCode(raw)
switch {
case couponCode == "":
return nil, saleRefusal{saleRefused, "coupon is not valid"}
case sourceID == "credits" || sourceID == "balance":
// One use per card is half the rule, and prepaid money has no card.
return nil, saleRefusal{saleRefused, "a coupon is redeemed with a card: send sourceId or paymentMethodId"}
case p.Interval == types.Yearly:
return nil, saleRefusal{saleRefused,
fmt.Sprintf("coupon %s takes a percent off the first month, so it applies to monthly billing only", couponCode)}
}
}
// 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.
//
// The COUPON is one of those facts when one is sent: a buyer who retried with a
// different code, or with none, is making a different purchase. It is absent
// from the facts when no coupon is sent, so a coupon-less sale keys exactly as
// it always has.
guard := in.IdempotencyKey
if guard == "" {
facts := "store:" + in.StoreID + ":plan:" + planID +
":level:" + strconv.Itoa(in.Level) + ":interval:" + string(p.Interval)
if couponCode != "" {
facts += ":coupon:" + couponCode
}
guard = windowKey(facts)
}
// 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.
squareKey := 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())}
}
// The coupon, resolved now that the guard is held: it must stand, cover this
// plan, and not have been redeemed by this org. Its percent comes off what
// the promo left, through the function the invoice prices with, so the charge
// equals the first invoice's AmountDue. It discounts this first charge only:
// the subscription never carries it, so every renewal bills the catalog price.
cpn := engine.Coupon{}
if couponCode != "" {
slug := p.Slug
if slug == "" {
slug = planID
}
offer, reason := promo.FindCoupon(ctx, couponCode, time.Now())
if reason == "" && !offer.AppliesTo(slug) {
reason = promo.CouponNotApplicable
}
if reason != "" {
abandon()
return nil, saleRefusal{saleRefused, couponRefusal(couponCode, slug, reason)}
}
if promo.Redeemed(ctx, couponCode, org.Name) {
abandon()
return nil, saleRefusal{saleHeld, fmt.Sprintf("%v (%s)", promo.ErrCouponRedeemedByOrg, couponCode)}
}
cpn = engine.Coupon{Code: offer.Code, Percent: offer.Percent}
chargeCents = engine.Discounted(chargeCents, cpn.Percent)
if chargeCents <= 0 {
abandon()
return nil, saleRefusal{saleRefused,
fmt.Sprintf("coupon %s leaves nothing to charge for plan %q, and a card sale must charge something", couponCode, planID)}
}
}
listCents := int64(p.Price) * seatMult
if sourceID == "credits" || sourceID == "balance" {
// The first period is paid from prepaid money, all or nothing, before the
// row exists. Every subject pays this way or by card, our own orgs included.
drawn, err := prepaidFor(ctx, org).Draw(ctx, in.Subject, cur, chargeCents, "subscribe:"+in.Subject+":"+guard)
switch {
case err == nil:
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))
}
}
noticeSale(ctx, saleNote{
Org: org.Name, Email: in.Email, Plan: p, Seats: seatMult, Sale: sale,
ListCents: listCents, PromoName: promoName, Test: org.TestMode(),
})
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"}
}
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()
return nil, saleRefusal{saleDeclined, parseCardDeclineReason(&processor.PaymentResult{ErrorMessage: err.Error()}, err)}
}
}
// The coupon's use is RESERVED before the card is charged, under the coupon's
// lock: the redemption rows for this org and this card are written and the use
// counted against the limit, so two buyers racing for the last use cannot both
// be charged for it. A card that already redeemed the code is refused here,
// once the card is known and before any money moves. A decline gives the use
// back; once the charge settles it is spent, whatever happens after.
var hold *promo.Hold
if cpn.Code != "" {
h, herr := promo.Reserve(ctx, cpn.Code, org.Name, instrumentOf(pm), time.Now())
if herr != nil {
if fresh {
_ = cp.RemovePaymentMethod(ctx, squareCustomerIDOf(pm), pm.ProviderRef)
_ = pm.Delete()
}
abandon()
return nil, holdRefusal(cpn.Code, herr)
}
hold = h
}
// 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, squareKey,
fmt.Sprintf("Subscription %s — first period", p.Name))
if err != nil || res == nil || !res.Success {
hold.Release()
// 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()
log.Error("saved-card charge failed for subscribe (subject=%s): %v", in.Subject, err)
return nil, saleRefusal{saleDeclined, parseCardDeclineReason(res, 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
meta := map[string]interface{}{"source": "subscribe/card"}
if cpn.Code != "" {
meta["coupon"] = cpn.Code
}
sub, err := createSubscription(db, p, &createSubscriptionRequest{
UserId: in.Subject,
PlanId: planID,
StoreId: in.StoreID,
DefaultPaymentMethod: pm.Id(),
Quantity: qty,
Metadata: meta,
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. The coupon is NOT stamped:
// it is handed to this one invoice, so renewals bill without it.
sub.DiscountPercent, sub.DiscountName = promoPercent, promoName
inv, err := engine.CreatePaidFirstInvoice(db, sub, "card", res.ProcessorRef, cpn)
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)
} else if err := paymentorg.Put(res.ProcessorRef, org.Name, in.Subject, inv.Id(), org.TestMode()); err != nil {
log.Warn("subscribe: payment %s: the index to its invoice was not written, so its refund or dispute will not find the period it paid: %v", 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",
Coupon: cpn.Code,
CouponPercent: cpn.Percent,
}
// 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))
}
}
noticeSale(ctx, saleNote{
Org: org.Name, Email: in.Email, Plan: p, Seats: seatMult, Sale: sale,
ListCents: listCents, PromoName: promoName, Coupon: cpn, Test: org.TestMode(),
})
return &saleOutcome{Sale: sale, Sub: sub, Inv: inv}, nil
}
// couponRefusal is the sentence a buyer reads when the coupon they sent cannot
// be used for this plan, by the reason the plans quote names.
func couponRefusal(code, slug, reason string) string {
switch reason {
case promo.CouponExpired:
return fmt.Sprintf("coupon %s has expired", code)
case promo.CouponExhausted:
return fmt.Sprintf("coupon %s has been fully redeemed", code)
case promo.CouponNotApplicable:
return fmt.Sprintf("coupon %s does not apply to plan %q", code, slug)
}
return fmt.Sprintf("coupon %s is not valid", code)
}
// holdRefusal is a coupon reservation's refusal as a sale refusal: the coupon
// no longer standing is the request's 400, an org or card that already redeemed
// it is a 409, and a store that could not record the use is the sale failing.
func holdRefusal(code string, err error) error {
var cr promo.CouponRefusal
switch {
case errors.As(err, &cr):
return saleRefusal{saleRefused, couponRefusal(code, "", cr.Reason)}
case errors.Is(err, promo.ErrCouponRedeemedByOrg), errors.Is(err, promo.ErrCouponRedeemedByCard):
return saleRefusal{saleHeld, fmt.Sprintf("%v (%s)", err, code)}
}
return fmt.Errorf("coupon %s: the use was not recorded, so the card was not charged: %w", code, err)
}
// instrumentOf is a saved card's identity across orgs and vaults, for the
// one-use-per-card rule: the processor's fingerprint of the card number, else
// its brand, last four and expiry, else the vaulted card itself.
func instrumentOf(pm *paymentmethod.PaymentMethod) string {
if fp := metaStr(pm, "fingerprint"); fp != "" {
return "fp:" + fp
}
if c := pm.Card; c != nil && c.Brand != "" && c.Last4 != "" {
return fmt.Sprintf("card:%s:%s:%d/%d", strings.ToLower(c.Brand), c.Last4, c.ExpMonth, c.ExpYear)
}
return "ref:" + strings.TrimSpace(pm.ProviderRef)
}
// 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?, interval?, currency?, coupon? }
// — NO client amount: the price is the plan's catalog price, always. coupon is a
// plan coupon code for a percent off the first month only.
// 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, interval, paymentMethodId, amountCents, currency, status, coupon?, couponPercent? }
//
// 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,
Coupon: req.Coupon,
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 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())
}
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)
}
cardID := strings.TrimSpace(pm.ProviderRef)
if cardID == "" {
return "", fmt.Errorf("payment method %s has no card-on-file token", pm.Id())
}
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, squareCustomerIDOf(pm), amountCents, cur, squareKey,
fmt.Sprintf("Subscription renewal invoice %s", inv.NumberStr))
if err != nil || res == nil || !res.Success {
return "", fmt.Errorf("%s", parseCardDeclineReason(res, err))
}
if err := paymentorg.Put(res.ProcessorRef, org.Name, inv.UserId, inv.Id(), org.TestMode()); err != nil {
log.Warn("renewal: payment %s: the index to invoice %s was not written, so its refund or dispute will not find the period it paid: %v", res.ProcessorRef, inv.Id(), err)
}
return res.ProcessorRef, 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 ""
}