Files
commerce/api/billing/events_emit.go
zooqueenandHanzo Dev bd0dabc515 Subscriptions renew from the source they were bought with, one period up front at the interval and price bought, retry a decline on days 1, 3 and 7 before dropping to free, and the cycle is a dry run unless asked
The billing cycle takes one step per live subscription (engine.Settle) and
reports every one: org, plan, interval, paid period, billed period, price,
amount, source and action. Without dryRun=false it writes nothing.

- A renewal bills the period after the paid one from sub.Plan, the plan as
  bought, on the source it was bought from: its card, or prepaid money
  (credits, then the balance) for a plan bought with credits or the balance.
  A plan collected outside Hanzo is renewed by its processor and never charged.
- A decline is retried 1, 3 and 7 days after it; the subscriber keeps the
  tier meanwhile, and the last decline ends the subscription.
- A subscription more than 72 hours past its paid period with no attempt is
  overdue: nothing is charged or written until its customer renews it, which
  bills the period from then and never the time it was not renewed.
- Realignment is an operator's act with its own dry run
  (POST /v1/billing/realign/run[-all]): a row whose PeriodStart is its current
  paid invoice's PeriodEnd moves back onto that invoice's period.
- Every payment on an invoice holds one lock and records its attempt before
  asking for money; an unknown outcome is repeated under its key, escalated to
  unpaid after the schedule, and resolved before any cancel or end voids it.
- A cancel landing while a renewal is at the processor stays canceled; a
  charge that does not answer in 30 seconds is unknown; one org's cycle stops
  starting subscriptions after 10 minutes.
- A voided or uncollectible invoice returns what it collected to prepaid
  money; tax is set on drafts only; a platform operator voids an unresolved
  attempt through POST /v1/billing/invoices/:id/void-unresolved with a reason
  recorded in the billing event ledger.
- How a subscription was bought is read from its earliest invoice that bills
  a period.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 06:36:35 -07:00

