How We Track Every Dollar Across Borders
A walkthrough of the double-entry bookkeeping system behind Netaro's cross-border payments. The 500-year-old principle and the code that runs it.
Every transaction touches two accounts. The equation always balances.
If you're building a fintech product and your accounting looks like balance += 100 on deposit and balance -= 50 on withdrawal, you've got a problem. It works fine until someone asks: "Where is every dollar right now?"
Not "what's the user's balance," but where are the funds physically? Can you prove that the total across all your bank accounts, wallets, and liquidity partners covers exactly what you owe all your users, down to the cent?
A single balance field can't answer that. The solution is a 500-year-old invention. In 1494, an Italian friar named Luca Pacioli published the first description of double-entry bookkeeping. Every bank, exchange, and financial institution still uses it today. This is how we implemented it at Netaro.
The accounting equation, for engineers
The fundamental equation is:
Mapped to a payments platform, it becomes concrete:
- ASSET = funds we physically hold at custody providers (crypto wallets, liquidity partner accounts, bank accounts)
- LIABILITY = what we owe customers (their balance on our platform)
- EQUITY = company treasury (Netaro's own funds)
A key insight: on the asset side, there are no customers. Customer A's $50 and Customer B's $200 are just "$180 in our bank account and $70 stablecoin in our crypto wallet." The asset side only cares where the money is, not whose it is. That's the liability side's job. The ASSET accounts track where funds physically are, the LIABILITY accounts track what we owe each customer, and the accounting equation guarantees the totals always match. This is exactly how banks work too.
When a customer deposits $100 via bank transfer, two things happen simultaneously:
Assets = Liabilities + Equity — both sides increased equally.
The equation holds. Every movement of money touches two accounts, and the books always balance.
So far we've been using +/- to show accounts going up and down. But in practice, accountants don't use +/-. They use two words: debit and credit. You probably know these from your bank account: debit means your balance goes down, credit means it goes up. But that's only true for your bank account. For an asset account, it's the opposite: debit increases the balance, and credit decreases it. Same words, opposite effect. Let's untangle this.
Left side, right side
Let's go back to the equation:
Assets live on the left. Liabilities and equity live on the right. Every account has a home side.
Now, every account is tracked with two columns, a left column and a right column:
And the rule is simple: to increase an account, write on its home side. To decrease it, write on the opposite side.
Example 1: Customer deposits $100.
Our bank account is an asset, home side is left. The customer's balance is a liability, home side is right.
Assets: $100. Liabilities: $100. Equation balanced.
Example 2: Customer withdraws $40.
Now we need to decrease both accounts. Decrease = write on the opposite side:
Assets: $60. Liabilities: $60. Equation still balanced.
Example 3: Netaro earns a $0.02 fee.
The customer's balance (liability) decreases. Netaro's revenue (equity, right side) increases:
Liabilities: $59.98. Equity: $0.02. Assets: $60.
Equation balanced: $60 = $59.98 + $0.02.
Example 4: Customer A sends $10 to Customer B.
Both are liability accounts. The money stays in the same bank account, we're just changing who we owe it to:
No asset moved. The bank account didn't change. Only the liabilities shifted: we owe A less and B more.
Assets: $60. Liabilities: $49.98 + $10 = $59.98. Equity: $0.02. Equation still balanced.
If you look at all four examples, every transaction writes once on the left and once on the right, across two accounts. Both sides move equally, so the equation stays balanced.
Those left and right columns have proper names. A left-column entry is called a debit. A right-column entry is called a credit.
The words come from Latin: debere ("to owe") and credere ("to entrust"). Italian merchants originally used them to track who owed them and who they owed. As accounting grew beyond tracking debts between people, the words lost their literal meaning, but the left/right convention stuck. Every accounting system still uses it, including the one running on our servers right now.
This also explains why a debit card is called a "debit" card. Your bank account is a liability from the bank's perspective. They owe you that money. Its home side is the right. When you swipe your debit card, the bank writes on the left (a debit, the opposite side), decreasing what they owe you. Your balance goes down.
In practice, accountants don't draw T-accounts for every transaction. They write journal entries, a compact format that lists each debit and credit. Example 1 (customer deposits $100) as a journal entry:
Debit column on the left, credit column on the right, credit entry indented. Same rules, just more compact. This is the journal entry format used by every accounting system.
How we modeled it in code
We use a single unified accounts table with an account_type enum:
class AccountType(str, Enum):
ASSET = "ASSET" # Debit increases, Credit decreases
LIABILITY = "LIABILITY" # Credit increases, Debit decreases
EQUITY = "EQUITY" # Credit increases, Debit decreases
One table for all account types, not separate tables. This lets us run a single query to check the accounting equation across the entire system.
The account model:
class Account(Base):
__tablename__ = "accounts"
id = Column(UUID, primary_key=True)
user_id = Column(UUID, ForeignKey("users.id"), nullable=True)
# USD, PHP, USDC...
asset = Column(Enum(Asset), nullable=False)
_balance = Column(
"balance", Numeric(20, 8), default=0)
account_type = Column(
Enum(AccountType), nullable=False)
# Which provider/wallet holds the funds
custody_pool_id = Column(UUID, nullable=True)
wallet_id = Column(UUID, nullable=True)
@property
def balance(self):
"""Read-only. Use apply_ledger_entry()."""
return self._balance
Notice _balance is private. The public balance property is read-only. Only ledger_service.py is authorized to call _modify_account_balance(), a "friend pattern" that ensures every balance change goes through proper double-entry bookkeeping.
Every completed transaction creates immutable ledger entries: no updates, no deletes. Once written, it's truth:
class LedgerEntry(Base):
__tablename__ = "ledger_entries"
id = Column(UUID, primary_key=True)
transaction_id = Column(UUID, ForeignKey("transactions.id"))
debit_account_id = Column(UUID, ForeignKey("accounts.id"))
credit_account_id = Column(UUID, ForeignKey("accounts.id"))
amount = Column(Numeric(20, 8), nullable=False)
asset = Column(Enum(Asset), nullable=False)
created_at = Column(DateTime, server_default=func.now())
apply_ledger_entry() applies the debit/credit rules based on account type:
def apply_ledger_entry(
db, debit_account, credit_account,
amount, asset, transaction_id):
# Create immutable record
entry = LedgerEntry(
transaction_id=transaction_id,
debit_account_id=debit_account.id,
credit_account_id=credit_account.id,
amount=amount,
asset=asset,
)
db.add(entry)
# Debit side: ASSET increases, LIABILITY/EQUITY decreases
if debit_account.account_type == AccountType.ASSET:
debit_account._modify_account_balance(+amount)
else:
debit_account._modify_account_balance(-amount)
# Credit side: ASSET decreases, LIABILITY/EQUITY increases
if credit_account.account_type == AccountType.ASSET:
credit_account._modify_account_balance(-amount)
else:
credit_account._modify_account_balance(+amount)
return entry
This small piece of code is the core of the debit/credit system. And because it runs inside a database transaction, it's atomic: both sides of the entry commit together, or neither does, so money can't vanish into thin air.
But what about reversals, you'll ask? A refund or a failed payout is just a new ledger entry with the accounts flipped. The original entry stays untouched, and the audit trail is preserved. Nothing is ever deleted or modified.
The FX swap, where it gets really interesting
A single-currency deposit is straightforward. But what happens when a customer sends money to the Philippines? The USD needs to become PHP: two simultaneous journal entries in one atomic transaction:
USD side: Assets -$100, Liabilities -$100. Balanced.
PHP side: Assets +₱5,910, Liabilities +₱5,910. Balanced.
The accounting equation holds per asset. The PHP then gets cashed out to the recipient in a separate step, another pair of journal entries following the same pattern.
One thing worth noting: many FX systems use a settlement account to hold funds during the swap window, because there's a delay between selling one currency and receiving another. Our provider settles instantly, so we skip it. No interim account, no pending state: the USD leaves and the PHP arrives in the same atomic transaction. When the infrastructure allows it, fewer accounts means fewer things to reconcile.
Trust, but verify: the reconciliation worker
Every night at 3 AM UTC, an automated worker checks the math:
DISCREPANCY_THRESHOLD = Decimal("0.01")
async def verify_proof_of_reserve(db, stats):
for asset in all_assets:
asset_sum = sum_balances(asset, AccountType.ASSET)
liability_sum = sum_balances(asset, AccountType.LIABILITY)
equity_sum = sum_balances(asset, AccountType.EQUITY)
# ASSETS - LIABILITIES - EQUITY must equal zero
discrepancy = asset_sum - liability_sum - equity_sum
if abs(discrepancy) > DISCREPANCY_THRESHOLD:
if discrepancy < 0:
alert_critical("SHORTFALL", asset, abs(discrepancy))
else:
alert_warning("SURPLUS", asset, discrepancy)
A shortfall (assets less than obligations) is a critical alert. But a surplus is still a bug worth investigating. Either way, the system catches it.
Automated proof-of-reserve, every night.
Every dollar, accounted for
This is the system we built to move billions of dollars across borders at Netaro. At any moment, we can answer the question we started with: where is every dollar right now?
Every movement is traceable from ledger entry to transaction to user, and every cent we hold at our custody providers is provably matched against what we owe. Nothing in the ledger is ever modified or deleted. When we add a new currency, a new payment rail, or a new custody provider, we just add new accounts. The core accounting logic doesn't change.
The same principle Pacioli wrote down five hundred years ago, just running on RDS instead of parchment.