Files
commerce/api/billing/subscriptions.go
zooqueenandHanzo Dev abab16618d feat(billing): plan coupon for a percent off the first month
POST /v1/billing/subscribe/card takes an optional coupon code. The coupon
is a models/coupon row (Filter "plan", Type percent) in the reserved
platform namespace "admin", beside the platform promo. It must be enabled,
inside [StartDate, EndDate], under Limit and cover the plan; an org and a
card each redeem a code once (models/couponredemption rows), and the use is
reserved under the code's lock before the card is charged and given back on
a decline. Any refusal is a 400 or 409 before the card is touched.

The percent comes off the first charge only, through engine.Discounted,
the same arithmetic the first invoice uses, so charge == AmountDue. The
first invoice names the coupon; the subscription never carries it, so
renewals bill the catalog price (less only a promo the row carries).

GET /v1/billing/plans?coupon=CODE quotes couponCode, couponPercent,
couponFirstCents and couponEnds on covered paid rows and couponError
(unknown, expired, exhausted, not_applicable) on the rest. Without the
parameter the response is unchanged.

SuperAdmin: GET/PUT /v1/platform/coupons/:code (RequirePlatformAdmin),
audited as coupon.created/coupon.updated billing events naming the actor.
middleware.Actor is the one actor-naming rule, moved from billing's minter.

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

