mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
RecordSubscription takes processor "balance": it draws the period from the subject's prepaid money now, raises and settles that period's invoice, and opens a regular subscription the engine renews from the same money. The period must be the one running now and one of the plan's; a subject holding a plan, or one gone past due, is refused. A balance that cannot cover the period is refused with nothing written. The ledger entry and the invoice are the reference. Prepaid money is read and spent one way. The creditledger seam gains Debit, all or nothing at the address Credit and Balance already name, so with a host ledger injected the draw lands where a grant credits and the AI gate reads, and the tier reads its balance, beside the credit grants, from there too. Standalone, the draw is an "iam-user" withdrawal, the kind every deposit writes. The self-serve balance purchase, the record and every renewal draw through it, credit grants first, exactly once per ref. The split is recorded under the ref before the ledger is asked, so a debit whose answer was lost is posted again as it was and the ledger answers with the posting it holds. The collector reads the allocation first and charges the card before any prepaid money moves, so an invoice that cannot be paid in full takes nothing from credits or balance. What an attempt does collect accumulates on AmountPaid, and the draw is keyed on the subscription's period, so two invoices raised for one period draw once between them. Periods are billed in advance. A subscription holds the period it paid for; when that period ends the next one is invoiced, fee in advance and metered usage in arrears, and the row moves onto it only once it is paid. An unpaid one leaves the row past due on the period it owes; once that period has gone by unserved it is owed no longer, its invoice is void, and the row owes the period running now. A row that was served nothing until it paid, past due or late, is served a whole period from the payment. An invoice is voided only under the guard its payment takes, so a payment landing at once is never voided over. A row whose renewal was missed for whole periods is billed the period running now, never every period since. A row canceled at the end of its period ends with the last period it paid for, and an ended row's unpaid invoices are void. Invoices are read by their filters rather than under the synckey ancestor an invoice loses once it is saved again, so a paid one stays listed and found by its renewal, and a new one is numbered one past the highest issued. The billing cycle reads subscriptions the same way, renews only rows of the org's own mode, and RenewDue is the sweep as a value core a schedule can call. Claude-Session: https://claude.ai/code/session_016hTDbBTaq6r3xTQ5RAVVtF
206 lines
6.9 KiB
Go
206 lines
6.9 KiB
Go
package billing
|
|
|
|
import (
|
|
"context"
|
|
"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/organization"
|
|
"github.com/hanzoai/commerce/models/subscription"
|
|
"github.com/hanzoai/commerce/thirdparty/kms"
|
|
"github.com/hanzoai/commerce/util/json/http"
|
|
)
|
|
|
|
type cycleUserRequest struct {
|
|
UserId string `json:"userId"`
|
|
}
|
|
|
|
// RunBillingCycle processes all subscriptions whose current period has ended
|
|
// for the request's organization. It generates invoices and attempts collection
|
|
// for each due subscription.
|
|
//
|
|
// POST /v1/billing/cycle/run
|
|
func RunBillingCycle(c *zip.Ctx) error {
|
|
org := middleware.GetOrganization(c)
|
|
hydratePaymentCreds(c, org)
|
|
db := datastore.New(org.Namespaced(c.Context()))
|
|
|
|
results := renewDue(c.Context(), org, "")
|
|
|
|
// Top up each active subscriber's included monthly allotment for the
|
|
// current period (idempotent per user+month).
|
|
allotGranted, allotSkipped, _ := grantOrgAllotments(c, db, time.Now(), !org.TestMode())
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"processed": len(results),
|
|
"results": results,
|
|
"allotments": map[string]any{
|
|
"granted": allotGranted,
|
|
"skipped": allotSkipped,
|
|
},
|
|
})
|
|
}
|
|
|
|
// RunBillingCycleUser processes due subscriptions for a single user within the
|
|
// request's organization.
|
|
//
|
|
// POST /v1/billing/cycle/run-user
|
|
func RunBillingCycleUser(c *zip.Ctx) error {
|
|
org := middleware.GetOrganization(c)
|
|
hydratePaymentCreds(c, org)
|
|
|
|
var req cycleUserRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return http.Fail(c, 400, "invalid request body", err)
|
|
}
|
|
|
|
if req.UserId == "" {
|
|
return http.Fail(c, 400, "userId is required", nil)
|
|
}
|
|
|
|
results := renewDue(c.Context(), org, req.UserId)
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"user": req.UserId,
|
|
"processed": len(results),
|
|
"results": results,
|
|
})
|
|
}
|
|
|
|
// RunBillingCycleAllOrgs iterates every organization and processes due
|
|
// subscriptions across all of them, then grants each org's included allotments.
|
|
//
|
|
// POST /v1/billing/cycle/run-all
|
|
func RunBillingCycleAllOrgs(c *zip.Ctx) error {
|
|
run, err := RenewDue(c.Context(), kmsOf(c))
|
|
if err != nil {
|
|
log.Error("Failed to list organizations for billing cycle: %v", err, c)
|
|
return http.Fail(c, 500, "failed to list organizations", err)
|
|
}
|
|
|
|
rootDb := datastore.New(c.Context())
|
|
orgs := make([]*organization.Organization, 0)
|
|
if _, err := organization.Query(rootDb).GetAll(&orgs); err == nil {
|
|
now := time.Now()
|
|
for _, org := range orgs {
|
|
grantOrgAllotments(c, datastore.New(org.Namespaced(c.Context())), now, !org.TestMode())
|
|
}
|
|
}
|
|
|
|
return c.JSON(200, run)
|
|
}
|
|
|
|
// Renewal is what renewing one due subscription did.
|
|
type Renewal struct {
|
|
OrgName string `json:"orgName"`
|
|
UserId string `json:"userId"`
|
|
SubscriptionId string `json:"subscriptionId"`
|
|
InvoiceId string `json:"invoiceId,omitempty"`
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// RenewalRun is one sweep: how many organizations were considered, how many
|
|
// subscriptions were due, and what renewing each one did.
|
|
type RenewalRun struct {
|
|
Orgs int `json:"orgs"`
|
|
Due int `json:"due"`
|
|
Renewed int `json:"renewed"`
|
|
Results []Renewal `json:"results"`
|
|
}
|
|
|
|
// RenewDue renews every due subscription in every organization: it invoices the
|
|
// period that ended and collects it through the one waterfall — prepaid money
|
|
// first, the card on file for the rest — moving the row on when it is paid and
|
|
// leaving it past due when it is not.
|
|
//
|
|
// It takes values because the caller is a SCHEDULE, not a person. A failure to
|
|
// collect one subscription is that subscription's result, not the sweep's: the
|
|
// loop continues. Only the population read failing ends the run.
|
|
func RenewDue(ctx context.Context, kmsClient *kms.CachedClient) (*RenewalRun, error) {
|
|
orgs := make([]*organization.Organization, 0)
|
|
if _, err := organization.Query(datastore.New(ctx)).GetAll(&orgs); err != nil {
|
|
return nil, err
|
|
}
|
|
run := &RenewalRun{Orgs: len(orgs), Results: make([]Renewal, 0)}
|
|
for _, org := range orgs {
|
|
// Hydrate each org's payment creds so a due renewal can charge its
|
|
// subscribers' vaulted cards through that org's own processor.
|
|
if kmsClient != nil {
|
|
if err := kms.Hydrate(kmsClient, org); err != nil {
|
|
log.Error("KMS hydration failed for org %q during renewal: %v", org.Name, err)
|
|
}
|
|
}
|
|
for _, r := range renewDue(ctx, org, "") {
|
|
run.Due++
|
|
if r.Success {
|
|
run.Renewed++
|
|
}
|
|
run.Results = append(run.Results, r)
|
|
}
|
|
}
|
|
return run, nil
|
|
}
|
|
|
|
// renewDue renews org's due subscriptions — one subject's when user is set. A row
|
|
// the sweep missed for whole periods is billed the period running now, never every
|
|
// period since (engine.RenewSubscription).
|
|
func renewDue(ctx context.Context, org *organization.Organization, user string) []Renewal {
|
|
// Subscriptions are stored ancestor-less, so they are read the way every
|
|
// other reader reads them. An Ancestor(synckey) filter matches none of them,
|
|
// which left this sweep renewing nothing.
|
|
subs, err := ListSubscriptions(ctx, org, user, "")
|
|
if err != nil {
|
|
log.Error("Failed to query subscriptions for renewal in org %q: %v", org.Name, err)
|
|
return nil
|
|
}
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
|
|
now := time.Now()
|
|
pre, charge := prepaidFor(ctx, org), chargeProviderForOrg(org)
|
|
out := make([]Renewal, 0)
|
|
for _, sub := range subs {
|
|
// A row renews from the books of its own mode, and the org's prepaid money
|
|
// is the org's mode: a sandbox row is never paid from live money, nor a
|
|
// live row from sandbox money.
|
|
if sub.Test != org.TestMode() || !engine.IsDue(sub, now) {
|
|
continue
|
|
}
|
|
out = append(out, renewOne(ctx, db, org.Name, sub, pre, charge))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// renewOne invoices and collects one subscription's due period, then persists
|
|
// the row as the collection left it.
|
|
func renewOne(ctx context.Context, db *datastore.Datastore, orgName string, sub *subscription.Subscription, pre engine.Prepaid, charge engine.ProviderCharger) Renewal {
|
|
// A row read by query is not bound to the store, so the write goes through a
|
|
// fresh read of the same id.
|
|
row := subscription.New(db)
|
|
if err := row.GetById(sub.Id()); err != nil {
|
|
return Renewal{OrgName: orgName, UserId: sub.UserId, SubscriptionId: sub.Id(), Error: err.Error()}
|
|
}
|
|
r := Renewal{OrgName: orgName, UserId: row.UserId, SubscriptionId: row.Id()}
|
|
inv, result, err := engine.RenewSubscription(ctx, db, row, pre, charge)
|
|
if inv != nil {
|
|
r.InvoiceId = inv.Id()
|
|
}
|
|
if err != nil {
|
|
log.Error("Billing cycle: failed to renew subscription %s: %v", row.Id(), err)
|
|
r.Error = err.Error()
|
|
return r
|
|
}
|
|
if err := row.Update(); err != nil {
|
|
log.Error("Billing cycle: failed to update subscription %s after renewal: %v", row.Id(), err)
|
|
r.Error = err.Error()
|
|
return r
|
|
}
|
|
r.Success, r.Error = result.Success, result.Error
|
|
return r
|
|
}
|