tariff

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Jul 22, 2026 License: MIT Imports: 6 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.
  • Proration, DST- and month-end-safe. Credit-unused + charge-new + net, to the second or by whole day, with cycle boundaries that clamp Jan 31 → Feb 28 and back to Mar 31 without drifting.
  • Interaction order is the caller's. Charges, discounts, minimums, credits and commitments compose as explicit, ordered, labeled steps — tariff computes each order faithfully instead of guessing one.
  • 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 (10 + 21 + 31), 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 to the parts with the largest fractional remainder (the Hamilton method), so a zero-ratio part receives zero and an exact part keeps its amount. It loses nothing and is deterministic. The total may be negative — a proration credit splits across lines with the sign carried through — the property that makes reconciliation and 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.

Proration

A plan that changes mid-period is rated with the verified cross-vendor model — credit the unused old price, charge the new price for the remaining time, net them — not a true-forward.

p := tariff.Period{Start: start, End: end} // half-open [Start, End)
pr, _ := tariff.Change(1000, 2000, usd, p, at, tariff.ProrateBySecond)
// pr.Credit -500, pr.Charge 1000, pr.Net 500  ($10 → $20 upgrade at the midpoint)

Period.Fraction returns the exact fraction of a period a window covers, as a *big.Rat, under one of two bases:

  • ProrateBySecond (the default) measures real elapsed time. A day that is 23 or 25 hours long across a DST change contributes its true length, so the fraction is never off by the missing or repeated hour.
  • ProrateByDay counts whole calendar days in the period's location, so a DST day is exactly one day.

Cycle boundaries handle the month-end trap without drift: NextBoundary clamps a Jan 31 anchor to Feb 28 (or 29 in a leap year) and then back to Mar 31, always measuring from the original anchor day. NextCalendarBoundary is the calendar-aligned wrapper (anchored on the 1st). Credits are negative amounts, and Allocate splits them across lines with the sign carried through.

Composition

Charges, discounts, minimums, prepaid credits and spend commitments must combine, and the order is where real billing systems disagree — does a percentage discount apply before or after a minimum? Public vendor docs under-specify it, so tariff does not bake an order: it exposes the operations as composable steps you sequence explicitly, each a labeled, auditable line.

inv, _ := tariff.Compose(usd,
    tariff.Charged(c, 1),                              // rate a charge
    tariff.PercentOff(big.NewRat(1, 10), "10% off"),   // fraction, not whole percent
    tariff.MinimumCharge(9500, "minimum $95"),         // top up to the floor
)
// discount before minimum: $100 → $90 → floored to $95
// swap the two steps and the $100 clears the floor untouched, ending at $90

The steps are Charged, PercentOff, AmountOff, MinimumCharge, DrawCredit and DrawCommitment. Discounts round once via the currency; credit and commitment draws are capped at both the running total and the balance and never go negative. tariff's job is that each step is individually correct and exactly rounded — not to decide the sequence.

Errors

Failures are typed sentinels matchable with errors.Is: ErrNegativeQuantity, ErrEmptyTiers, ErrTierOrder, ErrNoRate, ErrBadPackage, ErrBadAllowance, ErrBadCurrency, ErrBadAllocation, ErrOverflow, ErrUnknownModel, ErrBadPeriod, ErrBadWindow, ErrBadBasis, ErrBadDiscount, ErrBadFloor, ErrBadBalance, ErrCurrencyMismatch and ErrNilStep. 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 an amount across parts by ratio, distributing the floor of each share and handing the leftover minor units to the parts with the largest fractional remainder (the largest-remainder, or Hamilton, method). It loses nothing, is deterministic, and never credits a penny to a part that did not round up — a zero ratio receives zero. The total may be negative, so a proration credit splits across lines with the sign carried through. This is the property that makes reconciliation and proration penny-safe.

Proration

