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
208 lines
6.0 KiB
Go
208 lines
6.0 KiB
Go
package workflows
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/hanzoai/commerce/billing/engine"
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/models/billinginvoice"
|
|
"github.com/hanzoai/commerce/models/plan"
|
|
"github.com/hanzoai/commerce/models/subscription"
|
|
)
|
|
|
|
// Activity parameter and result types
|
|
|
|
type TransitionParams struct {
|
|
OrgName string `json:"orgName"`
|
|
SubscriptionId string `json:"subscriptionId"`
|
|
NewStatus string `json:"newStatus"`
|
|
}
|
|
|
|
type CancelParams struct {
|
|
OrgName string `json:"orgName"`
|
|
SubscriptionId string `json:"subscriptionId"`
|
|
AtPeriodEnd bool `json:"atPeriodEnd"`
|
|
}
|
|
|
|
type ChangePlanParams struct {
|
|
OrgName string `json:"orgName"`
|
|
SubscriptionId string `json:"subscriptionId"`
|
|
NewPlanId string `json:"newPlanId"`
|
|
Prorate bool `json:"prorate"`
|
|
}
|
|
|
|
type RenewalParams struct {
|
|
OrgName string `json:"orgName"`
|
|
SubscriptionId string `json:"subscriptionId"`
|
|
}
|
|
|
|
type RenewalResult struct {
|
|
Success bool `json:"success"`
|
|
InvoiceId string `json:"invoiceId"`
|
|
NextPeriodEnd time.Time `json:"nextPeriodEnd"`
|
|
}
|
|
|
|
type CollectInvoiceParams struct {
|
|
OrgName string `json:"orgName"`
|
|
InvoiceId string `json:"invoiceId"`
|
|
}
|
|
|
|
type CollectionActivityResult struct {
|
|
Success bool `json:"success"`
|
|
}
|
|
|
|
type MarkUncollectibleParams struct {
|
|
OrgName string `json:"orgName"`
|
|
InvoiceId string `json:"invoiceId"`
|
|
}
|
|
|
|
// BillingActivities holds dependencies for activity implementations.
|
|
type BillingActivities struct {
|
|
// Prepaid resolves an org's prepaid money — credits, then the balance on the
|
|
// one ledger — injected from the billing package to avoid circular imports.
|
|
// nil collects from the card alone.
|
|
Prepaid func(ctx context.Context, orgName string) engine.Prepaid
|
|
// ChargeProvider charges the subscription's vaulted card for whatever prepaid
|
|
// money did not cover (injected like Prepaid to avoid import cycles). nil
|
|
// keeps the prepaid-only waterfall — the workflow driver leaves it unset
|
|
// unless it can build a KMS-hydrated per-org charger.
|
|
ChargeProvider engine.ProviderCharger
|
|
}
|
|
|
|
// TransitionSubscriptionActivity updates a subscription's status.
|
|
func (a *BillingActivities) TransitionSubscriptionActivity(ctx context.Context, params TransitionParams) error {
|
|
db := orgDB(ctx, params.OrgName)
|
|
|
|
sub := subscription.New(db)
|
|
if err := sub.GetById(params.SubscriptionId); err != nil {
|
|
return fmt.Errorf("subscription not found: %w", err)
|
|
}
|
|
|
|
sub.Status = subscription.Status(params.NewStatus)
|
|
return sub.Update()
|
|
}
|
|
|
|
// CancelSubscriptionActivity cancels a subscription.
|
|
func (a *BillingActivities) CancelSubscriptionActivity(ctx context.Context, params CancelParams) error {
|
|
db := orgDB(ctx, params.OrgName)
|
|
|
|
sub := subscription.New(db)
|
|
if err := sub.GetById(params.SubscriptionId); err != nil {
|
|
return fmt.Errorf("subscription not found: %w", err)
|
|
}
|
|
|
|
if err := engine.CancelSubscription(sub, params.AtPeriodEnd); err != nil {
|
|
return err
|
|
}
|
|
|
|
return sub.Update()
|
|
}
|
|
|
|
// ChangePlanActivity changes a subscription's plan.
|
|
func (a *BillingActivities) ChangePlanActivity(ctx context.Context, params ChangePlanParams) error {
|
|
db := orgDB(ctx, params.OrgName)
|
|
|
|
sub := subscription.New(db)
|
|
if err := sub.GetById(params.SubscriptionId); err != nil {
|
|
return fmt.Errorf("subscription not found: %w", err)
|
|
}
|
|
|
|
plan := newPlan(db)
|
|
if err := plan.GetById(params.NewPlanId); err != nil {
|
|
return fmt.Errorf("plan not found: %w", err)
|
|
}
|
|
|
|
_, err := engine.ChangePlan(sub, plan, params.Prorate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return sub.Update()
|
|
}
|
|
|
|
// RenewSubscriptionActivity generates an invoice and attempts collection.
|
|
func (a *BillingActivities) RenewSubscriptionActivity(ctx context.Context, params RenewalParams) (*RenewalResult, error) {
|
|
db := orgDB(ctx, params.OrgName)
|
|
|
|
sub := subscription.New(db)
|
|
if err := sub.GetById(params.SubscriptionId); err != nil {
|
|
return nil, fmt.Errorf("subscription not found: %w", err)
|
|
}
|
|
|
|
inv, result, err := engine.RenewSubscription(ctx, db, sub, a.prepaid(ctx, params.OrgName), a.ChargeProvider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := sub.Update(); err != nil {
|
|
return nil, fmt.Errorf("failed to update subscription: %w", err)
|
|
}
|
|
|
|
return &RenewalResult{
|
|
Success: result.Success,
|
|
InvoiceId: inv.Id(),
|
|
NextPeriodEnd: sub.PeriodEnd,
|
|
}, nil
|
|
}
|
|
|
|
// CollectInvoiceActivity attempts to collect payment on an invoice.
|
|
func (a *BillingActivities) CollectInvoiceActivity(ctx context.Context, params CollectInvoiceParams) (*CollectionActivityResult, error) {
|
|
db := orgDB(ctx, params.OrgName)
|
|
|
|
inv := billinginvoice.New(db)
|
|
if err := inv.GetById(params.InvoiceId); err != nil {
|
|
return nil, fmt.Errorf("invoice not found: %w", err)
|
|
}
|
|
|
|
result, err := engine.CollectInvoice(ctx, db, inv, a.prepaid(ctx, params.OrgName), a.ChargeProvider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := inv.Update(); err != nil {
|
|
return nil, fmt.Errorf("failed to update invoice: %w", err)
|
|
}
|
|
|
|
return &CollectionActivityResult{Success: result.Success}, nil
|
|
}
|
|
|
|
// MarkUncollectibleActivity marks an invoice as uncollectible.
|
|
func (a *BillingActivities) MarkUncollectibleActivity(ctx context.Context, params MarkUncollectibleParams) error {
|
|
db := orgDB(ctx, params.OrgName)
|
|
|
|
inv := billinginvoice.New(db)
|
|
if err := inv.GetById(params.InvoiceId); err != nil {
|
|
return fmt.Errorf("invoice not found: %w", err)
|
|
}
|
|
|
|
if err := inv.MarkUncollectible(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return inv.Update()
|
|
}
|
|
|
|
// prepaid is the org's prepaid money, or nil when none was injected.
|
|
func (a *BillingActivities) prepaid(ctx context.Context, orgName string) engine.Prepaid {
|
|
if a.Prepaid == nil {
|
|
return nil
|
|
}
|
|
return a.Prepaid(ctx, orgName)
|
|
}
|
|
|
|
// orgDB creates a datastore scoped to an org namespace.
|
|
func orgDB(ctx context.Context, orgName string) *datastore.Datastore {
|
|
db := datastore.New(ctx)
|
|
if orgName != "" {
|
|
db.SetNamespace(orgName)
|
|
}
|
|
return db
|
|
}
|
|
|
|
// newPlan creates a new plan model for lookup.
|
|
func newPlan(db *datastore.Datastore) *plan.Plan {
|
|
return plan.New(db)
|
|
}
|