Amount Convention — NanoMoney & Pico-Units
This is the most important convention in the billing platform. Getting this wrong would corrupt financial data.
The Rule
All internal monetary amounts are stored as
i128integers with scale=12 (pico-units). Field names always end in_nanos. Never usef64for money. Ever.
Scale=12 Explained
1 USD = 1_000_000_000_000 (one trillion raw units)
10^12 raw units per major currency unit
Examples:
| Display amount | Internal representation |
|---|---|
| $0.00 | 0 |
| $0.000001 | 1_000_000 |
| $0.01 | 10_000_000_000 |
| $1.00 | 1_000_000_000_000 |
| $9.99 | 9_990_000_000_000 |
| $100.00 | 100_000_000_000_000 |
| $1,000,000.00 | 1_000_000_000_000_000_000 |
Why Not Floats?
IEEE 754 double-precision has ~15-16 significant decimal digits. Sounds sufficient — but isn’t:
>>> 0.1 + 0.2
0.30000000000000004 # NOT 0.3
In a billing system summing millions of small charges, these errors compound. $0.000001 * 1_000_000 in float arithmetic can yield $0.9999999999999999 instead of $1.00. That’s money lost.
With i128:
#![allow(unused)]
fn main() {
let per_token: i128 = 1_000_000; // $0.000001
let tokens: i128 = 1_000_000;
let total: i128 = per_token * tokens; // exactly 1_000_000_000_000 = $1.00
}
Why Not Cents (scale=2)?
Cents (i64 with scale=2) break down for AI/API pricing:
$0.000001per token =0.000001 * 100 = 0.0001cents → rounds to zero- Scale=12 preserves sub-nanodollar precision needed for usage-based pricing
The NanoMoney Type
#![allow(unused)]
fn main() {
pub struct NanoMoney {
raw: i128, // pico-units
currency: Currency,
}
impl NanoMoney {
pub fn new(nanos: i128, currency: &Currency) -> Self { ... }
pub fn to_money_round(&self) -> Money { ... } // rounds ONCE at output
pub fn add(&self, other: &NanoMoney) -> NanoMoney { ... }
pub fn mul_decimal(&self, factor: &Decimal) -> NanoMoney { ... }
}
}
NanoAccumulator for Hot Paths
For real-time spend tracking (spend alerts, rate limiting), the platform uses a NanoAccumulator:
#![allow(unused)]
fn main() {
// Atomic i128 operations for concurrent accumulation
pub struct NanoAccumulator {
inner: DashMap<CustomerId, AtomicI128>,
}
}
Redis implementation uses a Lua script for atomic compare-and-add.
Where Rounding Happens
Rounding happens exactly once: at invoice output, when converting from pico-units to display currency.
#![allow(unused)]
fn main() {
// In InvoicingService::finalize():
let display_total = NanoMoney::new(invoice.total_nanos, &invoice.currency)
.to_money_round(); // rounds to 2 decimal places here
}
Never round intermediate calculations. The NanoMoney type does not round on arithmetic operations.
API Serialization
In API responses, _nanos fields are serialized as strings (not integers) to avoid JavaScript’s 53-bit integer limit (Number.MAX_SAFE_INTEGER = 9_007_199_254_740_991):
{
"total_nanos": "9990000000000"
}
JavaScript clients should use BigInt to parse these values:
const totalNanos = BigInt(response.total_nanos);
const dollars = Number(totalNanos / 1_000_000_000_000n);
const cents = Number((totalNanos % 1_000_000_000_000n) / 10_000_000_000n);
const display = `$${dollars}.${String(cents).padStart(2, '0')}`;
Summary
Storage: i128 pico-units (scale=12)
Field name: _nanos suffix
Type: NanoMoney for arithmetic
Output: Round ONCE via .to_money_round()
API: Serialize as string to avoid JS BigInt overflow
NEVER: Use f64 for money calculations