264 lines
9.9 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"
"strings"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/events"
"github.com/hanzoai/commerce/models/billinginvoice"
"github.com/hanzoai/commerce/models/subscription"
)
// Analytics emit — the customer-activity spine. Each billing money-path handler
// fires ONE best-effort event to the analytics collector (which lands it in
// commerce.events) so the fleet read side (admin.hanzo.ai) can aggregate
// billing/subscription/usage across every tenant with NO per-org fan-out. This
// file holds the ONE fire-and-forget wiring + the model→event builders so the
// handlers stay one line and DRY. Money is USD cents end to end.
// fireEvent runs fn with the request-scoped analytics client on a DETACHED
// context (fire-and-forget) when a collector is wired — best-effort, so it can
// never block or fail the money path. No-op when no events client is present
// (the local is injected root-wide by installEventsLocal). Mirrors the
// order_completed emit in api/checkout/authorize.go.
func fireEvent(c *zip.Ctx, fn func(context.Context, *events.Client)) {
client := c.Locals("events")
if client == nil {
return
}
ev, ok := client.(*events.Client)
if !ok || ev == nil {
return
}
// WithoutCancel (not Background): survive client disconnect but keep trace values.
ctx := context.WithoutCancel(c.Context())
go fn(ctx, ev)
}
// emitSale fires the two events a settled sale produces, from a CONTEXT rather
// than a request.
//
// It is the same pair the endpoint has always fired and in the same order, moved
// where the sale happens so a caller that is not a request still records it. A
// nil collector is a no-op, which is what an unwired deployment has always been.
// WithoutCancel, not Background: the emit survives a client disconnect and keeps
// the trace it belongs to.
func emitSale(ctx context.Context, ev *events.Client, orgName string, sub *subscription.Subscription, inv *billinginvoice.BillingInvoice) {
if ev == nil {
return
}
detached := context.WithoutCancel(ctx)
if sub != nil {
go ev.EmitSubscriptionCreated(detached, subscriptionEvent(orgName, sub))
}
if inv != nil {
go ev.EmitInvoicePaid(detached, invoiceEvent(orgName, inv))
}
}
// MonthlyNormalizedCents normalizes a plan price to a monthly figure by its
// billing PERIOD so plans on different cycles are comparable in one MRR sum.
// The read side never re-normalizes — the cents emitted here ARE the MRR.
//
// This is the ONE definition of that normalization. api/metrics and the
// hanzoai/cloud admin each used to carry a copy, each with a comment promising
// it mirrored this one; the copies are gone and both now read the number this
// package produces.
//
// The period is interval × intervalCount, and BOTH are required, because the
// price is charged in full once per period. billing/engine Advance moves
// a subscription on by AddDate(0, IntervalCount, 0) and the invoice carries
// Plan.Price × seats for that whole span — so month/3 at $50 is $50 every three
// months, worth $16.67 of monthly recurring revenue. This function used to take
// only the interval, which made the multiplier structurally unreachable and
// reported such a plan at $50/mo: a 3× overstatement, and ARR = MRR × 12
// carried it straight to the revenue board. A count of 0 or less reads as 1,
// exactly as the engine reads it, so a legacy row with the field unset behaves
// as the plain every-interval plan it is.
//
// It is deliberately about a price and a period only. What a whole subscription
// contributes is SubscriptionMRRCents.
func MonthlyNormalizedCents(price int64, interval string, intervalCount int) int64 {
if intervalCount < 1 {
intervalCount = 1
}
// periods per year, so one rounded division does every interval at once
var perYear int64
switch strings.ToLower(strings.TrimSpace(interval)) {
case "year", "yearly", "annual", "annually":
perYear = 1
case "week", "weekly":
perYear = 52
case "day", "daily":
perYear = 365
default: // month/monthly and anything unrecognized → treat as monthly
perYear = 12
}
return roundDiv(price*perYear, 12*int64(intervalCount))
}
// roundDiv divides num by a positive den, rounding half away from zero.
//
// Money rounds to the nearer cent; it does not truncate. Truncating loses the
// fraction on every normalized price in the same direction, so a book of
// annual and quarterly plans reports low forever and never self-corrects.
func roundDiv(num, den int64) int64 {
if num < 0 {
return -((-num + den/2) / den)
}
return (num + den/2) / den
}
// SubscriptionMRRCents is what ONE subscription contributes to MRR: its
// interval-normalized price times the seats it bills for.
//
// Seats are part of the figure, not a footnote beside it. A 10-seat plan at
// $20/seat/month is $200 of monthly recurring revenue, and a caller that
// reports the $20 has not reported this subscription's MRR — it has reported
// the price. Every surface that says "MRR" now means this.
//
// A quantity below 1 is read as 1: a subscription that exists bills for at
// least one seat, and legacy rows written before the field existed carry 0.
//
// It reports the subscription's recurring CONTRIBUTION and deliberately does
// not look at status, because two different questions hang off that. Whether
// the contribution counts toward the run-rate right now is Status.CountsTowardMRR,
// which every summation must apply. What a canceled subscription was worth is
// this number — churn is the revenue we lost, so it needs the amount even
// though the status no longer counts.
func SubscriptionMRRCents(sub *subscription.Subscription) int64 {
qty := sub.Quantity
if qty < 1 {
qty = 1
}
monthly := MonthlyNormalizedCents(int64(sub.Plan.Price), string(sub.Plan.Interval), sub.Plan.IntervalCount)
return monthly * int64(qty)
}
// planCategoryForSlug resolves a plan's mix category from the static catalog
// (the same source bundledPlansForSlug reads). Empty when unknown — honest, the
// read side buckets it under "".
func planCategoryForSlug(slug string) string {
for i := range catalog {
if catalog[i].Slug == slug {
return catalog[i].Category
}
}
return ""
}
// rfc3339 formats a time as an RFC3339 UTC string; zero → "" (honest empty).
func rfc3339(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}
// subscriptionEvent maps a subscription (+ its owning org) onto the collector
// event, resolving the plan key, category, and monthly-normalized MRR.
func subscriptionEvent(orgName string, sub *subscription.Subscription) *events.Subscription {
plan := strings.TrimSpace(sub.Plan.Slug)
if plan == "" {
plan = strings.TrimSpace(sub.PlanId)
}
interval := string(sub.Plan.Interval)
price := int64(sub.Plan.Price)
status := string(sub.Status)
trial := strings.EqualFold(strings.TrimSpace(status), "trialing")
return &events.Subscription{
ID: sub.Id(),
OrgID: orgName,
UserID: sub.UserId,
Plan: plan,
PlanName: sub.Plan.Name,
Category: planCategoryForSlug(plan),
Status: status,
Interval: interval,
PriceCents: price,
MRRCents: SubscriptionMRRCents(sub),
Seats: sub.Quantity,
Trial: trial,
PeriodStart: rfc3339(sub.PeriodStart),
PeriodEnd: rfc3339(sub.PeriodEnd),
}
}
// invoiceEvent maps an invoice (+ its owning org) onto the collector event.
func invoiceEvent(orgName string, inv *billinginvoice.BillingInvoice) *events.Invoice {
return &events.Invoice{
ID: inv.Id(),
Number: inv.NumberStr,
OrgID: orgName,
UserID: inv.UserId,
Status: string(inv.Status),
AmountCents: int64(inv.AmountDue),
AmountPaidCents: int64(inv.AmountPaid),
Currency: string(inv.Currency),
SubscriptionID: inv.SubscriptionId,
Issued: rfc3339(inv.CreatedAt),
Due: rfc3339(inv.DueDate),
}
}
// ── per-handler emit wrappers (same-package, so the call sites import nothing) ──
func emitSubscriptionCreated(c *zip.Ctx, orgName string, sub *subscription.Subscription) {
fireEvent(c, func(ctx context.Context, ev *events.Client) {
ev.EmitSubscriptionCreated(ctx, subscriptionEvent(orgName, sub))
})
}
func emitSubscriptionPlanChanged(c *zip.Ctx, orgName string, sub *subscription.Subscription) {
fireEvent(c, func(ctx context.Context, ev *events.Client) {
ev.EmitSubscriptionPlanChanged(ctx, subscriptionEvent(orgName, sub))
})
}
func emitSubscriptionCanceled(c *zip.Ctx, orgName string, sub *subscription.Subscription) {
fireEvent(c, func(ctx context.Context, ev *events.Client) {
ev.EmitSubscriptionCanceled(ctx, subscriptionEvent(orgName, sub))
})
}
func emitInvoiceFinalized(c *zip.Ctx, orgName string, inv *billinginvoice.BillingInvoice) {
fireEvent(c, func(ctx context.Context, ev *events.Client) {
ev.EmitInvoiceFinalized(ctx, invoiceEvent(orgName, inv))
})
}
func emitInvoicePaid(c *zip.Ctx, orgName string, inv *billinginvoice.BillingInvoice) {
fireEvent(c, func(ctx context.Context, ev *events.Client) {
ev.EmitInvoicePaid(ctx, invoiceEvent(orgName, inv))
})
}
func emitInvoiceVoid(c *zip.Ctx, orgName string, inv *billinginvoice.BillingInvoice) {
fireEvent(c, func(ctx context.Context, ev *events.Client) {
ev.EmitInvoiceVoid(ctx, invoiceEvent(orgName, inv))
})
}
// emitAPIUsageDebit fires the metered-usage debit. It reads the same-package
// usageRequest directly (amountCents/effMicros are the post-rounding debit).
func emitAPIUsageDebit(c *zip.Ctx, orgName string, req *usageRequest, amountCents, amountMicros int64) {
fireEvent(c, func(ctx context.Context, ev *events.Client) {
ev.EmitAPIUsageDebit(ctx, &events.APIUsage{
OrgID: orgName,
UserID: req.User,
AmountCents: amountCents,
AmountMicros: amountMicros,
Model: req.Model,
Provider: req.Provider,
Project: req.Project,
Service: req.Service,
RequestID: req.RequestID,
TotalTokens: req.TotalTokens,
Status: req.Status,
})
})
}