Files
zooqueenandHanzo Dev 6fe76257bb Realignment is a one-time migration refused per org, a canceled customer's lost charge is looked up instead of sent again, and seats follow their team
The realignment records a cutover per org, on its first real run or the first
live cycle that finds nothing to move; only invoices issued before it make a
candidate, a realigned row is marked and never moved again, and comped rows are
never candidates. A live cycle skips only the org with rows awaiting it, and a
customer's renew waits for it too. The dry run names the rows the move lapses.

A canceled subscription's card attempt of unknown outcome is looked up by its
payment id, or cancelled by its idempotency key, and settled on the answer;
nothing asks for money again. A prepaid one goes straight back.

Seats are matched to their parent team, a replaced team's seats end with it,
and a seat's tier is read from its parent row. Replacing a lapsed plan voids
only invoices that collected nothing; money one collected toward a period it is
never served goes back through the cycle. Unpaid rows lapse like past_due ones.
Lapsed rows emit subscription_lapsed once and drop out of MRR, retired rows
emit subscription_canceled, and a late outside payment after a card rebuy is
recorded for a refund review.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 16:57:24 -07:00

211 lines
7.6 KiB
Go

package billing
import (
"context"
"errors"
"fmt"
"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/idempotencykey"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/util/json/http"
)
// RealignResult is one subscription the realignment moved back onto the period
// its paid invoice covers, or in a dry run would move. Lapses says the move
// leaves it past that period and the grace after it (engine.Lapsed): it will
// confer nothing until its customer renews it or subscribes again.
type RealignResult struct {
Org string `json:"org"`
SubscriptionId string `json:"subscriptionId"`
UserId string `json:"userId"`
Plan string `json:"plan"`
Interval string `json:"interval"`
InvoiceId string `json:"invoiceId"`
FromStart time.Time `json:"fromStart"`
FromEnd time.Time `json:"fromEnd"`
ToStart time.Time `json:"toStart"`
ToEnd time.Time `json:"toEnd"`
Lapses bool `json:"lapses"`
}
// RealignReport is one realignment run: every subscription it moved, or in a
// dry run would move, out of the live ones it considered.
type RealignReport struct {
DryRun bool `json:"dryRun"`
Orgs int `json:"orgs"`
Considered int `json:"considered"`
Realigned int `json:"realigned"`
Lapsing int `json:"lapsing"`
Results []RealignResult `json:"results"`
Errors []string `json:"errors,omitempty"`
}
// Realign is the one-time migration that corrects the dates of the
// subscriptions in orgs that sit a period ahead of the invoice that paid for
// them (engine.Realign): each moves back onto that invoice's period, is renewed
// when that period ends, and is marked so it is never moved again. It is an
// operator's act, run on its own and never as part of reading or renewing a
// subscription. With dryRun every move is reported and nothing is written.
//
// A real run over an org, or the first live cycle there that finds nothing to
// realign, records when it ran (realignCutover), and from then on no invoice
// issued after it makes a row a candidate. A row that is never
// charged (comped) is never a candidate. Each org's run holds the org's cycle
// lock, so it never overlaps a cycle run of the same org in this process.
func Realign(ctx context.Context, orgs []*organization.Organization, dryRun bool) *RealignReport {
report := &RealignReport{DryRun: dryRun, Orgs: len(orgs), Results: make([]RealignResult, 0)}
for _, org := range orgs {
realignOrg(ctx, org, dryRun, report)
}
return report
}
func realignOrg(ctx context.Context, org *organization.Organization, dryRun bool, report *RealignReport) {
defer lockOrgCycle(org.Name)()
fail := func(what string, err error) {
log.Error("realign: %s in %q: %v", what, org.Name, err)
report.Errors = append(report.Errors, fmt.Sprintf("%s: %s: %v", org.Name, what, err))
}
db := datastore.New(org.Namespaced(ctx))
cutover, err := realignCutover(db)
if err != nil {
fail("read the realignment's cutover", err)
return
}
subs, err := orgSubscriptions(db, "")
if err != nil {
fail("list subscriptions", err)
return
}
now, failed := time.Now(), false
for _, s := range subs {
if isBundleRow(s) || !engine.Live(s) {
continue
}
report.Considered++
if comped, err := compedRow(org, db, s); err != nil || comped {
if err != nil {
fail("subscription "+s.Id(), err)
failed = true
}
continue
}
r, err := engine.Realign(db, s, cutover, dryRun)
if err != nil {
fail("subscription "+s.Id(), err)
failed = true
continue
}
if r == nil {
continue
}
moved := *s
moved.PeriodStart, moved.PeriodEnd = r.ToStart, r.ToEnd
lapses := engine.Lapsed(&moved, now)
report.Realigned++
if lapses {
report.Lapsing++
}
report.Results = append(report.Results, RealignResult{
Org: org.Name, SubscriptionId: s.Id(), UserId: s.UserId, Plan: planOf(s), Interval: string(s.Plan.Interval),
InvoiceId: r.InvoiceId, FromStart: r.FromStart, FromEnd: r.FromEnd, ToStart: r.ToStart, ToEnd: r.ToEnd, Lapses: lapses,
})
}
if !dryRun && !failed && cutover.IsZero() {
if err := markRealigned(db, now); err != nil {
fail("record the realignment's cutover", err)
}
}
}
// compedRow reports whether s is never charged (renewalOf answers comped): an
// ecosystem org's own plan provisioned on enterprise terms. Its period is moved
// on by the cycle, never by a charge, so it is never a realignment candidate.
func compedRow(org *organization.Organization, db *datastore.Datastore, s *subscription.Subscription) (bool, error) {
_, source, err := renewalOf(org, db, s, rail{}, nil)
return source == sourceComped, err
}
// awaitingRealign reports whether s is a realignment candidate in org: not a
// seat, live, not comped, and matching engine.RealignPending under the org's
// cutover.
func awaitingRealign(org *organization.Organization, db *datastore.Datastore, s *subscription.Subscription, cutover time.Time) (bool, error) {
if isBundleRow(s) || !engine.Live(s) {
return false, nil
}
if comped, err := compedRow(org, db, s); err != nil || comped {
return false, err
}
return engine.RealignPending(db, s, cutover)
}
// The org's realignment record: when its cutover was recorded.
const (
realignScope = "billing-realign"
realignKey = "cutover"
)
// realignCutover is when the org's realignment first ran for real, or a live
// cycle first found nothing there to realign; zero before either.
func realignCutover(db *datastore.Datastore) (time.Time, error) {
rec := idempotencykey.New(db)
err := rec.Get(db.NewKey(rec.Kind(), idempotencykey.DeterministicID(realignScope, realignKey), 0, nil))
switch {
case errors.Is(err, datastore.ErrNoSuchEntity):
return time.Time{}, nil
case err != nil:
return time.Time{}, err
case rec.Status != idempotencykey.StatusCompleted:
return time.Time{}, nil
}
return time.Parse(time.RFC3339Nano, rec.Response)
}
// markRealigned records the org's cutover at now, once.
func markRealigned(db *datastore.Datastore, now time.Time) error {
rec, replay, err := idempotencykey.Begin(db, realignScope, realignKey)
if err != nil || (replay && rec.Status == idempotencykey.StatusCompleted) {
return err
}
return idempotencykey.Complete(rec, now.UTC().Format(time.RFC3339Nano))
}
// RealignSubscriptions runs the realignment for the request's organization: a
// dry run unless the request says dryRun=false.
//
// POST /v1/billing/realign/run[?dryRun=false]
func RealignSubscriptions(c *zip.Ctx) error {
dryRun, err := dryRunOf(c)
if err != nil {
return http.Fail(c, 400, err.Error(), nil)
}
org := middleware.GetOrganization(c)
return c.JSON(200, Realign(context.WithoutCancel(c.Context()), []*organization.Organization{org}, dryRun))
}
// RealignSubscriptionsAllOrgs runs the realignment for every organization: a dry
// run unless the request says dryRun=false.
//
// POST /v1/billing/realign/run-all[?dryRun=false]
func RealignSubscriptionsAllOrgs(c *zip.Ctx) error {
dryRun, err := dryRunOf(c)
if err != nil {
return http.Fail(c, 400, err.Error(), nil)
}
ctx := context.WithoutCancel(c.Context())
orgs := make([]*organization.Organization, 0)
if _, err := organization.Query(datastore.New(ctx)).GetAll(&orgs); err != nil {
log.Error("Failed to list organizations for realignment: %v", err, c)
return http.Fail(c, 500, "failed to list organizations", err)
}
return c.JSON(200, Realign(ctx, orgs, dryRun))
}