A subscription priced per billing period that changes mid-period is rated with the verified cross-vendor model — credit the unused old price, charge the new price for the remaining time, and net them — not a true-forward. A Period is a half-open instant range [Start, End) anchored in a *time.Location; Period.Fraction returns the exact fraction of it covered by a window as a math/big.Rat, under a Basis of ProrateBySecond (real elapsed time, DST-correct) or ProrateByDay (whole civil days in the period's location, so a 23- or 25-hour DST day counts as exactly one). Prorate scales an amount by a fraction, rounded once through the currency; Change returns a Proration of Credit (negative), Charge and Net for a plan change, so oldAmount = 0 is a trial-to-paid change with a zero credit.

Cycle boundaries come from NextBoundary (anniversary cycles, N units from an anchor) and NextCalendarBoundary (calendar-aligned, anchored on the 1st), over a CycleUnit of Monthly or Yearly. The month-end case is drift-free: a boundary's day is the anchor's day clamped to the target month's last valid day, so a January 31 anchor steps to February 28/29 and back to March 31.

Composition

Charges, discounts, minimums, prepaid credits and spend commitments combine in a caller-controlled order, because that order is where real systems disagree — a percentage discount before or after a minimum yields different totals — and tariff refuses to bake one in. Compose left-folds a sealed set of Step values over an empty Invoice in the given order: Charged, PercentOff, AmountOff, MinimumCharge, DrawCredit and DrawCommitment. Each step appends a labeled, auditable Line and moves the running total by exactly that amount, so the invoice lines always reconcile to Total and the sequence that produced them is visible.

Errors

Failures are typed sentinels matchable with errors.Is: ErrNegativeQuantity, ErrEmptyTiers, ErrTierOrder, ErrNoRate, ErrBadPackage, ErrBadAllowance, ErrBadCurrency, ErrBadAllocation, ErrOverflow, ErrUnknownModel, ErrBadPeriod, ErrBadWindow, ErrBadBasis, ErrBadDiscount, ErrBadFloor, ErrBadBalance, ErrCurrencyMismatch and ErrNilStep.

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 (Compose)

Example_compose shows that the order of steps is the caller's and is visible in the result: discounting a $100 charge by 10% then applying a $95 minimum floors the $90 back up to $95.

package main

import (
	"fmt"
	"math/big"

	"github.com/zkrebbekx/tariff"
)

func main() {
	usd := tariff.USD(tariff.RoundHalfUp)
	c := tariff.Charge{Model: tariff.PerUnit, Currency: usd, UnitRate: big.NewRat(100, 1)}

	inv, _ := tariff.Compose(usd,
		tariff.Charged(c, 1),
		tariff.PercentOff(big.NewRat(1, 10), "10% off"),
		tariff.MinimumCharge(9500, "minimum $95"),
	)
	fmt.Println(usd.Format(inv.Total))
}
Output:
95.00
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 (Proration)

Example_proration reproduces the Stripe mid-period upgrade: a $10 plan upgraded to $20 exactly halfway through a period is credited $5 for the unused old plan and charged $10 for the remaining new plan, netting $5.

package main

import (
	"fmt"
	"time"

	"github.com/zkrebbekx/tariff"
)

func main() {
	usd := tariff.USD(tariff.RoundHalfUp)
	p := tariff.Period{
		Start: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC),
		End:   time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC),
	}
	at := time.Date(2026, time.January, 16, 0, 0, 0, 0, time.UTC)

	pr, _ := tariff.Change(1000, 2000, usd, p, at, tariff.ProrateBySecond)
	fmt.Printf("credit %s, charge %s, net %s\n",
		usd.Format(pr.Credit), usd.Format(pr.Charge), usd.Format(pr.Net))
}
Output:
credit -5.00, charge 10.00, net 5.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")

	// ErrNegativeAmount is returned by [Change] when a plan amount is negative.
	// Proration's Credit-is-negative / Charge-is-positive result only holds for
	// non-negative plan prices; a negative price is rejected rather than
	// silently inverting those signs.
	ErrNegativeAmount = errors.New("tariff: negative amount")

	// 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 ratio. The total may be any sign — a negative
	// total is a proration credit split across lines.
	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")

	// ErrBadPeriod is returned when a billing [Period] is unusable: its End is
	// not strictly after its Start, or a day-based [Basis] is applied to a
	// period that spans no whole calendar day (so the day denominator is zero).
	ErrBadPeriod = errors.New("tariff: invalid billing period")

	// ErrBadWindow is returned by [Period.Fraction] when the window is
	// incoherent — from is after to — or by [Prorate] when the fraction is nil.
	// A window that simply falls outside the period is not an error; it clamps
	// to a zero fraction.
	ErrBadWindow = errors.New("tariff: invalid proration window")

	// ErrBadBasis is returned by [Period.Fraction] when the proration [Basis]
	// is not one this package implements.
	ErrBadBasis = errors.New("tariff: unknown proration basis")

	// ErrBadDiscount is returned by a composition step when a discount is
	// malformed: a nil percentage, a percentage outside [0, 1], or a negative
	// fixed amount off.
	ErrBadDiscount = errors.New("tariff: invalid discount")

	// ErrBadFloor is returned by [MinimumCharge] when the floor is negative.
	ErrBadFloor = errors.New("tariff: invalid minimum charge")

	// ErrBadBalance is returned by [DrawCredit] or [DrawCommitment] when the
	// balance pointer is nil or the balance it points to is negative.
	ErrBadBalance = errors.New("tariff: invalid balance")

	// ErrCurrencyMismatch is returned by [Compose] when a [Charged] step's
	// currency does not share the invoice currency's code and minor-unit scale,
	// so its amounts cannot be summed with the rest of the invoice.
	ErrCurrencyMismatch = errors.New("tariff: currency mismatch")

	// ErrNilStep is returned by [Compose] when one of the steps is nil.
	ErrNilStep = errors.New("tariff: nil step")
)

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 total across len(ratios) parts in proportion to the given ratios, losing nothing. Each part receives the floor (toward zero) 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.

