mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
The billing cycle takes one step per live subscription (engine.Settle) and reports every one: org, plan, interval, paid period, billed period, price, amount, source and action. Without dryRun=false it writes nothing. - A renewal bills the period after the paid one from sub.Plan, the plan as bought, on the source it was bought from: its card, or prepaid money (credits, then the balance) for a plan bought with credits or the balance. A plan collected outside Hanzo is renewed by its processor and never charged. - A decline is retried 1, 3 and 7 days after it; the subscriber keeps the tier meanwhile, and the last decline ends the subscription. - A subscription more than 72 hours past its paid period with no attempt is overdue: nothing is charged or written until its customer renews it, which bills the period from then and never the time it was not renewed. - Realignment is an operator's act with its own dry run (POST /v1/billing/realign/run[-all]): a row whose PeriodStart is its current paid invoice's PeriodEnd moves back onto that invoice's period. - Every payment on an invoice holds one lock and records its attempt before asking for money; an unknown outcome is repeated under its key, escalated to unpaid after the schedule, and resolved before any cancel or end voids it. - A cancel landing while a renewal is at the processor stays canceled; a charge that does not answer in 30 seconds is unknown; one org's cycle stops starting subscriptions after 10 minutes. - A voided or uncollectible invoice returns what it collected to prepaid money; tax is set on drafts only; a platform operator voids an unresolved attempt through POST /v1/billing/invoices/:id/void-unresolved with a reason recorded in the billing event ledger. - How a subscription was bought is read from its earliest invoice that bills a period. Co-authored-by: Hanzo Dev <[email protected]>
227 lines
6.8 KiB
Go
227 lines
6.8 KiB
Go
package billinginvoice
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/models/mixin"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
"github.com/hanzoai/commerce/util/json"
|
|
"github.com/hanzoai/commerce/util/val"
|
|
"github.com/hanzoai/orm"
|
|
|
|
. "github.com/hanzoai/commerce/types"
|
|
)
|
|
|
|
// Status represents the billing invoice lifecycle state.
|
|
type Status string
|
|
|
|
const (
|
|
Draft Status = "draft"
|
|
Open Status = "open"
|
|
Paid Status = "paid"
|
|
Void Status = "void"
|
|
Uncollectible Status = "uncollectible"
|
|
)
|
|
|
|
// LineItemType categorizes what a line item charges for.
|
|
type LineItemType string
|
|
|
|
const (
|
|
LineSubscription LineItemType = "subscription"
|
|
LineUsage LineItemType = "usage"
|
|
LineOneOff LineItemType = "one_off"
|
|
LineProration LineItemType = "proration"
|
|
)
|
|
|
|
// LineItem represents a single charge on an invoice.
|
|
type LineItem struct {
|
|
Id string `json:"id"`
|
|
Type LineItemType `json:"type"`
|
|
Description string `json:"description"`
|
|
|
|
// Usage items
|
|
MeterId string `json:"meterId,omitempty"`
|
|
Quantity int64 `json:"quantity,omitempty"`
|
|
UnitPrice int64 `json:"unitPrice,omitempty"` // cents
|
|
|
|
// Subscription items
|
|
PlanId string `json:"planId,omitempty"`
|
|
PlanName string `json:"planName,omitempty"`
|
|
|
|
Amount int64 `json:"amount"` // cents
|
|
Currency currency.Type `json:"currency"`
|
|
|
|
PeriodStart time.Time `json:"periodStart,omitempty"`
|
|
PeriodEnd time.Time `json:"periodEnd,omitempty"`
|
|
}
|
|
|
|
func init() { orm.Register[BillingInvoice]("billing-invoice") }
|
|
|
|
// BillingInvoice is a proper billing invoice with line items, status lifecycle,
|
|
// and payment tracking. This is distinct from the legacy Invoice model (Kind="payment")
|
|
// which is actually a charge/payment record.
|
|
type BillingInvoice struct {
|
|
mixin.Model[BillingInvoice]
|
|
|
|
// Customer
|
|
UserId string `json:"userId"`
|
|
CustomerEmail string `json:"customerEmail,omitempty"`
|
|
|
|
// Subscription link (empty for one-off invoices)
|
|
SubscriptionId string `json:"subscriptionId,omitempty"`
|
|
|
|
// Billing period
|
|
PeriodStart time.Time `json:"periodStart"`
|
|
PeriodEnd time.Time `json:"periodEnd"`
|
|
|
|
// Financial summary (all in cents)
|
|
Subtotal int64 `json:"subtotal"`
|
|
Tax int64 `json:"tax"`
|
|
TaxPercent float64 `json:"taxPercent"`
|
|
Discount int64 `json:"discount"`
|
|
DiscountName string `json:"discountName,omitempty"`
|
|
CreditApplied int64 `json:"creditApplied"`
|
|
AmountDue int64 `json:"amountDue"`
|
|
AmountPaid int64 `json:"amountPaid"`
|
|
|
|
Currency currency.Type `json:"currency" orm:"default:usd"`
|
|
|
|
// Status lifecycle: draft -> open -> paid | void | uncollectible
|
|
Status Status `json:"status" orm:"default:draft"`
|
|
DueDate time.Time `json:"dueDate,omitempty"`
|
|
PaidAt time.Time `json:"paidAt,omitempty"`
|
|
VoidedAt time.Time `json:"voidedAt,omitempty"`
|
|
|
|
// Payment reference
|
|
PaymentMethod string `json:"paymentMethod,omitempty"` // "balance", "stripe", "credit"
|
|
PaymentRef string `json:"paymentRef,omitempty"` // e.g. Stripe PaymentIntent ID
|
|
|
|
// Invoice number (auto-increment per org)
|
|
Number int `json:"number"`
|
|
NumberStr string `json:"numberStr"` // "INV-0042"
|
|
|
|
// Retry tracking: how many attempts declined, when the last was made and
|
|
// when the next is due.
|
|
AttemptCount int `json:"attemptCount"`
|
|
LastAttemptAt time.Time `json:"lastAttemptAt,omitempty"`
|
|
NextAttemptAt time.Time `json:"nextAttemptAt,omitempty"`
|
|
|
|
// The payment attempt in flight, written before the card or the ledger is
|
|
// asked and cleared once its outcome is known. An attempt that finds it
|
|
// repeats it exactly — the same instrument, amount and idempotency key — so
|
|
// an answer that was lost can never become a second charge. PendingRef is
|
|
// the processor's payment id when it answered without settling.
|
|
PendingMethod string `json:"pendingMethod,omitempty"`
|
|
PendingAmount int64 `json:"pendingAmount,omitempty"`
|
|
PendingKey string `json:"pendingKey,omitempty"`
|
|
PendingRef string `json:"pendingRef,omitempty"`
|
|
|
|
// Line items (JSON-serialized)
|
|
LineItems []LineItem `json:"lineItems,omitempty" datastore:"-"`
|
|
LineItems_ string `json:"-" datastore:",noindex"`
|
|
|
|
Metadata Map `json:"metadata,omitempty" datastore:"-"`
|
|
Metadata_ string `json:"-" datastore:",noindex"`
|
|
}
|
|
|
|
func (inv *BillingInvoice) Load(ps []datastore.Property) (err error) {
|
|
if err = datastore.LoadStruct(inv, ps); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(inv.LineItems_) > 0 {
|
|
if err = json.DecodeBytes([]byte(inv.LineItems_), &inv.LineItems); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if len(inv.Metadata_) > 0 {
|
|
err = json.DecodeBytes([]byte(inv.Metadata_), &inv.Metadata)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (inv *BillingInvoice) Save() (ps []datastore.Property, err error) {
|
|
inv.LineItems_ = string(json.EncodeBytes(&inv.LineItems))
|
|
inv.Metadata_ = string(json.EncodeBytes(&inv.Metadata))
|
|
return datastore.SaveStruct(inv)
|
|
}
|
|
|
|
func (inv *BillingInvoice) Validator() *val.Validator {
|
|
return nil
|
|
}
|
|
|
|
// Finalize transitions an invoice from draft to open and computes AmountDue.
|
|
func (inv *BillingInvoice) Finalize() error {
|
|
if inv.Status != Draft {
|
|
return fmt.Errorf("can only finalize draft invoices, current status: %s", inv.Status)
|
|
}
|
|
inv.Status = Open
|
|
inv.AmountDue = inv.Subtotal + inv.Tax - inv.Discount - inv.CreditApplied
|
|
if inv.AmountDue < 0 {
|
|
inv.AmountDue = 0
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkPaid marks the invoice as paid.
|
|
func (inv *BillingInvoice) MarkPaid(method, ref string) error {
|
|
if inv.Status != Open {
|
|
return fmt.Errorf("can only pay open invoices, current status: %s", inv.Status)
|
|
}
|
|
inv.Status = Paid
|
|
inv.PaidAt = time.Now()
|
|
inv.AmountPaid = inv.AmountDue
|
|
inv.PaymentMethod = method
|
|
inv.PaymentRef = ref
|
|
return nil
|
|
}
|
|
|
|
// MarkVoid voids an open invoice.
|
|
func (inv *BillingInvoice) MarkVoid() error {
|
|
if inv.Status != Open && inv.Status != Draft {
|
|
return fmt.Errorf("can only void draft or open invoices, current status: %s", inv.Status)
|
|
}
|
|
inv.Status = Void
|
|
inv.VoidedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
// MarkUncollectible marks an invoice as uncollectible after all retries.
|
|
func (inv *BillingInvoice) MarkUncollectible() error {
|
|
if inv.Status != Open {
|
|
return fmt.Errorf("can only mark open invoices as uncollectible, current status: %s", inv.Status)
|
|
}
|
|
inv.Status = Uncollectible
|
|
return nil
|
|
}
|
|
|
|
// RecalculateSubtotal sums all line item amounts.
|
|
func (inv *BillingInvoice) RecalculateSubtotal() {
|
|
var total int64
|
|
for _, li := range inv.LineItems {
|
|
total += li.Amount
|
|
}
|
|
inv.Subtotal = total
|
|
}
|
|
|
|
// SetNumber assigns the invoice number and formatted string.
|
|
func (inv *BillingInvoice) SetNumber(n int) {
|
|
inv.Number = n
|
|
inv.NumberStr = fmt.Sprintf("INV-%04d", n)
|
|
}
|
|
|
|
func New(db *datastore.Datastore) *BillingInvoice {
|
|
inv := new(BillingInvoice)
|
|
inv.Init(db)
|
|
inv.Parent = db.NewKey("synckey", "", 1, nil)
|
|
return inv
|
|
}
|
|
|
|
func Query(db *datastore.Datastore) datastore.Query {
|
|
return db.Query("billing-invoice")
|
|
}
|