The money model
A dual-rate credit economy — buy ≥ par ≥ payout — where the platform spread is the only fee and every value carries an explicit currency and scale.
Source src/money.tssrc/ports.ts#L137-L167 · Ratessrc/pricing.ts
Two kinds of value move through the economy: in-app CREDIT and real-world USD. Both are an Amount — a Currency plus a count of minor units held as a bigint. They convert to each other only at fixed, platform-set rates, never a live market.
Almost the whole model follows from three of those rates, ordered buy ≥ par ≥ payout. You pay the buy rate to acquire a credit. The platform backs and cashes out each credit at par. A creator settles earned credits at payout, which equals par.
The gap between buy and par is the platform spread — the single place the platform takes a margin.
Amount: a currency and a scale
A value should never lose track of what currency it's in. So every Amount carries its currency with it: it's { currency, minor, __brand }, where minor is a bigint of minor units (cents, for USD) and Currency is the string union 'CREDIT' | 'USD'. Minor units are exact integers, so large platform totals never lose precision the way a number does past 2^53.
const price = toAmount('CREDIT', 1000n); // 10.00 CREDIT
encodeAmount(price); // → 'CREDIT:10.00'
A whole unit is SCALE (100n) minor units — two decimal places, fixed. toAmount is the only constructor, and the __brand field makes a plain { currency, minor } object unassignable to Amount. So every value has to pass through toAmount or decodeAmount, and the rules here can't be quietly bypassed.
Two guards keep currencies from mixing by accident. add and compare call assertSameCurrency, which throws CURRENCY_MISMATCH if you hand them two different currencies — combining a CREDIT amount with a USD amount is always a bug. And decodeAmount rejects a string with more than two decimal places (INVALID_AMOUNT) rather than silently dropping the extra digits.
Why two rates exist, and where the fee lives
If a credit cashed out for exactly what you paid, the platform would earn nothing on it — so a credit costs more to buy than it pays back at cash-out. The Rates port supplies three fixed CREDIT-to-USD rates from an audited source, never from caller input. They always hold the order buy ≥ par ≥ payout:
| Rate | What it is | Example |
|---|---|---|
buy | The acquisition rate — what a user pays per credit when buying. | ~120 credits / USD ($0.00833) |
par | The redemption/backing rate — a credit's cash-out floor, and the value the backing check holds real USD against. | ~200 credits / USD ($0.005) |
payout | The creator settlement rate — what an earned credit converts to USD at. Equals par. | ~200 credits / USD ($0.005) |
The buy-to-par gap is the platform spread — about 40% on the example rates above. It's the platform's margin, and it funds fees, payment processing, reserves, and operating costs. The spread is taken once, at purchase, and never mixed into the cash held to back users.
Walk a $10 purchase through: it becomes 1,200 credits. Of those, $6.00 (1,200 × par) is set aside as backing, and $4.00 is the platform's to keep. And because payout = par, a credit a creator earns is worth exactly the dollars reserved for it.
A Rate is stored as exact integers — { rate, scale, rateId } — with the multiplier rate / 10^scale (so rate: 5n, scale: 3 is $0.005 per credit). The rateId names the exact rate. That lets a transaction record which rate it used, and lets a payout saga lock to one. Conversion floors: usd_minor = floor(credit_minor × rate / 10^scale).
The invariant: buy ≥ par ≥ payout
Why does the ordering matter? Each inequality protects a different promise the platform makes.
buy ≥ par is what keeps the platform's margin non-negative. A top-up values the buyer's cash at buy and the backing held in trust at par. It recognizes the difference as USD revenue, added as a posting line only when it's positive. If buy ever dropped below par, that difference would go negative and a purchase would book a loss.
The reference configuredRates adapter takes buy, par, and payout as separate deployment constants. It doesn't enforce the ordering in code, so a deployment that miswires them breaks this invariant. The ordering is a contract on the rate source, documented on the Rates port.
par ≥ payout (in practice payout = par) ties the creator's cash-out to the same value the platform reserved. It's why a credit earned is redeemable for exactly the dollars set aside for it. That's what solvency checks: real USD in trust covers every spendable credit valued at par.
How the spread is enforced — not a separate fee
You won't find a "purchase fee" anywhere in the code, and that's by design. The spread is realized by the split at top-up, not by a deduction. Because cash is valued at buy going in and backing at par, the margin just falls out of buy − par.
The marketplace fee on a sale is a different thing entirely, and not part of the money model. How a sale's price splits into recipient shares and platform revenue is the pricing policy's job. You can see the balanced Legs it produces in accounts and double-entry.
What relies on it
- Accounts and double-entry — every posting is a balanced set of
Legs whose amounts net to zero in each currency. TheAmountguards keep a posting from mixing currencies on one line. - Solvency — the backing check values spendable credits in USD at
parto confirm trust cash covers them. - The
Ratesport — suppliesbuy/par/payout; top-up and the payout saga read it. - The
FeePolicyport — the marketplace fee and recipient split, the only withholding outside the buy-par spread.