The total may be negative: a proration credit is a negative amount split across lines. A negative total is allocated as the exact mirror of its magnitude — every share is non-positive, the sum is exactly the (negative) total, and the negative leftover lands on the same largest-remainder parts a positive leftover would. A zero ratio still receives exactly zero. (Phase 1 refused a negative total; that limitation is now lifted.)

This is the penny-safe remainder split that makes line-item reconciliation and 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.

Every ratio must be non-negative; a negative ratio, or no parts at all, returns an error wrapping ErrBadAllocation. The total is unrestricted in sign.

func NextBoundary added in v0.2.0

func NextBoundary(anchor, from time.Time, unit CycleUnit) time.Time

NextBoundary returns the first anniversary cycle boundary strictly after from, for a cycle of the given unit anchored at anchor. Boundaries are anchor ± k units for integer k, all in the anchor's location and at the anchor's time of day.

The month-end rule is the delicate part, and it is handled without drift: a boundary's day is the anchor's day of month clamped to the last valid day of the target month, always measured from the original anchor day. An anchor of January 31 therefore steps to February 28 (or 29 in a leap year) and then back to March 31, never sticking at the 28th; a February 29 anchor steps to February 28 in common years and returns to February 29 the next leap year.

func NextCalendarBoundary added in v0.2.0

func NextCalendarBoundary(from time.Time, unit CycleUnit) time.Time

NextCalendarBoundary returns the next calendar-aligned cycle boundary strictly after from: the first of the next month for Monthly, or the first January for Yearly, at midnight in from's location. It is the thin wrapper over NextBoundary with the anchor pinned to the first of the period — calendar-aligned cycles are just anniversary cycles anchored on the 1st.

func Prorate added in v0.2.0

func Prorate(amount int64, cur Currency, frac *big.Rat) (int64, error)

Prorate returns amount scaled by frac, rounded once to the currency's minor unit with the currency's rounding mode. The amount is already an int64 count of minor units; frac is dimensionless, so amount * frac is the prorated amount in minor units, rounded through the same path Charge.Rate uses. A nil frac is ErrBadWindow; an unusable currency is ErrBadCurrency; a result that will not fit an int64 is ErrOverflow.

Types

type Basis added in v0.2.0

type Basis uint8

Basis selects how Period.Fraction measures elapsed time. The zero value, ProrateBySecond, is the Stripe default and needs no configuration.

