Skip to content

Search documentation

Search documentation pages, sections, and topics.

On this page

Money

Monetary amounts in Ledfra are strings like USD:42.01, never JSON numbers. Precision is exact end to end, and nothing is ever rounded through a floating-point value on the way in or out.

Why storing money in a database is its own problem

Every team building a product that handles money makes this decision early, usually in about four seconds, and usually wrong. The three common answers all fail differently:

  • A float or double. 0.1 + 0.2 is not 0.3 in binary floating point, and it never will be. Errors are tiny individually and accumulate silently across millions of rows, which is the worst possible failure mode: nothing breaks, the numbers are simply wrong by an amount that grows.
  • A string or varchar with no structure. Precision survives, but nothing else does: no validation, no currency, and every read is a parse that some caller will get wrong.
  • An integer of minor units. The right instinct, and the standard answer. But it only works if the scale is carried alongside it and never mixed up, and it quietly breaks for currencies that don't have two decimal places or that need more precision than an int4 can hold.

Ledfra makes the decision once, at the boundary, so it cannot be made differently by two services later. On the wire an amount is a string that carries its own currency. Internally it is stored as an exact integer of minor units, wide enough that large values in high-precision currencies cannot overflow it.

The amount string format

The format is <currency_code>:<amount>:

  • Currency code - three or more uppercase letters (USD, JPY, BTC).
  • Amount - a positive decimal, written the way the currency is written.
ExampleMeaning
USD:42.0142 dollars and 1 cent
JPY:1010 yen, no minor units at all
BTC:0.00000001one satoshi, eight decimal places

Because the currency travels with the amount, a value is never ambiguous in transit and a client can never accidentally apply the wrong scale to it. There is no separate currency field to forget, and no unit convention to agree on out of band.

Direction is never a sign

Amounts are always positive. Which way money moved is expressed by the entry's operation, DEBIT or CREDIT, not by a minus sign.

json
{
  "description": "Booking 1043 paid",
  "entries": [
    { "account": "assets:stripe",              "operation": "DEBIT",  "amount": "USD:100.00" },
    { "account": "income:commission",          "operation": "CREDIT", "amount": "USD:10.00"  },
    { "account": "liabilities:payables-hosts", "operation": "CREDIT", "amount": "USD:90.00"  }
  ]
}

This is deliberate, and it removes a whole class of bug. With signed amounts there are two ways to express the same movement and no way for the system to tell a deliberate negative from a sign error. With operations there is exactly one representation, and the balancing rule (debits must equal credits) can actually check it. See Transactions for why a debit increases an asset but decreases a liability.

Rules that keep amounts correct

  • The currency must belong to the ledger. Each amount is validated against the ledger's configured currency. An unknown or mismatched one is rejected with a 400 rather than coerced.
  • Respect the currency's precision. Use only as many decimal places as the currency supports: USD:42.01 is valid, USD:42.014 is not. Ledfra rejects the extra digit instead of silently rounding it away, because silent rounding is how money goes missing.
  • Amounts are positive. A negative amount is a validation error - use the opposite operation instead.
  • Never round-trip a display string. Format for humans at the edge, and send the exact value back. A number that has been through a currency formatter is no longer the truth.

A ledger that holds more than one currency

A platform collecting in EUR and paying out in USD has a choice to make, and both of the obvious answers are bad. Two ledgers means two charts of accounts to keep in step and no single place to ask what you owe. One ledger with a converted figure means every balance is only as true as the rate you converted at, and the rate moves.

Ledfra takes the third answer: one ledger, several currencies, and no exchange rate between them. An account holds one currency for its lifetime, and a ledger holds as many as you need. A payout account in EUR and a payout account in USD are separate accounts that never mix, and every transaction balances within each currency it touches rather than across the transaction as a whole.

So a single transaction can move both, as long as each side of each currency agrees. Charging a European customer and paying a US supplier in one posting is legal. Debiting dollars and crediting euros to answer them is not, because that only nets to zero if the two are treated as the same unit:

json
{
  "description": "EUR collection and USD payout, one posting",
  "entries": [
    { "account": "assets:stripe:eur", "amount": "EUR:100.00", "operation": "DEBIT" },
    { "account": "income:fees:eur",   "amount": "EUR:100.00", "operation": "CREDIT" },
    { "account": "expenses:payouts",  "amount": "USD:40.00",  "operation": "DEBIT" },
    { "account": "assets:stripe",     "amount": "USD:40.00",  "operation": "CREDIT" }
  ]
}

Recording a conversion your processor made

Money does change currency. Stripe converts a EUR balance into USD before a payout, or a bank exchanges it on your instruction. Ledfra records that conversion at the amounts it actually happened at, with one exchange account in each currency so that each side balances on its own:

json
{
  "description": "Stripe currency conversion",
  "entries": [
    { "account": "assets:currency-exchange:eur", "amount": "EUR:92.00",  "operation": "DEBIT" },
    { "account": "assets:stripe:eur",            "amount": "EUR:92.00",  "operation": "CREDIT" },
    { "account": "assets:stripe",                "amount": "USD:100.00", "operation": "DEBIT" },
    { "account": "assets:currency-exchange:usd", "amount": "USD:100.00", "operation": "CREDIT" }
  ]
}

The EUR balance falls by the 92 euros that left and the USD balance rises by the 100 dollars that arrived, both exactly as the processor reported them. The rate is implied by the two amounts, and Ledfra never stores or applies it. Allow a negative balance on both exchange accounts, since each one moves in whichever direction you convert.

What you get back: one set of statements per currency

Every report answers per currency. A ledger holding EUR and USD produces a EUR balance sheet and a USD balance sheet, a EUR trial balance and a USD one, and so on. There is no grand total across them and there is deliberately no way to ask for one, because the only honest figure would be a sum of unlike units.

That is a real opinion rather than a missing feature, and it is the same call the accounting packages make once the question becomes unavoidable. Sage Intacct removes the grand total when you enable multiple base currencies rather than blending them. A trial balance proves debits equal credits, and it can only prove that one currency at a time.

The limit, and what it costs you
There is no exchange rate anywhere in Ledfra. No rate table, no revaluation, no gain or loss accounts, and Ledfra never moves money between currencies itself: it records the conversions your processor or bank made. A platform that needs one consolidated figure - for a board pack, a tax return, or a group report - takes the per-currency figures and applies its own rate outside Ledfra, at a date it chooses and can defend. What you gain for that is that no figure inside the ledger is ever quietly wrong because a rate moved after it was written.

Where to go next

  1. Transactions - how entries pair up and why every transaction must balance.
  2. Accounts & templates - what these amounts post against.
  3. Quickstart - post a real amount through the API.
  4. What is Ledfra? - the model these amounts live in.