Files
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

493 lines
17 KiB
Go

// Package trial implements the new-signup on-ramp: a trialing subscription of
// the entry plan funded with a single unified trial credit.
//
// The model (settled 2026-07-02):
//
// - Entry plan is the catalog's entry paid rung, "dev": its
// allowance is metered down across usage. After the trial the subscriber is
// on that rung, metered, with overage billed.
// - A brand-new signup with NO card gets a 7-day trial; adding a card extends
// it to 30 days (ExtendForCard). A signup that adds a card up front is just
// the 7-day path immediately extended to 30 — ExtendForCard starts a 30-day
// trial when none exists yet.
// - The trial allowance is ONE unified credit spendable across compute OR AI:
// it is an ordinary tag-scoped, expiring Deposit — the SAME mechanism as
// billing/allotment's included credit — so it nets into the single balance
// the gateway's prepaid gate (available > 0) reads and both compute and AI
// usage debit. No separate buckets, no second gate.
//
// Decomplected from the plan catalog: this package never reads @hanzo/plans. The
// catalog owner (api/billing) wires the entry plan's economics once via
// SetEntryPlanResolver; here we only orchestrate subscription + credit.
package trial
import (
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/hanzoai/commerce/billing/engine"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/models/transaction"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/types"
)
const (
// PlanSlug is the catalog's entry plan; the trial funds one month of it.
PlanSlug = "dev"
// CreditTag marks the one unified trial-credit deposit. Exactly one per
// subject; it is both the funding record and the idempotency anchor.
CreditTag = "trial-credit"
// Kind is the destination kind for IAM-bound balance transactions — the
// same kind the balance read/gate resolves (models/transaction/util).
Kind = "iam-user"
// NoCardTrialDays / CardTrialDays are the trial windows. A card on file
// extends the trial from 7 to 30 days.
NoCardTrialDays = 7
CardTrialDays = 30
// ProviderType marks trial subscriptions (vs "manual_gift" / "stripe").
ProviderType = "trial"
)
// ErrInvalidSubject is returned when the billing subject is empty.
var ErrInvalidSubject = errors.New("trial: invalid subject")
// Plan is the entry-plan projection the trial needs: the recurring price to
// bill after the trial, and the unified credit to fund during it. The catalog
// owner supplies it (see SetEntryPlanResolver).
type Plan struct {
Slug string
Name string
Description string
PriceCents int64 // recurring monthly price after the trial
CreditCents int64 // unified trial credit funded for the trial window
Currency string
}
// Result describes the outcome of a trial operation.
type Result struct {
Started bool `json:"started"` // this call created a new trial
Extended bool `json:"extended"` // this call extended 7 -> 30 days
Reason string `json:"reason,omitempty"`
SubscriptionID string `json:"subscriptionId,omitempty"`
Plan string `json:"plan"`
TrialDays int `json:"trialDays"`
TrialEnd time.Time `json:"trialEnd,omitempty"`
CreditCents int64 `json:"creditCents"`
TransactionID string `json:"transactionId,omitempty"`
}
var (
entryPlanMu sync.RWMutex
entryPlanFn func() Plan
)
// SetEntryPlanResolver wires the catalog-backed resolver for the entry ($20)
// plan. Called once at startup by the catalog owner (api/billing). Kept as a
// resolver (not a value) so this package never imports the catalog and startup
// init-order does not matter. When unset, trial operations are safe no-ops so
// binaries that never load the catalog still build and run.
func SetEntryPlanResolver(fn func() Plan) {
entryPlanMu.Lock()
entryPlanFn = fn
entryPlanMu.Unlock()
}
func resolveEntryPlan() (Plan, bool) {
entryPlanMu.RLock()
fn := entryPlanFn
entryPlanMu.RUnlock()
if fn == nil {
return Plan{}, false
}
p := fn()
if p.Slug == "" || p.CreditCents <= 0 {
return Plan{}, false
}
return p, true
}
// Start begins a trial for a brand-new signup: a trialing subscription of the
// entry plan plus the single unified trial credit. cardPresent selects the
// window (7 days without a card, 30 with one). isTest routes the credit to the
// test ledger (org.TestMode()).
//
// New-signup only: if the subject already has a subscription OR any prior
// balance deposit (an existing/ comped user, or a repeat call), Start is a
// no-op (Started=false, Reason="not_new"). This is what leaves existing users —
// and Dave's real $10k ledger — untouched, and makes Start idempotent.
func Start(db *datastore.Datastore, subject string, cardPresent bool, isTest bool) (Result, error) {
return StartForStore(db, subject, "", cardPresent, isTest)
}
// StartForStore begins the entry trial for one store. A store is the billing
// unit, so prior activity on another store must neither unlock nor disqualify
// this one.
func StartForStore(db *datastore.Datastore, subject, storeID string, cardPresent bool, isTest bool) (Result, error) {
subject = normalizeSubject(subject)
if subject == "" {
return Result{}, ErrInvalidSubject
}
p, ok := resolveEntryPlan()
if !ok {
return Result{Plan: PlanSlug, Reason: "trial_not_configured"}, nil
}
days := NoCardTrialDays
if cardPresent {
days = CardTrialDays
}
res := Result{Plan: p.Slug, TrialDays: days, CreditCents: p.CreditCents}
has, err := subjectHasBillingHistory(db, subject, storeID)
if err != nil {
return res, err
}
if has {
res.Reason = "not_new"
return res, nil
}
sub, err := createTrialSubscription(db, subject, storeID, p, days)
if err != nil {
return res, err
}
res.SubscriptionID = sub.Id()
res.TrialEnd = sub.TrialEnd
dep, err := grantTrialCredit(db, subject, storeID, p, sub.TrialEnd, isTest)
if err != nil {
return res, err
}
res.TransactionID = dep.Id()
res.Started = true
return res, nil
}
// ExtendForCard extends a subject's trial to 30 days when a card is added.
//
// - Trialing entry-plan subscription found and shorter than 30 days: extend
// the trial window (from its original start) and stretch the trial credit's
// expiry to match. (Extended=true)
// - No trial yet: start a fresh 30-day trial (the "signup WITH a card" path),
// which is a no-op for existing users via Start's new-signup gate.
// - Trial already >= 30 days, or subject has no eligible trial: no-op.
func ExtendForCard(db *datastore.Datastore, subject string, isTest bool) (Result, error) {
subject = normalizeSubject(subject)
if subject == "" {
return Result{}, ErrInvalidSubject
}
p, ok := resolveEntryPlan()
if !ok {
return Result{Plan: PlanSlug, Reason: "trial_not_configured"}, nil
}
sub, found, err := findTrialSubscription(db, subject, p.Slug)
if err != nil {
return Result{Plan: p.Slug}, err
}
if !found {
// No trial to extend — a card added up front means "signup WITH a
// card": start a 30-day trial (no-op for existing users).
return Start(db, subject, true, isTest)
}
res := Result{Plan: p.Slug, SubscriptionID: sub.Id(), TrialDays: CardTrialDays, CreditCents: p.CreditCents}
base := sub.TrialStart
if base.IsZero() {
base = sub.Start
}
newEnd := base.AddDate(0, 0, CardTrialDays)
if !sub.TrialEnd.Before(newEnd) {
res.TrialEnd = sub.TrialEnd
res.Reason = "already_extended"
return res, nil
}
sub.TrialEnd = newEnd
sub.PeriodStart = newEnd
sub.PeriodEnd = newEnd.AddDate(0, 1, 0) // monthly cadence
if err := sub.Update(); err != nil {
return res, fmt.Errorf("trial: extend subscription: %w", err)
}
res.TrialEnd = newEnd
dep, ok, err := findTrialCredit(db, subject)
if err != nil {
return res, err
}
if ok && dep.ExpiresAt.Before(newEnd) {
dep.ExpiresAt = newEnd
if err := dep.Update(); err != nil {
return res, fmt.Errorf("trial: extend trial credit: %w", err)
}
res.TransactionID = dep.Id()
}
res.Extended = true
return res, nil
}
// subjectHasBillingHistory reports whether the subject already has a
// subscription or any deposit — i.e. is not a brand-new signup.
func subjectHasBillingHistory(db *datastore.Datastore, subject, storeID string) (bool, error) {
// Subscriptions are keyed by UserId without the synckey ancestor (the
// canonical create path, billing/grant.Grant, queries them ancestor-less);
// transactions live under the synckey ancestor. Query each accordingly.
subs := make([]*subscription.Subscription, 0, 1)
q := subscription.Query(db).Filter("UserId=", subject)
if storeID != "" {
q = q.Filter("StoreId=", storeID)
}
if _, err := q.Limit(1).GetAll(&subs); err != nil {
return false, err
}
if len(subs) > 0 {
return true, nil
}
// An unbound account trial retains the prior subject-wide history rule. A
// store trial is keyed by its subscription instead: org balance is shared,
// so a deposit for another store must not disqualify this store.
if storeID != "" {
return false, nil
}
// Any deposit credited to an unbound subject counts as prior history.
rootKey := db.NewKey("synckey", "", 1, nil)
deps := make([]*transaction.Transaction, 0, 1)
if _, err := transaction.Query(db).Ancestor(rootKey).
Filter("DestinationId=", subject).Limit(1).GetAll(&deps); err != nil {
return false, err
}
return len(deps) > 0, nil
}
func createTrialSubscription(db *datastore.Datastore, subject, storeID string, p Plan, days int) (*subscription.Subscription, error) {
planRec, err := seedPlan(db, p)
if err != nil {
return nil, err
}
// The 7-vs-30 trial length varies per subject, so ride it on a copy — never
// mutate the shared plan record.
pc := *planRec
pc.TrialPeriodDays = days
sub := subscription.New(db)
sub.UserId = subject
sub.StoreId = storeID
sub.ProviderType = ProviderType
sub.Quantity = 1
sub.Metadata = types.Map{
"trial": true,
"trial_days": days,
"billing_reason": "trial",
}
// StartSubscription sets Status=Trialing, TrialStart/TrialEnd, and the first
// post-trial period from the plan's cadence + TrialPeriodDays.
engine.StartSubscription(sub, &pc)
if err := sub.Create(); err != nil {
return nil, fmt.Errorf("trial: create subscription: %w", err)
}
return sub, nil
}
// seedPlan returns the entry plan's DB record, creating it on first use so the
// subscription references a stable Id (mirrors billing/grant.Grant).
func seedPlan(db *datastore.Datastore, p Plan) (*plan.Plan, error) {
pl := plan.New(db)
found, err := pl.Query().Filter("Slug=", p.Slug).Get()
if err != nil {
return nil, fmt.Errorf("trial: lookup plan %s: %w", p.Slug, err)
}
if found {
return pl, nil
}
pl.Slug = p.Slug
pl.Name = p.Name
pl.Description = p.Description
pl.Price = currency.Cents(p.PriceCents)
pl.Currency = currency.Type(planCurrency(p))
pl.Interval = types.Monthly
pl.IntervalCount = 1
if err := pl.Create(); err != nil {
return nil, fmt.Errorf("trial: seed plan %s: %w", p.Slug, err)
}
return pl, nil
}
// grantTrialCredit funds the single unified trial credit as an expiring Deposit.
// The credit is capped at ONE per org subject: a second store's trial (the
// subject is the shared org, the store only varies the subscription) reuses the
// existing credit rather than minting a fresh $20 — otherwise looping
// create-store -> trial would mint unlimited free credit. Per-store trial
// SUBSCRIPTIONS are untouched; only the money grant is org-capped.
func grantTrialCredit(db *datastore.Datastore, subject, storeID string, p Plan, expiresAt time.Time, isTest bool) (*transaction.Transaction, error) {
if dep, ok, err := findAnyTrialCredit(db, subject); err != nil {
return nil, err
} else if ok {
return dep, nil
}
trans := transaction.New(db)
trans.Type = transaction.Deposit
trans.DestinationId = subject
trans.DestinationKind = Kind
trans.Currency = currency.Type(planCurrency(p))
trans.Amount = currency.Cents(p.CreditCents)
trans.Notes = fmt.Sprintf(
"Trial credit: %s plan ($%.2f, one balance across compute & AI, expires %s)",
p.Slug, float64(p.CreditCents)/100, expiresAt.Format("2006-01-02"),
)
// Unified org tag (never per-store) so the single credit is capped to one per
// subject and ExtendForCard's findTrialCredit resolves it.
trans.Tags = CreditTag
trans.ExpiresAt = expiresAt
trans.Metadata = types.Map{
"creditType": "trial",
"plan": p.Slug,
"storeId": storeID,
}
trans.Test = isTest
if err := trans.Create(); err != nil {
return nil, fmt.Errorf("trial: fund trial credit: %w", err)
}
return trans, nil
}
// findTrialSubscription returns the newest trialing subscription of the given
// plan for the subject.
func findTrialSubscription(db *datastore.Datastore, subject, slug string) (*subscription.Subscription, bool, error) {
// Subscriptions are keyed ancestor-less by UserId (see subjectHasBillingHistory).
subs := make([]*subscription.Subscription, 0)
if _, err := subscription.Query(db).
Filter("UserId=", subject).GetAll(&subs); err != nil {
return nil, false, err
}
var best *subscription.Subscription
for _, s := range subs {
if s.Status != subscription.Trialing || s.Plan.Slug != slug {
continue
}
if best == nil || s.TrialStart.After(best.TrialStart) {
best = s
}
}
if best == nil {
return nil, false, nil
}
// Reload a New(db)-bound instance: models from a package-level GetAll carry
// no datastore key/handle, so mutating+Update on them is a no-op. GetById
// returns a fully-wired model (matches billing/grant's create/mutate path).
bound := subscription.New(db)
if err := bound.GetById(best.Id()); err != nil {
return nil, false, err
}
return bound, true, nil
}
func findTrialCredit(db *datastore.Datastore, subject string) (*transaction.Transaction, bool, error) {
return findTrialCreditByTag(db, subject, CreditTag)
}
// findAnyTrialCredit returns the subject's single trial credit if one already
// exists in ANY form — the unified org tag (CreditTag) or a legacy per-store tag
// (CreditTag+":"+storeID) minted before the org-cap. This is the money cap: a
// second store's trial must reuse the org's one credit, never mint a fresh grant.
func findAnyTrialCredit(db *datastore.Datastore, subject string) (*transaction.Transaction, bool, error) {
rootKey := db.NewKey("synckey", "", 1, nil)
ts := make([]*transaction.Transaction, 0, 4)
keys, err := transaction.Query(db).Ancestor(rootKey).
Filter("DestinationId=", subject).GetAll(&ts)
if err != nil {
return nil, false, err
}
for i, t := range ts {
if t.Tags != CreditTag && !strings.HasPrefix(t.Tags, CreditTag+":") {
continue
}
if i >= len(keys) {
break
}
// Reload under the full synckey-parented key so a later Update persists
// (same rationale as findTrialCreditByTag).
fullKey := db.NewKey("transaction", keys[i].StringID(), keys[i].IntID(), rootKey)
bound := transaction.New(db)
if err := bound.Get(fullKey); err != nil {
return nil, false, err
}
return bound, true, nil
}
return nil, false, nil
}
func findTrialCreditByTag(db *datastore.Datastore, subject, tag string) (*transaction.Transaction, bool, error) {
// Load straight into a New(db)-bound model via .Get() (grant.Grant's proven
// mutate pattern): the returned model carries both the datastore handle AND
// the full synckey-ancestor key, so a later Update() re-saves in place.
// (GetAll yields db-less copies; GetById drops the ancestor — both break the
// subsequent Update for ancestor-scoped ledger rows.)
// Single-filter query then match the tag in-memory: two-equality-filter
// queries (DestinationId + Tags) need a composite index not guaranteed across
// datastore backends. Then reload the hit by its full key (which retains the
// synckey ancestor) into a New(db)-bound model, so Update() persists in place.
rootKey := db.NewKey("synckey", "", 1, nil)
ts := make([]*transaction.Transaction, 0, 4)
keys, err := transaction.Query(db).Ancestor(rootKey).
Filter("DestinationId=", subject).GetAll(&ts)
if err != nil {
return nil, false, err
}
for i, t := range ts {
if t.Tags != tag {
continue
}
// Reload the row into a fresh New(db)-bound model under its FULL
// synckey-parented key. A raw GetAll element has no datastore handle and
// (crucially for ancestor-scoped ledger rows) no reload baseline, so a
// later Update silently no-ops the field change. Reconstructing the
// parented key + Get() gives a properly-loaded model whose Update()
// persists in place under synckey. keys[i] pairs with ts[i].
if i >= len(keys) {
break
}
fullKey := db.NewKey("transaction", keys[i].StringID(), keys[i].IntID(), rootKey)
bound := transaction.New(db)
if err := bound.Get(fullKey); err != nil {
return nil, false, err
}
return bound, true, nil
}
return nil, false, nil
}
func planCurrency(p Plan) string {
if p.Currency == "" {
return "usd"
}
return p.Currency
}
func normalizeSubject(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}