const (
	// ProrateBySecond measures real elapsed time to the nanosecond. A day that
	// is 23 or 25 hours long across a DST transition contributes its true
	// length, so the fraction is never off by the missing or repeated hour.
	// This is the Stripe default and the zero value of Basis.
	ProrateBySecond Basis = iota
	// ProrateByDay measures whole calendar days in the period's location. Every
	// calendar day counts as exactly one, regardless of a DST transition within
	// it, matching the Chargebee day-based option. The day containing a
	// window's lower bound is floored to that day's start, so used and remaining
	// day counts partition the period exactly.
	ProrateByDay
)

func (Basis) String added in v0.2.0

func (b Basis) String() string

String renders the basis name for diagnostics.

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 CycleUnit added in v0.2.0

type CycleUnit uint8

CycleUnit is the step of a billing cycle: Monthly or Yearly. Monthly is the zero value.

const (
	// Monthly steps one calendar month at a time. It is the zero value.
	Monthly CycleUnit = iota
	// Yearly steps one calendar year at a time.
	Yearly
)

func (CycleUnit) String added in v0.2.0

func (u CycleUnit) String() string

String renders the cycle unit for diagnostics.

type Invoice added in v0.2.0

type Invoice struct {
	// Currency fixes the minor-unit scale and the rounding mode used by the
	// discount steps.
	Currency Currency
	// Lines are the invoice lines in application order: charge lines followed
	// or interleaved with labeled adjustment lines.
	Lines []Line
	// Subtotal is the sum of the charge lines — the gross before adjustments.
	Subtotal int64
	// Total is the net amount after every step, in minor units. It equals the
	// sum of all line subtotals.
	Total int64
	// contains filtered or unexported fields
}

Invoice is a computed invoice: the currency it is priced in, the ordered adjustment and charge lines, the gross Subtotal from the charges, and the running Total after every step. It is a value, computed by Compose and stored by no one — composition is not persistence.

The line subtotals always sum exactly to Total: every step appends the exact line it applied and moves Total by that same amount.

func Compose added in v0.2.0

func Compose(cur Currency, steps ...Step) (Invoice, error)

Compose folds the steps left to right over an empty invoice in the given order, returning the resulting Invoice. The order is the caller's and it is visible in the result: composing a percentage discount before a minimum charge floors after discounting, and swapping the two floors before discounting — tariff computes each order faithfully rather than imposing one.

The currency is validated up front; a nil step is ErrNilStep; any step's own validation error (a bad discount, floor, balance, or a charge whose currency does not match the invoice) propagates unwrapped-through as a sentinel matchable with errors.Is.

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. It is negative on a credit
	// or discount line in a composed [Invoice].
	Subtotal int64
	// Label is a human-readable description of an adjustment line — a discount,
	// minimum top-up, credit or commitment draw in a composed [Invoice]. It is
	// empty on the rating lines produced by [Charge.Rate].
	Label string
}

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 Period added in v0.2.0

type Period struct {
	// Start is the inclusive lower bound of the period.
	Start time.Time
	// End is the exclusive upper bound of the period; it must be after Start.
	End time.Time
}

Period is a billing period, the half-open instant range [Start, End). Its End must be strictly after its Start. A period is anchored in a *time.Location — Start.Location() is the period's location, the frame in which a day-based Basis counts calendar days and in which cycle boundaries are computed. Second-based fractions are location-independent, being real elapsed time.

func (Period) Fraction added in v0.2.0

func (p Period) Fraction(from, to time.Time, b Basis) (*big.Rat, error)

Fraction returns the exact fraction of the period covered by the window [from, to), as a *big.Rat that never crosses float64.

The window is clamped to the period before measuring: the covered span is [max(from, Start), min(to, End)). A window wider than the period yields exactly 1; a window that falls entirely outside the period yields exactly 0; an empty window (from == to) yields 0. Only from strictly after to is an error (ErrBadWindow) — it is not a coherent window. The period itself must be valid, or ErrBadPeriod is returned.

