Documentation
¶
Overview ¶
Package tariff is a pure-Go usage-billing rating core: a price-plan component plus a usage quantity in, itemized line items out, with exact, deterministically-rounded amounts. It has zero dependencies — the standard library's math/big is the exact-rate engine.
tariff rates; it does not meter, store, tax, or invoice. It takes a quantity already aggregated by whatever the caller uses and turns it into money.
c := tariff.Charge{
Model: tariff.Graduated,
Currency: tariff.USD(tariff.RoundHalfUp),
Tiers: []tariff.Tier{
{UpTo: 5, UnitRate: big.NewRat(7, 1)},
{UpTo: 10, UnitRate: big.NewRat(13, 2)}, // $6.50
{Last: true, UnitRate: big.NewRat(6, 1)},
},
}
res, _ := c.Rate(6)
fmt.Println(c.Currency.Format(res.Total)) // 41.50
The exactness discipline ¶
Rates are math/big.Rat, exact rationals: a per-unit price of $0.0006 is exactly 6/10000, not a float. quantity * rate is evaluated entirely in math/big and is never a float64. The exact amount is rounded to a whole minor unit exactly once, at the line boundary, using a caller-selected rounding mode — half-up, half-even (banker's), floor, or ceil. There is no hidden default: a Currency whose RoundingMode is unset is refused, because a silent default is a compliance bug.
Amounts out are int64 counts of the currency's minor unit. The scale is currency-driven — 2 decimals for USD, 0 for JPY, 3 for KWD — and never hardcoded to cents. tariff ships no money type; the caller wraps the int64 amounts in whatever they like at the boundary.
Rating models ¶
See Model. PerUnit is quantity times a flat rate. Graduated charges each tier's units at that tier's rate and sums them (marginal). Volume charges the whole quantity at the single rate of the tier it lands in — under a decreasing schedule the total can fall as usage grows, which is intended. Package rounds up to whole blocks after a free allowance. Stairstep charges a flat fee per tier band. A free allowance and an optional fixed flat fee compose with every model.
Tiers are half-open on the low side: tier i covers the units in (tiers[i-1].UpTo, tiers[i].UpTo], the first covering (0, tiers[0].UpTo], and the final tier is unbounded.
Line-item reconciliation ¶
A graduated charge rates every tier exactly, sums exactly, rounds the total once, then allocates that rounded total back across the tier lines with Allocate, so the line subtotals sum to the total with no drift. Rounding each line independently would let three lines that each end in half a minor unit sum to one unit more than the correctly rounded total; allocation prevents that. Across any Result, the line subtotals sum to Total exactly.
Allocation ¶
Allocate splits a whole amount across parts by ratio, distributing the floor of each share and handing the leftover minor units out round-robin from the first part. It loses nothing and is deterministic — the property that makes reconciliation, and later proration, penny-safe.
Errors ¶
Failures are typed sentinels matchable with errors.Is: ErrNegativeQuantity, ErrEmptyTiers, ErrTierOrder, ErrNoRate, ErrBadPackage, ErrBadAllowance, ErrBadCurrency, ErrBadAllocation, ErrOverflow and ErrUnknownModel.
Deviations from the design sketch ¶
Two intentional refinements over the indicative shape in docs/DESIGN.md, recorded there under "Phase 1 as built":
- Charge gains an optional FlatFee (minor units) so a fixed-plus-usage charge — the most common SaaS shape — is one charge, and so the vendor "$49 = 65000 * $0.0006 + $10" vector is reproduced exactly. It emits its own line and applies even at zero usage.
- Rounding is explicit on the Currency (no default), surfaced as USD, JPY and KWD constructors that force the choice.
The design's parenthetical that a volume total decreases from quantity 10 to 11 under the Stripe golden schedule is inaccurate — there it rises from $65.00 to $66.00. The decrease property is real but needs a steeper rate drop; see the volume tests. Likewise the correct graduated total for quantity 11 under that schedule is $73.50 (5*$7 + 5*$6.50 + 1*$6), not $71.50.
Example ¶
Example rates six units on a graduated schedule: 1-5 @ $7, 6-10 @ $6.50, 11+ @ $6.
package main
import (
"fmt"
"math/big"
"github.com/zkrebbekx/tariff"
)
func main() {
c := tariff.Charge{
Model: tariff.Graduated,
Currency: tariff.USD(tariff.RoundHalfUp),
Tiers: []tariff.Tier{
{UpTo: 5, UnitRate: big.NewRat(7, 1)},
{UpTo: 10, UnitRate: big.NewRat(13, 2)}, // $6.50
{Last: true, UnitRate: big.NewRat(6, 1)},
},
}
res, _ := c.Rate(6)
fmt.Printf("total %s across %d lines\n", c.Currency.Format(res.Total), len(res.Lines))
}
Output: total 41.50 across 2 lines
Example (Allocate) ¶
Example_allocate splits a rounded total across parts by ratio, losing nothing: with equal ratios the leftover cent goes to the first part.
package main
import (
"fmt"
"github.com/zkrebbekx/tariff"
)
func main() {
shares, _ := tariff.Allocate(100, []int64{1, 1, 1})
fmt.Println(shares)
}
Output: [34 33 33]
Example (Package) ¶
Example_package rounds the chargeable quantity up to whole blocks after the free allowance: 201 - 100 free = 101 units, which is two $5 blocks.
package main
import (
"fmt"
"github.com/zkrebbekx/tariff"
)
func main() {
c := tariff.Charge{
Model: tariff.Package,
Currency: tariff.USD(tariff.RoundHalfUp),
PackageSize: 100,
PackagePrice: 500, // $5.00
FreeAllowance: 100,
}
res, _ := c.Rate(201)
fmt.Println(c.Currency.Format(res.Total))
}
Output: 10.00
Example (Reconciliation) ¶
Example_reconciliation shows that tier lines are allocated from the once-rounded total, so they reconcile exactly even when each tier ends in half a minor unit.
package main
import (
"fmt"
"math/big"
"github.com/zkrebbekx/tariff"
)
func main() {
c := tariff.Charge{
Model: tariff.Graduated,
Currency: tariff.USD(tariff.RoundHalfUp),
Tiers: []tariff.Tier{
{UpTo: 1, UnitRate: big.NewRat(21, 200)}, // $0.105
{UpTo: 2, UnitRate: big.NewRat(41, 200)}, // $0.205
{Last: true, UnitRate: big.NewRat(61, 200)}, // $0.305
},
}
res, _ := c.Rate(3)
var sum int64
for _, l := range res.Lines {
sum += l.Subtotal
}
fmt.Printf("total=%d lines=%d+%d+%d sum=%d\n",
res.Total, res.Lines[0].Subtotal, res.Lines[1].Subtotal, res.Lines[2].Subtotal, sum)
}
Output: total=62 lines=10+21+31 sum=62
Example (Volume) ¶
Example_volume charges the whole quantity at the single rate of the tier it lands in, which is why six units cost 6 x $6.50 rather than the graduated mix.
package main
import (
"fmt"
"math/big"
"github.com/zkrebbekx/tariff"
)
func main() {
c := tariff.Charge{
Model: tariff.Volume,
Currency: tariff.USD(tariff.RoundHalfUp),
Tiers: []tariff.Tier{
{UpTo: 5, UnitRate: big.NewRat(7, 1)},
{UpTo: 10, UnitRate: big.NewRat(13, 2)},
{Last: true, UnitRate: big.NewRat(6, 1)},
},
}
res, _ := c.Rate(6)
fmt.Println(c.Currency.Format(res.Total))
}
Output: 39.00
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNegativeQuantity is returned by [Charge.Rate] when the quantity is // negative. A zero quantity is valid and rates to nothing. ErrNegativeQuantity = errors.New("tariff: negative quantity") // ErrEmptyTiers is returned when a tiered model (graduated, volume, // stairstep) is rated with no tiers at all. ErrEmptyTiers = errors.New("tariff: no tiers") // ErrTierOrder is returned when a tier schedule is malformed: upper bounds // that do not strictly increase, a non-positive bound, a non-final tier // marked unbounded, or a final tier that is not unbounded. Tiers are // half-open on the low side — tier i covers the units in (tiers[i-1].UpTo, // tiers[i].UpTo] — and the final tier must be unbounded so that every // quantity is covered. ErrTierOrder = errors.New("tariff: tiers out of order") // ErrNoRate is returned when a required per-unit or flat rate is missing or // negative: a per-unit charge with a nil rate, a graduated or volume tier // with a nil or negative rate, a stairstep tier with a negative flat rate, // or a negative flat fee on the charge. ErrNoRate = errors.New("tariff: missing or invalid rate") // ErrBadPackage is returned when a package charge is misconfigured: a // package size that is not positive, or a negative package price. ErrBadPackage = errors.New("tariff: invalid package configuration") // ErrBadAllowance is returned when a charge's free allowance is negative. ErrBadAllowance = errors.New("tariff: negative free allowance") // ErrBadCurrency is returned when a currency is unusable: negative or // implausibly large decimal places, or an unset rounding mode. A charge // must always choose its rounding mode explicitly, because a hidden default // is a silent compliance bug. ErrBadCurrency = errors.New("tariff: invalid currency") // ErrBadAllocation is returned by [Allocate] when asked to split across no // parts, or with a negative total or a negative ratio. ErrBadAllocation = errors.New("tariff: invalid allocation") // ErrOverflow is returned when an exact amount, or an allocated share, does // not fit in an int64 count of minor units. ErrOverflow = errors.New("tariff: amount overflows int64 minor units") // ErrUnknownModel is returned when a charge names a rating model that this // package does not implement. ErrUnknownModel = errors.New("tariff: unknown rating model") )
Sentinel errors returned (usually wrapped with context) by this package. Match them with errors.Is rather than by comparing error strings.
Functions ¶
func Allocate ¶
Allocate splits a whole total across len(ratios) parts in proportion to the given ratios, losing nothing. Each part receives the floor of its exact share, then the leftover minor units — always fewer than the number of parts — are handed to the parts whose exact share had the largest fractional remainder (the largest-remainder, or Hamilton, method), ties broken by position. The result sums exactly to total for any ratios and any remainder, is deterministic, and — unlike a round-robin-from-the-first split — never hands a penny to a part that did not round up: a zero ratio receives zero, and a part whose exact share is already whole keeps it.
This is the penny-safe remainder split that makes line-item reconciliation (and, later, proration) exact, without misrepresenting what any one line charged. tariff uses the same routine internally to distribute a rounded tiered total back across its tier lines. If every ratio is zero the split is made evenly.
total and every ratio must be non-negative; otherwise Allocate returns an error wrapping ErrBadAllocation.
Types ¶
type Charge ¶
type Charge struct {
// Model selects the rating algebra.
Model Model
// Currency fixes the minor-unit scale and rounding mode.
Currency Currency
// UnitRate is the exact per-unit price for the tier-less PerUnit model.
UnitRate *big.Rat
// Tiers is the price schedule for the Graduated, Volume and Stairstep
// models.
Tiers []Tier
// PackageSize is the block size for the Package model; it must be positive.
PackageSize int64
// PackagePrice is the price per block, in minor units, for the Package
// model.
PackagePrice int64
// FreeAllowance is a quantity subtracted before rating (or before the
// package ceil). It composes with every model.
FreeAllowance int64
// FlatFee is an optional fixed amount, in minor units, added to the rated
// total as its own line — the fixed half of a fixed-plus-usage charge. It
// applies regardless of quantity, including when usage rates to nothing.
FlatFee int64
}
Charge is a price-plan component: one rating model, a currency, and the parameters that model reads. Rate it against a usage quantity with Charge.Rate.
The rate fields are exact rationals (*big.Rat) so that sub-cent prices such as $0.0006 never drift; quantity * rate is evaluated entirely in math/big and rounded to the minor unit exactly once, at the line boundary, using the currency's explicit rounding mode.
func (Charge) Rate ¶
Rate computes the charge for a usage quantity, returning the rounded total in minor units and the reconciling line items.
A zero quantity rates the usage to nothing (any FlatFee still applies). A negative quantity is an error. The charge's configuration is validated first, so a malformed schedule is reported even at zero quantity.
type Currency ¶
type Currency struct {
// Code is an informational label such as "USD"; it does not affect
// arithmetic.
Code string
// Decimals is the number of minor-unit decimal places: 2 for cents, 0 for
// whole yen, 3 for fils.
Decimals int
// Rounding is the mode used to round an exact amount to a whole minor unit.
// It must be set explicitly.
Rounding RoundingMode
}
Currency describes the money unit a charge is priced in: the number of decimal places its minor unit has, and how exact amounts are rounded to it. The minor-unit scale is entirely currency-driven — 2 for USD, 0 for JPY, 3 for KWD — and never hardcoded to cents anywhere in the package.
A Currency is a small immutable value; construct one directly or use USD, JPY and KWD for the common cases.
func JPY ¶
func JPY(r RoundingMode) Currency
JPY returns the zero-decimal Japanese yen with the given rounding mode.
func KWD ¶
func KWD(r RoundingMode) Currency
KWD returns the three-decimal Kuwaiti dinar with the given rounding mode.
func USD ¶
func USD(r RoundingMode) Currency
USD returns the two-decimal US dollar with the given rounding mode.
type Line ¶
type Line struct {
// Quantity is the number of units this line rates, or the number of blocks
// for a package charge. It is zero on a bare flat-fee line.
Quantity int64
// Rate is the exact per-unit rate applied, or nil where the model has no
// per-unit rate (package, stairstep, flat fee). It is a copy the caller may
// keep or mutate freely.
Rate *big.Rat
// Subtotal is this line's amount in minor units.
Subtotal int64
}
Line is one component of a rated charge: the units (or blocks) it covers, the exact per-unit rate applied where one applies, and the subtotal in minor units. Across a Result the line subtotals always sum exactly to the total.
type Model ¶
type Model uint8
Model selects a charge's rating algebra.
const ( // PerUnit charges quantity * UnitRate, a single flat rate for every unit. PerUnit Model = iota // Graduated charges each tier's units at that tier's rate and sums the // per-tier subtotals — the marginal, cumulative interpretation of a tiered // schedule. Graduated // Volume charges the whole quantity at the single rate of the one tier the // total lands in. The total can fall as usage grows into a cheaper tier. Volume // Package rounds the chargeable quantity up to whole blocks of PackageSize // (after any free allowance) and charges PackagePrice per block. Package // Stairstep charges a flat fee for landing in a tier, regardless of the // exact quantity within it. Stairstep )
type Result ¶
type Result struct {
// Total is the amount owed, in the currency's minor units.
Total int64
// Lines are the itemized components; their subtotals sum to Total exactly.
Lines []Line
}
Result is the outcome of rating a charge: the rounded total in minor units, and the line items that reconcile to it exactly.
type RoundingMode ¶
type RoundingMode uint8
RoundingMode selects how an exact amount is rounded to a whole minor unit.
The zero value is RoundingUnspecified, which is never a valid mode: tariff refuses to rate a charge whose currency has not chosen one, because a hidden default rounding mode is a silent compliance bug. Different jurisdictions and contracts mandate different modes, so the choice is always the caller's.
const ( // RoundingUnspecified is the zero value and is never valid; a currency must // choose a real rounding mode. RoundingUnspecified RoundingMode = iota // RoundHalfUp rounds to the nearest minor unit, breaking ties away from // zero (0.5 becomes 1, -0.5 becomes -1). This is the common "round half up" // of everyday arithmetic and many billing contracts. RoundHalfUp // RoundHalfEven rounds to the nearest minor unit, breaking ties toward the // even neighbour (0.5 and 1.5 both become their even side). Also known as // banker's rounding; it removes the upward bias of RoundHalfUp. RoundHalfEven // RoundFloor rounds toward negative infinity. RoundFloor // RoundCeil rounds toward positive infinity. RoundCeil )
func (RoundingMode) String ¶
func (m RoundingMode) String() string
String renders the rounding mode for diagnostics.
type Tier ¶
type Tier struct {
// UpTo is the inclusive upper bound of this band, in units. It is ignored
// when Last is true.
UpTo int64
// Last marks the final, unbounded band. It must be true for the last tier
// and false for every other.
Last bool
// UnitRate is the exact per-unit price within this band, used by the
// graduated and volume models.
UnitRate *big.Rat
// FlatRate is the flat fee, in minor units, for landing in this band, used
// by the stairstep model.
FlatRate int64
}
Tier is one band of a tiered price schedule. Bands are half-open on the low side: tier i covers the units in (tiers[i-1].UpTo, tiers[i].UpTo], with the first tier covering (0, tiers[0].UpTo]. Exactly one tier — the last — is unbounded, marked with Last, so that every quantity is covered.
Which fields matter depends on the charge's model. Graduated and volume read UnitRate; stairstep reads FlatRate. UpTo is ignored on the last (unbounded) tier.