1366 lines
54 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"
"errors"
"fmt"
"strings"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/billing/engine"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/billinginvoice"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/models/types/currency"
types "github.com/hanzoai/commerce/types"
"github.com/hanzoai/commerce/util/json/http"
)
type createSubscriptionRequest struct {
UserId string `json:"userId"`
PlanId string `json:"planId"`
StoreId string `json:"storeId,omitempty"`
DefaultPaymentMethod string `json:"defaultPaymentMethod"`
// Quantity is the billable seat count for per-seat plans (catalog
// price_ref.recurring.per_seat); defaults to 1 and must meet the catalog's
// limits.minSeats. Ignored as a multiplier on flat plans.
Quantity int `json:"quantity"`
// Level picks which of the plan's published prices to open at — an INDEX into
// the catalog's `prices`, never an amount. 0 (the default) is the plan's base
// price. See plan.LevelPrice for why the wire carries a choice and not a sum.
Level int `json:"level,omitempty"`
// Interval picks which of the plan's published billing periods to open at.
// Empty (the default) and "month" both open at the recurring price; "year"
// opens at the catalog's annual one. See planAtInterval.
Interval types.Interval `json:"interval,omitempty"`
// Members are the seat holders of a per-seat subscription. Each gets a
// zero-price bundle-child subscription row so the monthly allotment run
// grants their own per-user included credit. Never more than Quantity.
Members []string `json:"members"`
Metadata map[string]interface{} `json:"metadata"`
// Test is the org's mode at the sale, stamped on the row and its children. It
// is set by the endpoint from the org, never read off the wire.
Test bool `json:"-"`
// Collected opens the row as a plan paid OUTSIDE checkout, on the period that
// payment covers. Only RecordSubscription sets it, never a request body.
Collected *collected `json:"-"`
}
type updateSubscriptionRequest struct {
PlanId string `json:"planId"`
Prorate bool `json:"prorate"`
Quantity int `json:"quantity"`
Members []string `json:"members"` // see createSubscriptionRequest.Members
}
type cancelSubscriptionRequest struct {
AtPeriodEnd bool `json:"atPeriodEnd"`
}
// CreateBillingSubscription creates a new subscription and starts the billing lifecycle.
//
// POST /v1/billing/subscriptions
func CreateBillingSubscription(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
var req createSubscriptionRequest
if err := c.Bind(&req); err != nil {
return http.Fail(c, 400, "invalid request body", err)
}
req.Test = org.TestMode()
if req.UserId == "" {
return http.Fail(c, 400, "userId is required", nil)
}
if req.PlanId == "" {
return http.Fail(c, 400, "planId is required", nil)
}
if !sold(c) {
return http.Fail(c, 404, "plan not found", nil)
}
p, err := resolveSubscriptionPlan(db, req.PlanId)
if err != nil {
return http.Fail(c, 404, "plan not found", err)
}
if !p.Listed() {
// Retired/draft tier: it still resolves (renewals need it) but is not on
// sale — see plan.Listed. Same 404 as a slug that never existed, so the
// refusal tells a prober nothing about which slugs are real.
return http.Fail(c, 404, "plan not found", nil)
}
// The price this subscription opens at, chosen from what the catalog
// publishes. A level or a period the plan does not publish is refused outright.
p, err = planAt(p, req.Level, req.Interval)
if err != nil {
return http.Fail(c, 400, err.Error(), nil)
}
// C1-a: a PAID-tier subscription confers a spendable entitlement — its
// included monthly allotment is MINTED onto the user's gateway balance — yet
// this endpoint starts it Active instantly with ZERO payment
// (ProviderType="internal", no CollectInvoice). An org-level admin must NOT be
// able to self-mint a paid tier ("max" → $100/mo, recurring) by naming it
// here; only a proven mint principal (internal service token / platform global
// admin — e.g. cloud-api after a real payment) may create a paid-tier
// subscription. A FREE ($0) tier stays self-serve even when it carries a small
// included credit as a perk (developer's $5/mo) — price is the paid-tier gate,
// not the allotment. Pairs with the payment-backed clamp in subscriptionPlanSlug.
//
// The card path (POST /v1/billing/subscribe/card) bypasses THIS gate legitimately:
// it calls createSubscription directly AFTER a real card charge settles — the
// settled payment IS the mint authority (mirrors topup_token's
// mintauth.WithAuthorized rationale). So the gate lives HERE, in the
// Gate on the IMMUTABLE embed paidTier of the RESOLVED slug (p.Slug), NOT the
// editable DB p.Price and NOT the raw req.PlanId (Red F2 + R1). Plan pricing is
// now admin-editable (increment 3a) and the minted allotment AMOUNT
// (IncludedMonthlyCents) is embed-derived, so reading p.Price would let a
// SuperAdmin lowering an embed-paid plan's DB Price to 0 skip C1-a. And because
// resolveSubscriptionPlan→GetById also accepts a plan's DB HASHID, scoring the
// raw req.PlanId (a hashid → paidTier false) would skip the gate while p is the
// real paid plan — so score p.Slug, the canonical slug both id forms resolve to.
// paidTier reads the same embed authority as the allotment, so they never desync.
//
// A plan the catalog does not publish has no embed entry to score, so it is scored
// by its own row (paidPlan), which is what the tier reads from the snapshot. Left
// ungated, the row opens internal and the first renewal marks it invoiced: a
// contact-sales plan's invoice is $0, so that collection succeeds with no money
// and the row confers its tier.
if paidPlan(p.Slug, p) {
if !middleware.MayMintMoney(c) {
return http.Fail(c, 403,
"creating a paid-tier subscription requires platform-administrator or internal-service credentials", nil)
}
// No payment opens this row, so it is a comp, and it names who granted it.
if req.Metadata == nil {
req.Metadata = map[string]interface{}{}
}
req.Metadata["compedBy"] = middleware.Actor(c)
} else {
delete(req.Metadata, "compedBy") // client input never forges the audit field
}
qty := req.Quantity
if qty < 1 {
qty = 1
}
if perSeat(p.Slug) {
if min := minSeats(p.Slug); qty < min {
return http.Fail(c, 400, fmt.Sprintf("plan %q requires at least %d seats (got %d)", p.Slug, min, qty), nil)
}
}
sub, err := createSubscription(db, p, &req)
if err != nil {
return subscriptionCreateError(c, err)
}
emitSubscriptionCreated(c, org.Name, sub)
return c.JSON(201, subscriptionResponse(sub))
}
// subValidationError marks a client-side (HTTP 400) subscription failure — a
// seat-floor or members>seats violation on the way in, a lifecycle move the
// engine refuses once the row exists — as distinct from an internal (500)
// failure, so a core can refuse the caller's values without knowing what a
// status code is.
type subValidationError struct{ msg string }
func (e subValidationError) Error() string { return e.msg }
// errSubscriptionNotFound is the miss: no such subscription in the caller's org.
// The namespace IS the boundary, so a row belonging to another tenant is simply
// not found — there is no window in which the wrong row is in hand.
var errSubscriptionNotFound = errors.New("subscription: no such subscription for this org")
// IsSubscriptionNotFound reports whether err is that miss. A caller outside this
// package has to answer it the same way the endpoint does, and an unexported
// sentinel cannot be asked about.
func IsSubscriptionNotFound(err error) bool { return errors.Is(err, errSubscriptionNotFound) }
// IsSubscriptionRefused reports whether err is a refusal of what the CALLER
// asked for — seats below the plan's floor, members beyond the seats paid for,
// a lifecycle move the engine will not make — as distinct from the store
// failing. The asker can fix the first and can do nothing about the second,
// which is why they carry different statuses, and why a caller that could not
// tell them apart would report every typo as an outage.
func IsSubscriptionRefused(err error) bool {
var ve subValidationError
return errors.As(err, &ve)
}
// subscriptionCreateError maps a createSubscription error to the right HTTP status:
// a seat/members validation error is a 400, anything else (persist failure) a 500.
func subscriptionCreateError(c *zip.Ctx, err error) error {
if IsSubscriptionRefused(err) {
return http.Fail(c, 400, err.Error(), nil)
}
return http.Fail(c, 500, "failed to create subscription", err)
}
// resolveSubscriptionPlan resolves a plan by slug — the DB row when present, else
// the static catalog (seeded into the DB best-effort). The returned plan carries
// the SERVER-AUTHORITATIVE price; a client-supplied amount is never consulted. It
// is the single plan-resolution path shared by CreateBillingSubscription and the
// card subscribe endpoint.
func resolveSubscriptionPlan(db *datastore.Datastore, planId string) (*plan.Plan, error) {
// The plan authority is platform-global (models/plan, "system" ns) — the SAME
// rows admin.hanzo.ai edits and GET /v1/billing/plans serves — so an admin
// price edit flows straight into this charge. Read the authority, NOT the
// caller's per-org db. The embed is the fallback (seed did not run / a
// never-seeded slug), and the row is lazily materialized in the authority ns
// so its Id stays stable. The returned Price is the SERVER-AUTHORITATIVE
// amount; a client-supplied value is never consulted.
pdb := plan.AuthorityDB(db.Context)
p := plan.New(pdb)
if err := p.GetById(planId); err != nil {
var staticP *staticPlan
for i := range catalog {
if catalog[i].Slug == planId {
staticP = &catalog[i]
break
}
}
if staticP == nil {
return nil, err
}
p.Slug = staticP.Slug
p.Name = staticP.Name
p.Description = staticP.Description
p.Category = staticP.Category
p.Price = currency.Cents(staticP.Price)
p.PriceAnnual = currency.Cents(staticP.annual())
p.AnnualTotal = currency.Cents(staticP.AnnualTotal)
p.Prices = centsOf(staticP.Prices)
p.ContactSales = staticP.ContactSales
p.Popular = staticP.Popular
p.PerSeat = staticP.PerSeat
p.Currency = currency.Type(staticP.Currency)
p.Interval = types.Interval(staticP.Interval)
p.IntervalCount = staticP.IntervalCount
p.TrialPeriodDays = staticP.TrialPeriodDays
_ = p.Create() // best-effort; ignore dup-key errors
}
return p, nil
}
// planAtLevel returns p priced at the level the purchase chose — a COPY, always,
// priced at exactly one of the prices the catalog publishes for that plan. An
// unpublished level is refused here, before any card is touched.
//
// It copies rather than repricing p because p is the AUTHORITY row: the same row
// admin.hanzo.ai edits, GET /v1/billing/plans serves, and every other
// subscription on this plan resolves through. A level is one customer's choice,
// so it belongs to one subscription, and a level written onto the shared row
// would be one persist away from repricing the tier for everyone.
//
// Where the choice then lives is what makes it survive: StartSubscription
// snapshots the plan it is handed onto sub.Plan, the snapshot persists with the
// subscription, and buildPeriodInvoice invoices sub.Plan.Price — so a
// subscription opened at a level renews at that level for its whole life,
// without the renewal path knowing levels exist.
func planAtLevel(p *plan.Plan, level int) (*plan.Plan, error) {
price, err := p.LevelPrice(level)
if err != nil {
return nil, fmt.Errorf("plan %q is not sold at level %d: %w", p.Slug, level, err)
}
at := *p
at.Price = price
return &at, nil
}
// planAtInterval returns p billed over the period the purchase chose — a COPY,
// always, at a price the catalog publishes for that period. A period the plan is
// not sold over is refused here, before any card is touched.
//
// Price is collected in FULL once per period, and Advance makes a yearly
// plan's period a year, so a yearly subscription's Price is the whole year's
// money: AnnualTotal, the year the catalog states — Dev's $199. PriceAnnual is
// that year shown per month ($16.58) and is never multiplied back up: twelve of
// it is $198.96, four cents short on every renewal.
//
// The identity holding the two halves together is that MonthlyNormalizedCents
// reads AnnualTotal straight back to PriceAnnual: the price the revenue board
// reports is the price the catalog advertises, and neither side knows the other
// exists.
//
// A plan carrying no annual total is not sold annually. The free tier's is $0 and
// a contact-sales tier has no price at all, so a year of it is a $0 charge —
// which a card rail answers as a decline, blaming the buyer's card for a plan that
// was never on sale. Naming the real reason is the whole difference.
func planAtInterval(p *plan.Plan, interval types.Interval) (*plan.Plan, error) {
at := *p
switch interval {
case "", types.Monthly:
// The plan exactly as published. Every client that predates this field
// sends nothing and keeps buying what it bought before.
return &at, nil
case types.Yearly:
if p.AnnualTotal <= 0 {
return nil, fmt.Errorf("plan %q is not sold annually", p.Slug)
}
at.Price = p.AnnualTotal
at.Interval = types.Yearly
at.IntervalCount = 1
return &at, nil
}
return nil, fmt.Errorf("plan %q is not sold by the %q; choose %q or %q",
p.Slug, interval, types.Monthly, types.Yearly)
}
// planAt returns the plan this purchase opens at: one of the levels the catalog
// publishes, over one of the periods it publishes. It is the single answer to
// "what was bought", so the card path and the self-serve path price a purchase
// identically rather than by two call sites agreeing.
//
// A level and an annual period cannot be combined, because the catalog prices no
// such thing: `prices` is a ladder of the plan's RECURRING prices and priceAnnual
// is one figure standing against the base one. Max publishes ten rungs and a
// single $999 year, so Max at level 3 has a published monthly price ($299) and no
// published annual price whatsoever. The alternative is deriving one from the
// discount the other rungs happen to carry, which invents a price and charges a
// year of it up front. Refusing is the rule levels already follow — sell only what
// is published — and it is answered before any card is touched.
func planAt(p *plan.Plan, level int, interval types.Interval) (*plan.Plan, error) {
at, err := planAtLevel(p, level)
if err != nil {
return nil, err
}
if interval == types.Yearly && level != 0 {
return nil, fmt.Errorf("plan %q is sold annually at its base price only, not at level %d", p.Slug, level)
}
return planAtInterval(at, interval)
}
// createSubscription is the reusable CORE of subscription creation: seat gate →
// create the row → expand bundles → provision member seats. It does NOT enforce
// the C1-a paid-tier mint gate — that is the caller's job (the HTTP
// CreateBillingSubscription gates it; the card path is authorized by its settled
// charge). p carries the server-authoritative price; req.DefaultPaymentMethod is
// set on the row (the card path passes the vaulted payment-method id).
//
// It takes the datastore and the priced plan and nothing else, so it is reachable
// from a core as well as from an endpoint. The analytics emit belongs to the endpoint that
// rendered the result — it reads the collector off the request — so each
// entrypoint fires its own once this hands back the row.
//
// p is ALREADY priced at the chosen level and period (planAt), because the card
// path has to know the amount before it charges. So req.Level and req.Interval are
// the wire fields, and p.Price and p.Interval are the answer — this core reads
// them and never re-derives either.
func createSubscription(db *datastore.Datastore, p *plan.Plan, req *createSubscriptionRequest) (*subscription.Subscription, error) {
// Seat gate: the catalog is the sole authority for per-seat-ness and the
// seat floor. A per-seat plan bills Price × quantity, never below
// limits.minSeats, and member rows (each minting a per-user allotment) can
// never exceed the seats paid for.
quantity, err := seats(req.PlanId, req.Quantity, len(req.Members))
if err != nil {
return nil, err
}
if perSeat(req.PlanId) {
p.PerSeat = true
}
// Create subscription
sub := subscription.New(db)
sub.UserId = req.UserId
sub.StoreId = req.StoreId
sub.DefaultPaymentMethod = req.DefaultPaymentMethod
sub.ProviderType = "internal"
sub.Quantity = quantity
sub.Test = req.Test
if req.Metadata != nil {
sub.Metadata = req.Metadata
}
// Initialize subscription lifecycle
engine.StartSubscription(sub, p)
if c := req.Collected; c != nil {
c.open(sub)
}
if err := sub.Create(); err != nil {
log.Error("Failed to create subscription: %v", err)
return nil, err
}
// Bundle expansion. Plans declare their bundle list in the
// canonical catalog (subscription.json:bundles) — e.g. "pro"
// includes "world-pro", "team" includes "world-team", etc. Mint a
// child zero-cost subscription for each bundled slug so the
// downstream feature gate (world.hanzo.ai, chat, etc.) sees the
// entitlement without the customer having to pay twice. The child
// subscriptions reference the parent via Metadata.bundleParent so
// cancellation can cascade them.
bundled := bundledPlansForSlug(req.PlanId)
for _, childSlug := range bundled {
// A bundle child is ALWAYS zero-cost (the parent's invoice carries the
// charge) and must NEVER touch the plan authority: persisting a Price=0,
// envelope-less partial row there under-charged a DIRECT sub to the child
// plan (prod: world-pro/world-team served $0), and on a HIT it would have
// snapshotted the child's FULL price and double-charged. Build a LOCAL
// zero-price snapshot from the embed — the authority is owned only by the
// seed + CRUD. Mirrors provisionMembers' in-memory child copy.
childPlan := childSnapshotPlan(db, childSlug)
if childPlan == nil {
log.Error("bundled plan %q not found for parent %q (skipping)", childSlug, req.PlanId, nil)
continue
}
childSub := subscription.New(db)
childSub.UserId = sub.UserId
childSub.DefaultPaymentMethod = sub.DefaultPaymentMethod
childSub.ProviderType = "bundle"
childSub.Type = sub.Type
childSub.Quantity = 1
childSub.Test = sub.Test
childMeta := map[string]interface{}{
"bundleParent": sub.Id(),
"bundleParentPlan": req.PlanId,
}
for k, v := range req.Metadata {
childMeta[k] = v
}
childSub.Metadata = childMeta
engine.StartSubscription(childSub, childPlan)
childSub.PlanId = childSlug // stable label; the child plan is a local snapshot, not an authority row
if err := childSub.Create(); err != nil {
log.Error("Failed to create bundled subscription %q for parent %q: %v", childSlug, req.PlanId, err)
continue
}
}
// Seat → credits: one zero-price bundle-child row per member, so the
// monthly allotment run grants each member their own per-user included
// credit (never a multiplied org wallet).
if perSeat(req.PlanId) {
provisionMembers(db, sub, req.PlanId, p, req.Members)
}
return sub, nil
}
// bundledPlansForSlug returns the slugs of plans that ride along with
// a parent plan. Reads from the static catalog so we never need to hit
// the DB for plan metadata that is already baked into the binary.
func bundledPlansForSlug(slug string) []string {
for i := range catalog {
if catalog[i].Slug == slug {
out := make([]string, len(catalog[i].Bundles))
copy(out, catalog[i].Bundles)
return out
}
}
return nil
}
// childSnapshotPlan builds a ZERO-COST snapshot plan for a bundle child from the
// embed. It NEVER reads or writes the plan authority: a bundle child must not
// create a partial ($0, envelope-less) row there (Red: that under-charged a
// DIRECT sub to the child plan). Price is ALWAYS 0 — the parent's invoice carries
// the charge — so a child snapshot can never be invoiced. Returns nil if the slug
// is unknown to the embed.
func childSnapshotPlan(db *datastore.Datastore, childSlug string) *plan.Plan {
var sc *staticPlan
for i := range catalog {
if catalog[i].Slug == childSlug {
sc = &catalog[i]
break
}
}
if sc == nil {
return nil
}
// Bound to db (so StartSubscription's .Id() is safe) but NEVER Create()d — the
// child is an in-memory snapshot, exactly like provisionMembers' *base copy;
// the authority is never written from the subscription flow.
p := plan.New(db)
p.Slug = sc.Slug
p.Name = sc.Name
p.Description = sc.Description
p.Category = sc.Category
p.Currency = currency.Type(sc.Currency)
p.Interval = types.Interval(sc.Interval)
p.IntervalCount = sc.IntervalCount
p.Price = 0 // ALWAYS zero — a bundle child never charges
return p
}
// provisionMembers mints one zero-price bundle-child subscription row per
// member of a per-seat subscription — the SAME machinery as plan bundles, so
// the monthly allotment run (grantOrgAllotments) grants each member their own
// per-user included credit off their own row. Idempotent per (parent, member):
// a live child row is never duplicated. The child plan is an in-memory
// zero-price copy of the parent's plan — the customer pays once, on the
// parent's per-seat invoice.
func provisionMembers(db *datastore.Datastore, parent *subscription.Subscription, planSlug string, base *plan.Plan, members []string) {
seen := map[string]bool{parent.UserId: true}
for _, m := range members {
m = strings.ToLower(strings.TrimSpace(m))
if m == "" || seen[m] {
continue
}
seen[m] = true
if hasMemberSub(db, planSlug, m) {
continue
}
childPlan := *base
childPlan.Price = 0 // the member rides the parent's per-seat charge
childSub := subscription.New(db)
childSub.UserId = m
childSub.DefaultPaymentMethod = parent.DefaultPaymentMethod
childSub.ProviderType = "bundle"
childSub.Type = parent.Type
childSub.Quantity = 1
childSub.Test = parent.Test
childSub.Metadata = map[string]interface{}{
"bundleParent": parent.Id(),
"bundleParentPlan": planSlug,
}
engine.StartSubscription(childSub, &childPlan)
if err := childSub.Create(); err != nil {
log.Error("Failed to create member subscription for %q (parent %q): %v", m, parent.Id(), err)
}
}
}
// hasMemberSub reports whether member already holds a live bundle-child row
// for the plan — the persisted match key (subscription Metadata does not
// survive the datastore round-trip). One per-user grant anchor per plan per
// org is exactly the allotment semantics.
func hasMemberSub(db *datastore.Datastore, planSlug, member string) bool {
subs := make([]*subscription.Subscription, 0)
if _, err := subscription.Query(db).Filter("UserId=", member).GetAll(&subs); err != nil {
return false
}
for _, s := range subs {
if s.ProviderType != "bundle" || s.Plan.Slug != planSlug {
continue
}
switch s.Status {
case subscription.Active, subscription.Trialing:
return true
}
}
return false
}
// billingSubscription returns the subject's live, payment-backed PAID
// subscription — the one a second paid sale would bill twice — or nil.
//
// ONE SUBSCRIPTION SPEAKS FOR A SUBJECT. That is not a rule invented here; it is
// what the platform already assumes. subscriptionPlanSlug reduces every live
// subscription a subject holds to a SINGLE governing plan by picking the newest
// PeriodStart, with no notion of a product line — so a second concurrent
// subscription does not merely charge twice, it SHADOWS the first: hold "max"
// ($100/mo included) and start "dns-pro" and the newer row answers "what plan is
// this?" with a tier whose IncludedMonthlyCents is 0. Selling a second one cannot
// be made correct without first giving that resolver a notion of which
// subscription governs what, so this refuses the sale instead of corrupting the
// entitlement.
//
// Two exclusions, each for its own reason, and neither is the other's:
//
// FREE tiers do not count. Nothing bills, so nothing double-bills, and a free
// row must never block the customer who is finally paying — free → paid is the
// main funnel.
// NOT-payment-backed paid rows do not count either. A zero-payment internal row
// collects nothing, so it cannot double-charge, and counting it would trap the
// holder of a forged Active row outside any endpoint that could fix it.
//
// It answers a different question from subscriptionPlanSlug ("what may this
// subject's allotment anchor on") and shares its filters by coincidence of
// meaning, not by being the same predicate; keeping them apart is what lets FREE
// anchor an allotment while not blocking a sale.
func billingSubscription(db *datastore.Datastore, subject string, test bool) *subscription.Subscription {
subs, err := userSubscriptions(db, subject, test)
if err != nil {
// Fail OPEN on a read failure: refusing a legitimate first purchase because
// the subscription store hiccuped costs a customer, while the worst case of
// proceeding is the duplicate this exists to prevent — which the stable
// Square idempotency key and the guard still bound.
return nil
}
for _, s := range subs {
switch s.Status {
case subscription.Active, subscription.Trialing:
default:
continue
}
// A bundle child is an entitlement row the parent minted, not a purchase.
if strings.EqualFold(strings.TrimSpace(s.ProviderType), "bundle") {
continue
}
if paidRow(s) && subscriptionPaymentBacked(s) {
return s
}
}
return nil
}
// ListSubscriptions is the org's subscriptions, filtered — the QUERY, with no
// HTTP in it.
//
// It takes values rather than a request so a caller that is not a request can
// ask: the same list is read over the internal plane by a peer process that
// holds no ledger of its own, and re-deriving this query there would be a second
// implementation of one question. Two copies of a subscription filter is how a
// billing page and a plan gate come to disagree about who is subscribed.
//
// Empty userID or status means "do not filter on it", which is what an absent
// query parameter has always meant here.
//
// It returns the ROWS, not a rendered view: the HTTP handler builds its own
// envelope from these, and a peer builds its own. Returning the envelope would
// put a renderer next to the datastore and make every future caller inherit it.
//
// The org's namespace is the whole scope. Subscriptions are stored without a
// parent key (see subscriptionPlanSlug), so a synckey ancestor filter matched no
// row at all and every account's list read empty, its MRR zero.
func ListSubscriptions(ctx context.Context, org *organization.Organization, userID, status string) ([]*subscription.Subscription, error) {
if org == nil {
return nil, errors.New("subscriptions: no organization")
}
db := datastore.New(org.Namespaced(ctx))
q := subscription.Query(db)
if userID != "" {
q = q.Filter("UserId=", userID)
}
if status != "" {
q = q.Filter("Status=", status)
}
subs := make([]*subscription.Subscription, 0)
if _, err := q.GetAll(&subs); err != nil {
return nil, err
}
return subs, nil
}
// Subscriptions is the org's subscriptions as the customer's own billing page
// reads them — the rows of ListSubscriptions, each projected through the one
// view.
//
// It exists beside ListSubscriptions because a caller without a datastore cannot
// apply that projection itself: viewSubscription is where a row becomes an
// answer, and a peer re-deriving it would be a second description of one
// subscription. Two cores, one query, one projection.
//
// Empty userID or status means "do not filter on it", the same as an absent
// query parameter.
func Subscriptions(ctx context.Context, org *organization.Organization, userID, status string) ([]Subscription, error) {
rows, err := ListSubscriptions(ctx, org, userID, status)
if err != nil {
return nil, err
}
db := datastore.New(org.Namespaced(ctx))
out := make([]Subscription, 0, len(rows))
for _, s := range rows {
if s == nil {
continue
}
v := viewSubscription(s)
pay := periodPaid(db, s)
v.Settled, v.Seats, v.ChargedCents = pay.settled, pay.seats, pay.charged
out = append(out, *v)
}
return out, nil
}
// periodPayment is how a subscription's period in hand was paid, and for what.
type periodPayment struct {
settled string // Subscription.Settled
seats int // Subscription.Seats
charged int64 // Subscription.ChargedCents
}
// periodPaid is how the subscription's period in hand was paid: from that period's
// paid invoice — its settlement method, the seats its plan line charged, and its plan
// fee after discount less what was refunded — or, for a plan recorded as collected
// outside commerce with no invoice, "external:<processor>" with the seats and the
// price recorded with the period. A period unpaid, unknown, refunded in full or
// disputed answers the zero value.
func periodPaid(db *datastore.Datastore, s *subscription.Subscription) periodPayment {
invs := make([]*billinginvoice.BillingInvoice, 0)
if _, err := billinginvoice.Query(db).Filter("SubscriptionId=", s.Id()).GetAll(&invs); err == nil {
for _, inv := range invs {
if inv.Status != billinginvoice.Paid || !paysPeriod(inv, s) {
continue
}
if inv.Disputed() || (inv.AmountPaid > 0 && inv.Refunded() >= inv.AmountPaid) {
return periodPayment{}
}
fee, seats := int64(0), int64(0)
for _, li := range inv.LineItems {
if li.Type != billinginvoice.LineSubscription {
continue
}
// A plan line states its seats; one that does not is read from its
// amount at its unit price, and is one seat when it states neither.
n := li.Quantity
if n < 1 && li.UnitPrice > 0 {
n = li.Amount / li.UnitPrice
}
fee, seats = fee+li.Amount, seats+max(n, 1)
}
charged := min(fee-inv.Discount, inv.AmountPaid) - inv.Refunded()
return periodPayment{
settled: strings.ToLower(strings.TrimSpace(inv.PaymentMethod)),
seats: int(seats),
charged: max(charged, 0),
}
}
}
if c, _ := s.Metadata["collection"].(string); c != collectionExternal {
return periodPayment{}
}
periods, _ := s.Metadata["periods"].([]interface{})
for _, p := range periods {
m, _ := p.(map[string]interface{})
if start, _ := m["start"].(string); start != s.PeriodStart.UTC().Format(time.RFC3339) {
continue
}
// A period recorded without its seats and price reads what the record
// checked: the row's seats at the plan's price.
seats, ok := numberField(m, "quantity")
if !ok {
seats = recordedSeats(s)
}
charged, ok := numberField(m, "priceCents")
if !ok {
charged = int64(s.Plan.Price) * seats
}
return periodPayment{
settled: "external:" + strings.ToLower(strings.TrimSpace(s.ProviderType)),
seats: int(seats),
charged: charged,
}
}
return periodPayment{}
}
// paysPeriod reports whether inv paid the period s holds: the invoice is for that
// period, or the period began when a late payment of it settled.
func paysPeriod(inv *billinginvoice.BillingInvoice, s *subscription.Subscription) bool {
return inv.PeriodStart.Unix() == s.PeriodStart.Unix() ||
(!inv.PaidAt.IsZero() && inv.PaidAt.Unix() == s.PeriodStart.Unix())
}
// recordedSeats is the seat count an external record charges a row for: its
// quantity on a per-seat plan, one on a flat plan.
func recordedSeats(s *subscription.Subscription) int64 {
if s.Plan.PerSeat && s.Quantity > 1 {
return int64(s.Quantity)
}
return 1
}
// ListBillingSubscriptions lists subscriptions for a user.
//
// GET /v1/billing/subscriptions?userId=...
func ListBillingSubscriptions(c *zip.Ctx) error {
// #146 class: never panic on a missing org (co-resident embed path — see ListInvoices).
// No org ⇒ honest empty.
org, ok := middleware.GetOrganizationOK(c)
if !ok || org == nil {
return c.JSON(200, map[string]any{"subscriptions": []map[string]any{}, "count": 0})
}
// A caller that holds only its own wallet lists only that wallet's rows, whatever
// userId it names.
userID := strings.TrimSpace(c.Query("userId"))
if holder := callerHolder(c); holder != AnyHolder {
userID = holder
if userID == "" {
return c.JSON(200, map[string]any{"subscriptions": []map[string]any{}, "count": 0})
}
}
subs, err := ListSubscriptions(c.Context(), org, userID, strings.TrimSpace(c.Query("status")))
if err != nil {
log.Error("Failed to list subscriptions: %v", err, c)
return http.Fail(c, 500, "failed to list subscriptions", err)
}
items := make([]map[string]any, 0, len(subs))
for _, s := range subs {
items = append(items, subscriptionResponse(s))
}
return c.JSON(200, map[string]any{
"subscriptions": items,
"count": len(items),
})
}
// GetBillingSubscription returns a single subscription.
//
// GET /v1/billing/subscriptions/:id
func GetBillingSubscription(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
id := c.Param("id")
sub := subscription.New(db)
if err := sub.GetById(id); err != nil || !Holds(callerHolder(c), sub.UserId) {
return http.Fail(c, 404, "subscription not found", err)
}
return c.JSON(200, subscriptionResponse(sub))
}
// UpdateBillingSubscription updates a subscription (plan change, quantity).
//
// PATCH /v1/billing/subscriptions/:id
func UpdateBillingSubscription(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
id := c.Param("id")
sub := subscription.New(db)
if err := sub.GetById(id); err != nil || !Holds(callerHolder(c), sub.UserId) {
return http.Fail(c, 404, "subscription not found", err)
}
var req updateSubscriptionRequest
if err := c.Bind(&req); err != nil {
return http.Fail(c, 400, "invalid request body", err)
}
planChanged := req.PlanId != "" && req.PlanId != sub.PlanId
// Seat gate: the EFFECTIVE plan + quantity after this update must satisfy
// the catalog's per-seat floor, and member rows never exceed seats paid for.
// A change of seats waits for the next period, whose invoice charges it, so
// the seats paid for are the fewer of the period in hand's and the next one's.
slug := req.PlanId
if !planChanged {
if slug = sub.Plan.Slug; slug == "" {
slug = sub.PlanId
}
}
quantity := sub.Quantity
if sub.PendingQuantity > 0 {
quantity = sub.PendingQuantity
}
if req.Quantity > 0 {
quantity = req.Quantity
}
if quantity < 1 {
quantity = 1
}
if perSeat(slug) {
if min := minSeats(slug); quantity < min {
return http.Fail(c, 400,
fmt.Sprintf("plan %q requires at least %d seats (got %d)", slug, min, quantity), nil)
}
if paidSeats := min(quantity, max(sub.Quantity, 1)); len(req.Members) > paidSeats {
return http.Fail(c, 400,
fmt.Sprintf("members (%d) exceed seats paid for (%d)", len(req.Members), paidSeats), nil)
}
}
if planChanged {
// Resolve the target plan FIRST so the gate scores its canonical SLUG. A
// client may pass a plan's DB HASHID (resolveSubscriptionPlan→GetById accepts
// the ByKey path), which the slug-only IncludedMonthlyCents/paidTier would
// score as 0/false — bypassing the gate while ChangePlan applies the real
// high-allotment plan (Red R1). Resolving first collapses both id forms to the
// slug. This reads/lazily-materializes only the PLAN authority row; it does
// NOT touch the subscription or mint anything, so nothing anchors before the
// 403 below. It also reads the admin-edited price + inherits the embed
// fallback — never a bare per-org-ns miss.
if !sold(c) {
return http.Fail(c, 404, "new plan not found", nil)
}
newPlan, err := resolveSubscriptionPlan(db, req.PlanId)
if err != nil {
return http.Fail(c, 404, "new plan not found", err)
}
if !newPlan.Listed() {
// You may keep a retired tier you are already on; you may not MOVE onto
// one. Without this, archiving a plan leaves it purchasable by PATCH.
return http.Fail(c, 404, "new plan not found", nil)
}
// C1-a mint gate, PATCH parity (Red F1): CreateBillingSubscription gates a
// paid-tier START, but a plan CHANGE had NO gate — and engine.ChangePlan
// swaps sub.Plan for free (its proration line item is discarded, never
// charged). So a cheap payment-backed sub could be laundered into a higher
// tier's spendable allotment (subscribe dns-pro via card → PATCH to "max" →
// allotment/grant mints ~$100). Gate a move that RAISES the plan's list price
// (listCents) on MayMintMoney. Score the IMMUTABLE embed by the RESOLVED slug
// (never the raw id, never the editable DB Price), so neither a hashid nor an
// admin price edit can move the gate; a lateral/downgrade stays self-serve.
//
// A paid plan the catalog does not publish covers nothing the catalog can
// score, yet the tier reads it from the snapshot this move writes, on a row
// whose payment was for the plan it leaves. So a move onto one is scored by
// its own row (paidPlan) and is a staff move, as opening one is.
curSlug := sub.Plan.Slug
if curSlug == "" {
curSlug = sub.PlanId
}
uncatalogued := lookupPlan(newPlan.Slug) == nil && paidPlan(newPlan.Slug, newPlan)
if (listCents(newPlan.Slug) > listCents(curSlug) || uncatalogued) && !middleware.MayMintMoney(c) {
return http.Fail(c, 403,
"moving a subscription to a pricier plan requires platform-administrator or internal-service credentials", nil)
}
if perSeat(slug) {
newPlan.PerSeat = true
}
if _, err := engine.ChangePlan(sub, newPlan, req.Prorate); err != nil {
return http.Fail(c, 400, err.Error(), nil)
}
}
// The period in hand keeps the seats it was paid for; the next period opens
// at the seats asked for, and the renewal that opens it charges them.
switch {
case req.Quantity <= 0:
case req.Quantity == sub.Quantity:
sub.PendingQuantity = 0
default:
sub.PendingQuantity = req.Quantity
}
if err := sub.Update(); err != nil {
log.Error("Failed to update subscription: %v", err, c)
return http.Fail(c, 500, "failed to update subscription", err)
}
// Seat → credits on seat changes: same per-member machinery as create.
if perSeat(slug) && len(req.Members) > 0 {
provisionMembers(db, sub, slug, &sub.Plan, req.Members)
}
if planChanged {
emitSubscriptionPlanChanged(c, org.Name, sub)
}
return c.JSON(200, subscriptionResponse(sub))
}
// Subscription is one subscription as this surface reports it — the same facts
// subscriptionResponse renders for the browser, carried with a schema so a
// caller holding types instead of a response writer gets the same answer. One
// row, two projections; never two sets of facts.
type Subscription struct {
ID string `json:"id"`
UserID string `json:"userId"`
PlanID string `json:"planId"`
Status string `json:"status"`
Quantity int `json:"quantity"`
CurrentPeriodStart time.Time `json:"currentPeriodStart"`
CurrentPeriodEnd time.Time `json:"currentPeriodEnd"`
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
// MRRCents is what this subscription contributes to monthly recurring
// revenue, computed here so no reader re-derives it from price and interval.
MRRCents int64 `json:"mrrCents"`
ProviderType string `json:"providerType"`
DefaultPaymentMethod string `json:"defaultPaymentMethod"`
// Settled is how the period in hand was paid: "card" (a processor charged a card
// for all of it), "external:<processor>" (a payment recorded as collected outside
// commerce), "balance", "credit" or "mixed" (prepaid money, alone or beside a
// card). Empty when the period is unpaid or nothing says how.
Settled string `json:"settled,omitempty"`
// Seats is the seat count the period in hand was paid for: the quantity its
// payment charged, never the row's Quantity. 0 when the period is unpaid.
Seats int `json:"seats,omitempty"`
// ChargedCents is what the period in hand was paid for its plan, after any
// discount, less what was refunded against that payment. 0 when the period is
// unpaid.
ChargedCents int64 `json:"chargedCents,omitempty"`
// PendingQuantity is the seat count the next period opens at, when a change of
// seats waits for the renewal that charges it.
PendingQuantity int `json:"pendingQuantity,omitempty"`
Plan SubscriptionPlan `json:"plan"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// The four dates a subscription may not have. Each is a POINTER because the
// key is absent on a CONDITION — a trial was opened, a cancel happened, the
// row ended — and NOT because the value is zero: omitempty omits no
// time.Time, so a plain field would report every subscription as trialing
// since year one. Trial start and end appear together or not at all, which
// is what "it had a trial" means.
TrialStart *time.Time `json:"trialStart,omitempty"`
TrialEnd *time.Time `json:"trialEnd,omitempty"`
CanceledAt *time.Time `json:"canceledAt,omitempty"`
EndedAt *time.Time `json:"endedAt,omitempty"`
}
// SubscriptionPlan is the plan AS THIS SUBSCRIPTION SNAPSHOTTED IT: the priced
// thing the customer bought, which is not necessarily what the catalog sells
// today. It is a different type from PlanView (the catalog's sales shape)
// because renewals invoice off this snapshot — the two are meant to be free to
// disagree, and one shared type would quietly make every price edit retroactive.
type SubscriptionPlan struct {
ID string `json:"id"`
Name string `json:"name"`
Price int64 `json:"price"`
Currency string `json:"currency"`
Interval string `json:"interval"`
}
// viewSubscription projects the row onto the typed answer. ONE projection, so
// two cores cannot describe the same subscription two ways.
func viewSubscription(sub *subscription.Subscription) *Subscription {
v := &Subscription{
ID: sub.Id(),
UserID: sub.UserId,
PlanID: sub.PlanId,
Status: string(sub.Status),
Quantity: sub.Quantity,
PendingQuantity: sub.PendingQuantity,
CurrentPeriodStart: sub.PeriodStart,
CurrentPeriodEnd: sub.PeriodEnd,
CancelAtPeriodEnd: sub.EndCancel,
MRRCents: SubscriptionMRRCents(sub),
ProviderType: sub.ProviderType,
DefaultPaymentMethod: sub.DefaultPaymentMethod,
Plan: SubscriptionPlan{
ID: sub.Plan.Id(),
Name: sub.Plan.Name,
Price: int64(sub.Plan.Price),
Currency: string(sub.Plan.Currency),
Interval: string(sub.Plan.Interval),
},
CreatedAt: sub.CreatedAt,
UpdatedAt: sub.UpdatedAt,
}
if !sub.TrialStart.IsZero() {
start, end := sub.TrialStart, sub.TrialEnd
v.TrialStart, v.TrialEnd = &start, &end
}
if !sub.CanceledAt.IsZero() {
at := sub.CanceledAt
v.CanceledAt = &at
}
if !sub.Ended.IsZero() {
at := sub.Ended
v.EndedAt = &at
}
return v
}
// AnyHolder is the holder a caller with the org's own authority acts as — an
// administrator of the org, an operator's service token — and it reaches every
// subscription in the org. Every other caller names the wallet it pays from as its
// holder, and reaches only the subscriptions that wallet holds.
const AnyHolder = "*"
// Holds reports whether holder may act on a subscription owned by owner (its
// UserId): AnyHolder holds every one, a wallet holds its own, and an empty holder
// holds none.
func Holds(holder, owner string) bool {
holder = strings.TrimSpace(holder)
if holder == AnyHolder {
return true
}
return holder != "" && strings.EqualFold(holder, strings.TrimSpace(owner))
}
// heldSubscription reads one subscription the holder may act on. A row another
// wallet holds is not found rather than refused, so an id cannot be probed for
// existence inside a shared org.
func heldSubscription(ctx context.Context, org *organization.Organization, holder, id string) (*subscription.Subscription, error) {
sub, err := loadSubscription(ctx, org, id)
if err != nil {
return nil, err
}
if !Holds(holder, sub.UserId) {
return nil, fmt.Errorf("%w: held by another wallet", errSubscriptionNotFound)
}
return sub, nil
}
// callerHolder is the holder an HTTP caller acts as: AnyHolder for a privileged
// billing caller ([isPrivilegedBillingCaller]), and the wallet it pays from
// otherwise.
func callerHolder(c *zip.Ctx) string {
if isPrivilegedBillingCaller(c) {
return AnyHolder
}
return userBillingKey(c)
}
// loadSubscription reads one subscription inside the org's own namespace, and
// answers errSubscriptionNotFound when there is none. The namespace does the
// scoping by construction: an id from another org is not found rather than found
// and then filtered, so there is no window in which the wrong row is in hand.
func loadSubscription(ctx context.Context, org *organization.Organization, id string) (*subscription.Subscription, error) {
if org == nil {
return nil, errors.New("subscriptions: no organization")
}
sub := subscription.New(datastore.New(org.Namespaced(ctx)))
if err := sub.GetById(id); err != nil {
return nil, fmt.Errorf("%w: %v", errSubscriptionNotFound, err)
}
return sub, nil
}
// CancelSubscription ends a subscription — at the end of the period already paid
// for, or immediately — and answers with the row as it now stands.
//
// It takes values rather than a request because the customer who cancels is not
// always at a browser: the same act arrives over the internal plane from a peer
// that holds no ledger of its own, and a second implementation of "cancel" is
// how a billing page and a renewal come to disagree about who is still paying.
//
// The lifecycle belongs to the engine, so a move it will not make comes back as
// the caller's own refusal (IsSubscriptionRefused) rather than being re-checked
// here: one state machine, in one place.
//
// holder is whose subscriptions the caller may change ([AnyHolder] or the wallet it
// pays from). A subscription it does not hold is not found (IsSubscriptionNotFound).
func CancelSubscription(ctx context.Context, org *organization.Organization, holder, id string, atPeriodEnd bool) (*Subscription, error) {
sub, err := cancelSubscription(ctx, org, holder, id, atPeriodEnd)
if err != nil {
return nil, err
}
return viewSubscription(sub), nil
}
// cancelSubscription is the worker: it returns the ROW, so the HTTP endpoint renders
// its long-standing subscriptionResponse body and fires its analytics event off
// the model, while the typed caller reads Subscription. Two projections of one
// result, never two implementations.
//
// The emit stays at the endpoint on purpose — it reads the collector off the
// request, and nothing that needs a request belongs in here.
func cancelSubscription(ctx context.Context, org *organization.Organization, holder, id string, atPeriodEnd bool) (*subscription.Subscription, error) {
sub, err := heldSubscription(ctx, org, holder, id)
if err != nil {
return nil, err
}
if err := engine.CancelSubscription(sub, atPeriodEnd); err != nil {
return nil, subValidationError{err.Error()}
}
if err := sub.Update(); err != nil {
return nil, err
}
// Ended now, the row is served no further period, so an invoice it still owes
// for one is void rather than left to be paid onto a canceled plan.
if !atPeriodEnd {
if err := engine.VoidOpen(datastore.New(org.Namespaced(ctx)), sub); err != nil {
return nil, err
}
}
return sub, nil
}
// ReactivateSubscription puts a canceled subscription back on its plan and
// answers with the row as it now stands. Values rather than a request for the
// reason CancelSubscription gives, and with more force: reactivation is the act
// least likely to come from a browser, since what asks for it is usually a
// recovered payment method or a support tool. holder is as [CancelSubscription]
// takes it.
func ReactivateSubscription(ctx context.Context, org *organization.Organization, holder, id string) (*Subscription, error) {
sub, err := reactivateSubscription(ctx, org, holder, id)
if err != nil {
return nil, err
}
return viewSubscription(sub), nil
}
// reactivateSubscription is the worker. See cancelSubscription.
func reactivateSubscription(ctx context.Context, org *organization.Organization, holder, id string) (*subscription.Subscription, error) {
sub, err := heldSubscription(ctx, org, holder, id)
if err != nil {
return nil, err
}
if err := engine.ReactivateSubscription(sub); err != nil {
return nil, subValidationError{err.Error()}
}
if err := sub.Update(); err != nil {
return nil, err
}
return sub, nil
}
// CancelBillingSubscription cancels a subscription.
//
// POST /v1/billing/subscriptions/:id/cancel
func CancelBillingSubscription(c *zip.Ctx) error {
// The OK form: IAMTokenRequired falls through WITHOUT setting the
// "organization" local when the gateway named no principal, and the MustGet
// form panics there — a 500 with no body, after the money has moved. Refuse
// before touching anything. See SubscribeWithCard, where this cost a $99
// charge with no subscription behind it.
org, ok := middleware.GetOrganizationOK(c)
if !ok || org == nil {
return http.Fail(c, 401, "sign in to change your subscription", nil)
}
var req cancelSubscriptionRequest
if err := c.Bind(&req); err != nil {
// Default to cancel at period end
req.AtPeriodEnd = true
}
sub, err := cancelSubscription(c.Context(), org, callerHolder(c), c.Param("id"), req.AtPeriodEnd)
if err != nil {
switch {
case IsSubscriptionNotFound(err):
return http.Fail(c, 404, "subscription not found", err)
case IsSubscriptionRefused(err):
return http.Fail(c, 400, err.Error(), nil)
}
log.Error("Failed to cancel subscription: %v", err, c)
return http.Fail(c, 500, "failed to cancel subscription", err)
}
emitSubscriptionCanceled(c, org.Name, sub)
return c.JSON(200, subscriptionResponse(sub))
}
// ReactivateBillingSubscription reactivates a canceled subscription.
//
// POST /v1/billing/subscriptions/:id/reactivate
func ReactivateBillingSubscription(c *zip.Ctx) error {
// The OK form: IAMTokenRequired falls through WITHOUT setting the
// "organization" local when the gateway named no principal, and the MustGet
// form panics there — a 500 with no body, after the money has moved. Refuse
// before touching anything. See SubscribeWithCard, where this cost a $99
// charge with no subscription behind it.
org, ok := middleware.GetOrganizationOK(c)
if !ok || org == nil {
return http.Fail(c, 401, "sign in to change your subscription", nil)
}
sub, err := reactivateSubscription(c.Context(), org, callerHolder(c), c.Param("id"))
if err != nil {
switch {
case IsSubscriptionNotFound(err):
return http.Fail(c, 404, "subscription not found", err)
case IsSubscriptionRefused(err):
return http.Fail(c, 400, err.Error(), nil)
}
log.Error("Failed to reactivate subscription: %v", err, c)
return http.Fail(c, 500, "failed to reactivate subscription", err)
}
return c.JSON(200, subscriptionResponse(sub))
}
// RenewBillingSubscription manually triggers a billing cycle renewal.
// Normally this would be automated by Temporal, but this endpoint allows
// manual triggering for testing and for deployments without Temporal.
//
// POST /v1/billing/subscriptions/:id/renew
func RenewBillingSubscription(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
// Hydrate the org's payment credentials so the renewal can re-charge the
// subscription's vaulted card via the per-org Square processor.
hydratePaymentCreds(c, org)
db := datastore.New(org.Namespaced(c.Context()))
id := c.Param("id")
sub := subscription.New(db)
if err := sub.GetById(id); err != nil || !Holds(callerHolder(c), sub.UserId) {
return http.Fail(c, 404, "subscription not found", err)
}
inv, result, err := engine.RenewSubscription(c.Context(), db, sub, prepaidFor(c.Context(), org), chargeProviderForOrg(org))
if err != nil {
log.Error("Failed to renew subscription: %v", err, c)
return http.Fail(c, 500, "failed to renew subscription", err)
}
if err := sub.Update(); err != nil {
log.Error("Failed to update subscription after renewal: %v", err, c)
return http.Fail(c, 500, "failed to update subscription", err)
}
resp := map[string]any{
"subscription": subscriptionResponse(sub),
"collection": result,
}
// inv is nil when the period was not due (or a concurrent renewal is already in
// flight): no invoice was generated and nothing was charged.
if inv != nil {
emitSubscriptionRenewed(c, org.Name, sub)
resp["invoice"] = invoiceResponse(inv)
}
return c.JSON(200, resp)
}
func subscriptionResponse(sub *subscription.Subscription) map[string]any {
resp := map[string]any{
"id": sub.Id(),
"userId": sub.UserId,
"planId": sub.PlanId,
"status": sub.Status,
"quantity": sub.Quantity,
"currentPeriodStart": sub.PeriodStart,
"currentPeriodEnd": sub.PeriodEnd,
"cancelAtPeriodEnd": sub.EndCancel,
// What this subscription contributes to MRR, computed here so no reader
// has to re-derive it from price and interval. The hanzoai/cloud admin
// used to, with its own copy of the normalization.
"mrrCents": SubscriptionMRRCents(sub),
"providerType": sub.ProviderType,
"defaultPaymentMethod": sub.DefaultPaymentMethod,
"plan": map[string]any{
"id": sub.Plan.Id(),
"name": sub.Plan.Name,
"price": sub.Plan.Price,
"currency": sub.Plan.Currency,
"interval": sub.Plan.Interval,
},
"createdAt": sub.CreatedAt,
"updatedAt": sub.UpdatedAt,
}
if sub.PendingQuantity > 0 {
resp["pendingQuantity"] = sub.PendingQuantity
}
if !sub.TrialStart.IsZero() {
resp["trialStart"] = sub.TrialStart
resp["trialEnd"] = sub.TrialEnd
}
if !sub.CanceledAt.IsZero() {
resp["canceledAt"] = sub.CanceledAt
}
if !sub.Ended.IsZero() {
resp["endedAt"] = sub.Ended
}
return resp
}
// seats is the seat count a subscription to planID opens with, or why it cannot open:
// at least one; for a per-seat plan at least the catalog's floor (limits.minSeats),
// and never fewer seats than members. It is the ONE seat rule, asked by every path
// that opens a subscription — and by a path that takes money for one, BEFORE it takes
// the money.
func seats(planID string, quantity, members int) (int, error) {
if quantity < 1 {
quantity = 1
}
if !perSeat(planID) {
return quantity, nil
}
if min := minSeats(planID); quantity < min {
return 0, subValidationError{fmt.Sprintf("plan %q requires at least %d seats (got %d)", planID, min, quantity)}
}
if members > quantity {
return 0, subValidationError{fmt.Sprintf("members (%d) exceed seats (%d)", members, quantity)}
}
return quantity, nil
}