Under ProrateBySecond the fraction is real elapsed nanoseconds of the covered span over real elapsed nanoseconds of the whole period, so a DST day contributes its true length. Under ProrateByDay it is the count of whole calendar days in the covered span over the whole period, computed from civil dates in the period's location, so a DST day contributes exactly one and the result is immune to the 23- or 25-hour day. A day-based fraction requires the period to span at least one whole calendar day, or ErrBadPeriod is returned (its day denominator would be zero).

type Proration added in v0.2.0

type Proration struct {
	// Credit is the credit for the unused remainder of the old price. It is
	// zero or negative.
	Credit int64
	// Charge is the charge for the remaining time on the new price. It is zero
	// or positive.
	Charge int64
	// Net is Charge + Credit, the amount actually billed for the change.
	Net int64
}

Proration is the outcome of a mid-period plan change: a negative Credit for the unused remainder of the old price, a positive Charge for the remaining time on the new price, and their Net. It is the verified cross-vendor model — Stripe's "−5 USD unused, 10 USD remaining, 5 USD net" and Chargebee's credit/charge/net — not a true-forward.

func Change added in v0.2.0

func Change(oldAmount, newAmount int64, cur Currency, p Period, at time.Time, b Basis) (Proration, error)

Change computes the credit, charge and net for a plan change at instant at, partway through period p. The remaining fraction is p.Fraction(at, p.End, b); Credit is −oldAmount × remaining, Charge is +newAmount × remaining, each rounded once, and Net is their sum. A trial-to-paid change falls out with oldAmount = 0, giving a zero credit. Errors from the period, window or currency propagate.

Both amounts must be non-negative — a plan price is not a debt — otherwise ErrNegativeAmount is returned. Given that, Credit is always ≤ 0 and Charge always ≥ 0; a downgrade (newAmount < oldAmount) yields a negative Net, a legitimate refund.

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 Step added in v0.2.0

type Step interface {
	// contains filtered or unexported methods
}

Step is one operation in an invoice composition: a charge, a discount, a minimum, or a credit or commitment draw. The set is sealed — only this package implements it — so Compose over the constructors below is the only way to build an Invoice. Each step appends the labeled line(s) it applies and moves the running total by exactly that amount.

func AmountOff added in v0.2.0

func AmountOff(minor int64, label string) Step

AmountOff removes a fixed amount of minor units from the running total as a discount. The amount must be non-negative; a negative amount is ErrBadDiscount. It may drive the running total below zero — pair it with a MinimumCharge if a floor is wanted.

func Charged added in v0.2.0

func Charged(c Charge, quantity int64) Step

Charged rates the charge for the given quantity and appends its line items to the invoice, adding its total to both the gross subtotal and the running total. The charge's currency must share the invoice currency's code and minor-unit scale (its rounding mode may differ — that is a legitimate per-charge choice); otherwise Compose returns ErrCurrencyMismatch.

func DrawCommitment added in v0.2.0

func DrawCommitment(balance *int64, label string) Step

DrawCommitment draws a spend-commitment balance down against the running total, with the same capping and mutation semantics as DrawCredit; the two differ only in intent and the audit label. A nil pointer or a negative balance is ErrBadBalance.

func DrawCredit added in v0.2.0

func DrawCredit(balance *int64, label string) Step

DrawCredit draws a prepaid credit balance down against the running total, mutating the caller's balance. The draw is capped at both the running total and the balance and is never negative, so the running total cannot go below zero from a credit and the balance cannot be overdrawn. A nil pointer or a negative balance is ErrBadBalance.

func MinimumCharge added in v0.2.0

func MinimumCharge(floor int64, label string) Step

MinimumCharge tops the running total up to floor if it is currently below it, as a single positive adjustment line; if the running total is already at or above the floor it does nothing. The floor must be non-negative; a negative floor is ErrBadFloor.

func PercentOff added in v0.2.0

func PercentOff(pct *big.Rat, label string) Step

PercentOff removes pct of the running total as a discount, rounded once with the invoice currency's rounding mode. pct is a fraction, not a whole percent: big.NewRat(1, 10) is 10% off. It must be non-nil and in [0, 1]; a nil, negative or above-one pct is ErrBadDiscount.

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