mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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]>
132 lines
5.2 KiB
Go
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) != ""
|
|
}
|