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

132 lines
5.2 KiB
Go

package billing
import (
"context"
"errors"
"strings"
"github.com/hanzoai/commerce/billing/engine"
gift "github.com/hanzoai/commerce/billing/grant"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/billinginvoice"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/models/paymentmethod"
"github.com/hanzoai/commerce/models/subscription"
)
// What renews a subscription, as the cycle report names it: the source it was
// bought from.
const (
// sourceCard: the subscription's own saved card.
sourceCard = "card"
// sourcePrepaid: the subscriber's prepaid money, credit grants then the
// balance (prepaid.go), for a plan bought with credits or the balance.
sourcePrepaid = "prepaid"
// sourceExternal: a processor outside Hanzo. Nothing here charges it; its
// next period is recorded when that payment arrives (RecordSubscription).
sourceExternal = "external"
// sourceGift: nobody pays; it ends when the time it was granted for does.
sourceGift = "gift"
// sourceComped: never charged, and moved on period by period.
sourceComped = "comped"
// sourceNone: nothing on file pays it; it ends at its period end.
sourceNone = "none"
)
// renewalOf is how sub's renewals are paid: from the source it was bought
// from, read off what the row and its first invoice record, in every org. The
// first match wins:
//
// 1. sub.ProviderType is gift.ProviderType (billing/grant): never charged, and it
// ends when the time it was granted for does — its PeriodEnd, which
// grant.Grant sets from the grant's duration.
// 2. sub.Type is subscription.External: the processor it was bought through
// renews it outside Hanzo, and the engine never finds it due (IsDue).
// 3. The first invoice's PaymentMethod: "card" pays by the subscription's
// card; "credit", "balance" or "prepaid" pays from the subscriber's prepaid
// money. A "credit" first invoice on a paid ecosystem org's own account is
// how a plan provisioned on enterprise terms was recorded, and it is comped.
// 4. No first invoice: sub.ProviderType "credit" or DefaultPaymentMethod
// "credits" or "balance" is 3's prepaid; a card in DefaultPaymentMethod pays
// by the card; a plan with no fee pays its invoice's metered usage, if any,
// from prepaid money; an ecosystem org's own account is comped; anything
// else records no payment and ends at its period end.
//
// A card is only ever the subscription's own DefaultPaymentMethod, never another
// card the subscriber saved. card is the org's card rail and pre the org's
// prepaid money.
func renewalOf(org *organization.Organization, db *datastore.Datastore, sub *subscription.Subscription, card rail, pre engine.Prepaid) (engine.Collection, string, error) {
if sub.ProviderType == gift.ProviderType {
return engine.Collection{End: engine.GiftEnded, Reason: "the gift ran the time it was granted for"}, sourceGift, nil
}
if sub.Type == subscription.External {
return engine.Collection{Reason: "collected outside Hanzo; its next period is recorded when that payment arrives"}, sourceExternal, nil
}
eco := IsEcosystemAccount(org, sub.UserId)
comped := engine.Collection{Comped: true, Reason: "provisioned on an ecosystem org's enterprise terms"}
byCard := engine.CardPayer(card.charge)
byCard.Look = card.look
// A card-bought subscription whose card is gone is a declined attempt, and a
// dry run reports it so.
byCard.Choose = func(_ context.Context, db *datastore.Datastore, _ *billinginvoice.BillingInvoice, _ int64) (string, error) {
if !hasCard(db, sub) {
return "", errNoCard
}
return engine.PaidByCard, nil
}
byPrepaid := engine.PrepaidPayer(pre)
carded := hasCard(db, sub)
first, err := engine.FirstInvoice(db, sub)
if err != nil {
return engine.Collection{}, "", err
}
method := ""
if first != nil {
method = first.PaymentMethod
}
switch {
case method != "":
case sub.ProviderType == "credit" || sub.DefaultPaymentMethod == "credits" || sub.DefaultPaymentMethod == "balance":
method = "credit"
}
switch method {
case engine.PaidByCard:
return byCard, sourceCard, nil
case "credit":
if eco {
return comped, sourceComped, nil
}
return byPrepaid, sourcePrepaid, nil
case "balance", engine.PaidByPrepaid:
return byPrepaid, sourcePrepaid, nil
}
switch {
case carded:
return byCard, sourceCard, nil
case method == "" && sub.Plan.Price <= 0:
return byPrepaid, sourcePrepaid, nil
case method == "" && eco:
return comped, sourceComped, nil
}
return engine.Collection{Reason: "nothing on file pays for it: no card and no recorded way it was bought"}, sourceNone, nil
}
// errNoCard is a card renewal with no card on file to charge.
var errNoCard = errors.New("no card on file: the subscription's default payment method is not a saved card")
// hasCard reports whether the subscription's DefaultPaymentMethod names a
// vaulted card.
func hasCard(db *datastore.Datastore, sub *subscription.Subscription) bool {
id := strings.TrimSpace(sub.DefaultPaymentMethod)
if id == "" || id == "credits" || id == "balance" {
return false
}
pm := paymentmethod.New(db)
if err := pm.GetById(id); err != nil {
return false
}
return strings.TrimSpace(pm.ProviderRef) != ""
}