invoice

package
v1.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 3, 2026 License: MIT Imports: 9 Imported by: 1

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrInvalidTiers = errors.New("invoice: invalid price tiers")

ErrInvalidTiers is wrapped by every ValidateTiers failure.

Functions

func ComputeOverage

func ComputeOverage(tiers []plan.PriceTier, usage, included int64, currency string) (types.Money, error)

ComputeOverage prices the billable excess of usage over an included allowance, using whichever tier model the plan declares.

currency is normalised to lowercase once, here, at entry. Every Money this function and its helpers construct downstream uses that normalised value, so a caller passing "USD" gets an amount back in "usd" rather than hitting the currency-mismatch panic in types.Money.Add/Subtract.

UpTo counts TOTAL period usage, not units past the allowance: a plan that prices its allowance as a $0 first tier (as this package's README example does) needs `usage`, not `usage - included`, to land in the right tier, or the allowance would be priced twice. Concretely:

  • graduated: price(usage) - price(included). Each unit is charged at the rate of the tier position it occupies in the ladder; billing only the delta between the two totals prices the included units once, at graduated's per-position rate, not again on top of it.
  • volume: the tier reached by TOTAL usage sets the rate, applied to usage - included units.
  • flat: max(0, fee(usage) - fee(included)), where fee(q) is the FlatAmount of the tier reached by total quantity q. Charging the whole band fee for any usage past included would re-bill the band the allowance already sat in: included 3000 with usage 3001 must not bill a fresh fee for one unit inside a band already paid for.

A ladder with no unbounded tier does not stop pricing at its highest UpTo: the last tier after sorting extends to cover everything above it (see computeGraduated, computeVolume, computeFlat, and tierAt).

Every product and sum is checked. When one overflows an int64 the result is an error wrapping types.ErrOverflow and a zero Money, never a wrapped amount: a plugin aggregator reporting MaxInt64/2 units at 3c used to price as a huge negative overage.

included < 0 means an unlimited allowance, matching plan.Feature.Limit == -1, and prices at zero regardless of usage. usage <= included also prices at zero.

An empty tier slice prices at zero: a metered feature whose plan declares no tiers is a gap in the catalogue, not a billing failure. Callers must call ValidateTiers before ComputeOverage, which reports only arithmetic overflow and prices an invalid ladder by best effort; the default branch below, for a tier type ValidateTiers would have rejected, also prices at zero, but ValidateTiers is what is meant to keep callers from reaching it.

func SortTiers

func SortTiers(tiers []plan.PriceTier) []plan.PriceTier

SortTiers returns tiers ordered for evaluation: by UpTo ascending, with any UpTo <= 0 (0 in this package's own tests, -1 in the documented schema) treated as unbounded and sorted last, and Priority breaking ties.

The input is not mutated. Callers hold tiers that belong to a stored plan, and reordering that slice in place would rewrite the plan's own view of its pricing.

func ValidateTiers

func ValidateTiers(tiers []plan.PriceTier, currency string) error

ValidateTiers reports whether one feature's tiers can be priced unambiguously in the given currency. Callers must validate before ComputeOverage, which prices an invalid ladder by best effort and reports only arithmetic overflow.

Every returned error names the offending tier by its index in the slice and its UpTo, not only its FeatureKey: a plan with several tiers sharing one feature key would otherwise leave the caller guessing which tier is bad.

An empty slice is valid: a metered feature with no tiers yet is a catalogue gap for ComputeOverage to price at zero, not a validation failure. Otherwise ValidateTiers rejects a ladder where:

  1. the tiers do not all share one Type;
  2. any Type is empty or not one of graduated, volume, or flat;
  3. any tier's UnitAmount.Currency or FlatAmount.Currency is non-empty and differs from currency, case-insensitively;
  4. the tiers carry more than one distinct FeatureKey;
  5. two tiers have the same UpTo, treating every UpTo <= 0 as the same unbounded value (so a 0 and a -1 together are a duplicate);
  6. a graduated or volume tier has a non-zero FlatAmount, or a flat tier has a non-zero UnitAmount;
  7. any tier's UnitAmount.Amount or FlatAmount.Amount is negative. A negative rate would quietly reduce an invoice instead of erroring.

Types

type Invoice

type Invoice struct {
	types.Entity
	ID             id.InvoiceID      `json:"id"`
	TenantID       string            `json:"tenant_id"`
	SubscriptionID id.SubscriptionID `json:"subscription_id"`
	Status         Status            `json:"status"`
	Currency       string            `json:"currency"`
	Subtotal       types.Money       `json:"subtotal"`
	TaxAmount      types.Money       `json:"tax_amount"`
	DiscountAmount types.Money       `json:"discount_amount"`
	Total          types.Money       `json:"total"`
	LineItems      []LineItem        `json:"line_items"`
	PeriodStart    time.Time         `json:"period_start"`
	PeriodEnd      time.Time         `json:"period_end"`
	DueDate        *time.Time        `json:"due_date,omitempty"`
	PaidAt         *time.Time        `json:"paid_at,omitempty"`
	VoidedAt       *time.Time        `json:"voided_at,omitempty"`
	VoidReason     string            `json:"void_reason,omitempty"`
	PaymentRef     string            `json:"payment_ref,omitempty"`
	ProviderID     string            `json:"provider_id,omitempty"`
	ProviderName   string            `json:"provider_name,omitempty"`
	AppID          string            `json:"app_id"`
	Metadata       map[string]string `json:"metadata,omitempty"`
}

Invoice is a bill for one subscription period.

LineItems sum to Subtotal, minus DiscountAmount, plus TaxAmount. That sum equals Total, except when the discount exceeds the subtotal: the net amount then clamps at zero before tax is added, rather than letting Total go negative.

type LineItem

type LineItem struct {
	ID          id.LineItemID     `json:"id"`
	InvoiceID   id.InvoiceID      `json:"invoice_id"`
	FeatureKey  string            `json:"feature_key,omitempty"`
	Description string            `json:"description"`
	Quantity    int64             `json:"quantity"`
	UnitAmount  types.Money       `json:"unit_amount"`
	Amount      types.Money       `json:"amount"`
	Type        LineItemType      `json:"type"`
	Metadata    map[string]string `json:"metadata,omitempty"`
}

LineItem is one priced component of an Invoice.

A discount line item carries a negative Amount (and UnitAmount), while the invoice's own DiscountAmount is always recorded positive. A line item priced from a tier ladder, such as usage overage or seat charges, carries a zero UnitAmount: a ladder has no single per-unit price, so only Amount, the total charged, means anything for it.

type LineItemType

type LineItemType string
const (
	LineItemBase     LineItemType = "base"
	LineItemUsage    LineItemType = "usage"
	LineItemOverage  LineItemType = "overage"
	LineItemSeat     LineItemType = "seat"
	LineItemDiscount LineItemType = "discount"
	LineItemTax      LineItemType = "tax"
)

type ListOpts

type ListOpts struct {
	Status Status
	Start  time.Time
	End    time.Time
	Limit  int
	Offset int
}

type OverdueOpts

type OverdueOpts struct {
	Before time.Time
	AppID  string
	Limit  int
	Offset int
}

OverdueOpts selects pending invoices whose due date is before Before, earliest due date first, ties broken by id. An empty AppID means every app.

type Status

type Status string
const (
	StatusDraft   Status = "draft"
	StatusPending Status = "pending"
	StatusPaid    Status = "paid"
	StatusPastDue Status = "past_due"
	StatusVoided  Status = "voided"
)

type Store

type Store interface {
	Create(ctx context.Context, inv *Invoice) error
	Get(ctx context.Context, invID id.InvoiceID) (*Invoice, error)
	List(ctx context.Context, tenantID, appID string, opts ListOpts) ([]*Invoice, error)
	Update(ctx context.Context, inv *Invoice) error
	GetByPeriod(ctx context.Context, tenantID, appID string, periodStart, periodEnd time.Time) (*Invoice, error)
	ListPending(ctx context.Context, appID string) ([]*Invoice, error)
	MarkPaid(ctx context.Context, invID id.InvoiceID, paidAt time.Time, paymentRef string) error
	MarkVoided(ctx context.Context, invID id.InvoiceID, reason string) error
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL