tariff

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 5 Imported by: 0

README

tariff

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. Zero dependencies.

Every usage-billing product reimplements the same rating algebra, and the OSS options are the wrong shape for embedding — services that need ClickHouse and Kafka, or AGPL engines that can't sit inside proprietary code. tariff is just the calculator: it takes a quantity you already aggregated and turns it into money. It meters nothing, stores nothing, and taxes nothing.

The interesting part is what happens to the fractions of a cent. Whether a per-unit rate of $0.0006 is an exact rational or a drifting float, whether three tier lines still sum to the invoice total after rounding, and whether the yen (no decimals) and the dinar (three) round on their own minor unit rather than a hardcoded cent — tariff takes those three questions seriously.

  • Exact rates, no float drift. Rates are math/big.Rat; quantity × rate is evaluated entirely in math/big and rounded to the minor unit exactly once, at the line boundary. $1.005 rounds to $1.01, not the $1.00 a float64 quietly produces.
  • Rounding is explicit. Half-up, half-even (banker's), floor or ceil — the caller chooses on the currency. There is no default, because a hidden one is a compliance bug.
  • Lines reconcile. A graduated charge rates each tier exactly, sums exactly, rounds the total once, then allocates it back across the tier lines so the subtotals sum to the total with no drift.
  • Currency-driven minor units. 2 places for USD, 0 for JPY, 3 for KWD — never hardcoded to cents.
  • Five rating models. Per-unit, graduated (tiered), volume, package/block, and stairstep, all with optional free allowances and a fixed fee.
  • Typed errors, matchable with errors.Is.
  • Zero dependencies. Go 1.23+.
import "github.com/zkrebbekx/tariff"

Example

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
// res.Lines: 5 @ $7 = $35.00, 1 @ $6.50 = $6.50

The rating models

Definitions are pinned to Stripe and Lago, with their golden vectors baked into the test suite.

  • Per-unitquantity × rate, one line.
  • Graduated (tiered) — each tier's units at that tier's rate, summed. The Stripe schedule 1–5 @ $7, 6–10 @ $6.50, 11+ @ $6 rates quantity 6 to $41.50 and quantity 11 to $73.50. One line per tier touched.
  • Volume — the whole quantity at the single rate of the tier it lands in. The same schedule rates quantity 6 to $39.00. Under a steeply decreasing schedule the total can fall as usage grows into a cheaper tier — intended, not a bug.
  • Package / block — round up to whole blocks of size N after the free allowance: $5 per 100-unit block, 100 free, 201 units → $10.00. The allowance is subtracted before the ceil.
  • Stairstep — a flat fee for landing in a tier band, regardless of position within it.

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 (Last: true).

Exactness and reconciliation

Rating each tier exactly and rounding the total once — then allocating that total back across the lines — is what keeps an invoice's line items summing to its total. Three tiers that each end in half a cent would drift to one cent too many if rounded independently (11 + 21 + 31 = 63); tariff rounds the sum (62) and allocates it (11 + 21 + 30), so sum(lines) == total, exactly, always.

shares, _ := tariff.Allocate(100, []int64{1, 1, 1}) // [34 33 33]

Allocate distributes the floor of each share and hands 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.

Currencies and rounding

tariff.USD(tariff.RoundHalfUp)   // 2 decimals
tariff.JPY(tariff.RoundHalfEven) // 0 decimals
tariff.KWD(tariff.RoundFloor)    // 3 decimals

Amounts out are int64 counts of the currency's minor unit. tariff ships no money type; wrap the amounts in whatever you like at the boundary.

Errors

Failures are typed sentinels matchable with errors.Is: ErrNegativeQuantity, ErrEmptyTiers, ErrTierOrder, ErrNoRate, ErrBadPackage, ErrBadAllowance, ErrBadCurrency, ErrBadAllocation, ErrOverflow and ErrUnknownModel. A zero quantity is valid and rates to nothing; a negative one is an error.

Non-goals

  • No metering. Deduplication, idempotency, late events, aggregation — that is the upstream concern that forces ClickHouse and Kafka on the incumbents. tariff takes an already-aggregated quantity.
  • No tax. The moat there is jurisdiction data, not code. tariff emits pre-tax line items.
  • No persistence, subscriptions, invoicing, dunning or payments. A rating core computes; it stores nothing.
  • No money type. tariff has amounts — int64 minor units — not a Money.

License

MIT

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

View Source
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

func Allocate(total int64, ratios []int64) ([]int64, error)

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

func (c Charge) Rate(quantity int64) (Result, error)

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.

func (Currency) Format

func (c Currency) Format(minor int64) string

Format renders a count of minor units as a plain decimal string with the currency's number of fractional digits: Format(4150) is "41.50" for USD, "4150" for JPY, and "4.150" for KWD. It is a display convenience and plays no part in rating.

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
)

func (Model) String

func (m Model) String() string

String renders the model name.

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.

Directories

Path Synopsis
tariffd module

Jump to

Keyboard shortcuts

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