mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
subscribe resolves the plan, then reads every rule by its slug, so a row id is priced per seat and held to the plan's seat floor and ceiling as its slug is. Every subscription view carries held: the row is the subject's one paid plan (active or trialing, not a bundle child, a paid tier, payment-backed), the one a second paid sale is refused over. billingSubscription asks the same predicate, so a reader asks commerce instead of restating its rule. Co-authored-by: Hanzo Dev <[email protected]>
1395 lines
55 KiB
Go
1395 lines
55 KiB
Go
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 why := overSeats(p.Slug, qty); why != "" {
|
||
return http.Fail(c, 400, why, nil)
|
||
}
|
||
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.Status = staticP.Status // a draft stays unlisted when the embed materializes it
|
||
_ = 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 {
|
||
if holdsPlan(s) {
|
||
return s
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// holdsPlan reports whether s is a subject's one paid plan, the row a second
|
||
// paid sale is refused over (billingSubscription): active or trialing, not a
|
||
// bundle child, a paid tier, and payment-backed. Every subscription view states
|
||
// it (Subscription.Held), so a reader asks commerce rather than restating it.
|
||
func holdsPlan(s *subscription.Subscription) bool {
|
||
switch s.Status {
|
||
case subscription.Active, subscription.Trialing:
|
||
default:
|
||
return false
|
||
}
|
||
// A bundle child is an entitlement row the parent minted, not a purchase.
|
||
if strings.EqualFold(strings.TrimSpace(s.ProviderType), "bundle") {
|
||
return false
|
||
}
|
||
return paidRow(s) && subscriptionPaymentBacked(s)
|
||
}
|
||
|
||
// 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 why := overSeats(slug, quantity); why != "" {
|
||
return http.Fail(c, 400, why, nil)
|
||
}
|
||
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"`
|
||
// Held reports that this row is the subject's one paid plan: while it stands,
|
||
// a second paid sale is refused (409).
|
||
Held bool `json:"held"`
|
||
|
||
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"`
|
||
// Slug is the catalog plan the row was opened on, e.g. "max-20x": what a
|
||
// reader matches against GET /v1/billing/plans. Empty on a row whose snapshot
|
||
// predates it.
|
||
Slug string `json:"slug,omitempty"`
|
||
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,
|
||
Held: holdsPlan(sub),
|
||
Plan: SubscriptionPlan{
|
||
ID: sub.Plan.Id(),
|
||
Slug: sub.Plan.Slug,
|
||
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,
|
||
"held": holdsPlan(sub),
|
||
"plan": map[string]any{
|
||
"id": sub.Plan.Id(),
|
||
"slug": sub.Plan.Slug,
|
||
"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 why := overSeats(planID, quantity); why != "" {
|
||
return 0, subValidationError{why}
|
||
}
|
||
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
|
||
}
|