Files
zooqueenandHanzo Dev ca7fd95c5c A dispute moves money only while Square withholds the funds, every dispute report is ordered by the dispute's version, and Square's dispute.state.updated is read as dispute.state.changed was
dispute.state.updated and dispute.state.changed both normalize to dispute.updated
and both are clawback events. A dispute report carries the dispute's version and
updated_at, and dispute.Report (embedded in the invoice and in the wallet payment's
record, models/paymentorg) keeps the latest: a report older by version, else by
updated_at, else by phase (inquiry, chargeback, decided), changes nothing, and WON
stays final.

An inquiry holds no funds: it takes nothing from a wallet, and on an invoice it is
recorded while the period stays paid. A chargeback (EVIDENCE_REQUIRED, PROCESSING),
or one lost or accepted, takes a wallet payment back once and unpays a period; a
chargeback won gives the wallet payment back and restores the period.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-01 17:42:55 -07:00

251 lines
7.3 KiB
Go

package billinginvoice
import (
"fmt"
"time"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/dispute"
"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
// Refunds is each refund the processor gave back against the payment that
// paid this invoice, in cents, keyed by the processor's refund id, so a refund
// reported twice counts once.
Refunds map[string]int64 `json:"refunds,omitempty"`
// Report is a dispute against the payment that paid this invoice, as the
// processor last reported it, ordered by the dispute's version.
dispute.Report
// Invoice number (auto-increment per org)
Number int `json:"number"`
NumberStr string `json:"numberStr"` // "INV-0042"
// Dunning retry tracking
AttemptCount int `json:"attemptCount"`
LastAttemptAt time.Time `json:"lastAttemptAt,omitempty"`
NextAttemptAt time.Time `json:"nextAttemptAt,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
}
// Refund records amount given back under ref, once: a ref already recorded changes
// nothing. It reports whether it recorded anything.
func (inv *BillingInvoice) Refund(ref string, amount int64) bool {
if ref == "" || amount <= 0 {
return false
}
if _, ok := inv.Refunds[ref]; ok {
return false
}
if inv.Refunds == nil {
inv.Refunds = map[string]int64{}
}
inv.Refunds[ref] = amount
return true
}
// Refunded is what has been given back against the invoice's payment, never more
// than was paid.
func (inv *BillingInvoice) Refunded() int64 {
var total int64
for _, cents := range inv.Refunds {
total += cents
}
return min(total, inv.AmountPaid)
}
// 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")
}