←
Building a Wallet on FRAGMENT
Jun 1, 2026Steven Klaiber-NobleSoftware Engineer

A wallet holds a balance for each user, takes deposits, moves money between users, and stays in sync with a payment processor. This guide builds one twice: once in plain application code, once on FRAGMENT.

The plain version starts easy, and for a while it should. A balances table, a transactions table, a CHECK (cents >= 0) so a debit can't overdraw, a unique index so a retry can't double-post — all inside one database transaction. For a toy wallet that is the whole job, and Fragment would be overkill.

This guide is about what the plain version becomes when the wallet is the product's spine and an agent extends it for a year: when money has to be conserved, history has to be provable, and the model keeps changing. The floor check and the retry guard are the easy part — a CHECK and a unique index already handle them, and we'll take them for granted. The rest is what you end up rebuilding by hand, badly, as a worse ledger.

Before you start

You need a FRAGMENT workspace, an API client (Dashboard → API clients), and an SDK installed. See Install the SDK. The examples are Python; the shape is the same in every SDK. In the snippets below, fragment is the SDK client you set up there.

Step 1 — Money is conserved

The rule. Money is never created, only moved. Every event takes from one account and gives to another by the same amount, so the whole system always sums to zero.

Pure code. The v1 is correct, and the database enforces the floor on every path, no matter who writes it:

the v1 schema
balances(user_id pk, cents bigint not null default 0 CHECK (cents >= 0))
ledger(id, user_id, cents, kind, created)
pure code: deposit, then the path that creates money
# deposit: move money in, atomically
def deposit(user_id, cents):
    with tx():
        execute("UPDATE balances SET cents = cents + %s WHERE user_id = %s", cents, user_id)
        execute("INSERT INTO ledger (user_id, cents, kind) VALUES (%s, %s, 'deposit')", cents, user_id)

# later, an agent adds promo credits. locally reasonable, and the CHECK is happy:
def apply_promo(user_id, cents):
    with tx():
        execute("UPDATE balances SET cents = cents + %s WHERE user_id = %s", cents, user_id)
        execute("INSERT INTO ledger (user_id, cents, kind) VALUES (%s, %s, 'promo')", cents, user_id)
        # nothing was debited to fund this. money just appeared.

The CHECK guards the floor on every path — keep it. What it can't see is that the promo created money: nothing was debited to fund it, so the system no longer sums to zero. You find out at month-end, when the books don't reconcile and nothing was ever responsible for keeping them honest.

On Fragment. The wallet lives in one schema: the JSON document describing a ledger's accounts and the entries allowed against it. The ledger is the live instance — it holds the balances, and it is your state store for money, not a copy beside your database.

schema: accounts
{
  "key": "wallet",
  "chartOfAccounts": {
    "assets": { "banks": { "user-cash": {} } },
    "liabilities": {
      "users": {
        "template": "user",
        "available": { "consistencyConfig": { "totalBalanceUpdates": "strong" } }
      }
    },
    "expenses": { "promotions": {} }
  }
}

A deposit is one entry with two lines: money leaves assets/banks/user-cash and lands in the user's balance. A promo is also one balanced entry — it comes out of expenses/promotions, not from nowhere.

schema: deposit entry
{
  "type": "deposit",
  "description": "Fund {{user_id}} for {{amount}}",
  "lines": [
    { "key": "from_bank", "account": { "path": "assets/banks/user-cash" }, "amount": "{{amount}}" },
    { "key": "to_user",   "account": { "path": "liabilities/users:{{user_id}}/available" }, "amount": "{{amount}}" }
  ]
}

An entry that doesn't balance is rejected, so there is no way to record money appearing — a promo has to say where it came from. A CHECK constraint does balance >= 0; it cannot make the system net to zero. That is double-entry, and it is the property a transactions table never gives you.

Posting a deposit is the whole of an API endpoint — authenticate, authorize, forward one entry:

on Fragment: the deposit endpoint
@app.post("/deposit")
async def deposit(req):
    user = authenticate(req)              # who is this
    require_can_deposit(user)             # are they allowed
    await fragment.add_ledger_entry(
        ik=req.idempotency_key,           # retries collapse to one post
        type="deposit",
        parameters={"user_id": user.id, "amount": req.amount},
    )

The ik makes retries safe — the easy twenty percent, handled by an argument. The rest of the guide is the part a constraint can't reach.

Step 2 — One event, many accounts

The rule. A real money movement touches several accounts at once, and all of it lands or none of it does.

Pure code. The transfer started wallet-to-wallet. Now it charges a fee: the sender pays 100, the recipient gets 98, the platform keeps 2.

pure code: the split now lives in application code
def transfer(from_id, to_id, gross, fee):
    with tx():
        net = gross - fee                 # arithmetic, re-derived in every flow with a fee
        debit(from_id, gross)             # CHECK enforces the floor here
        credit(to_id, net)
        credit_revenue(fee)
        # forget one line, or get net wrong, and money leaks -- the CHECK still passes

Every flow with a fee — payouts, refunds, card loads — re-derives this split in application code. The floor check still fires on the debit, so each version looks fine; the leak is in the arithmetic and the set of accounts touched, which nothing reconciles.

On Fragment. Model the event as one entry with three lines: debit the sender, credit the recipient, credit revenue.

schema: transfer with a fee
{
  "type": "transfer",
  "description": "{{from_id}} sends {{amount}} to {{to_id}} (fee {{fee}})",
  "lines": [
    { "key": "debit_sender",    "account": { "path": "liabilities/users:{{from_id}}/available" }, "amount": "-{{amount}}" },
    { "key": "credit_recipient","account": { "path": "liabilities/users:{{to_id}}/available" },   "amount": "{{amount}} - {{fee}}" },
    { "key": "take_fee",        "account": { "path": "income/revenue" },                          "amount": "{{fee}}" }
  ],
  "conditions": [
    { "account": { "path": "liabilities/users:{{from_id}}/available" },
      "postcondition": { "totalBalance": { "gte": "0" } } }
  ]
}

A plain transaction already makes the three writes atomic; what it doesn't do is force them to balance. Here the lines must sum to zero or the entry is rejected, so the fee can't be dropped or double-counted, and a new flow is a new entry type rather than another hand-derived split. (The postcondition is the overdraft floor — the part a CHECK already did; it rides on the same entry, checked in the transaction that moves the money.)

Step 3 — History you can prove

The rule. You can ask what any balance was at any past moment, and a correction is a recorded event, not an overwrite.

Pure code. A balances column has only a present.

pure code: the correction that erases the past
# support says a balance is wrong; an agent "fixes" it
def correct(user_id, cents):
    execute("UPDATE balances SET cents = %s WHERE user_id = %s", cents, user_id)
    # the number is right now, and the history is a lie

To answer "what was this balance in March" for an auditor or a dispute, you start writing balance snapshots and replaying transactions — event-sourcing a ledger on top of Postgres, which is the thing you were avoiding.

On Fragment. Entries are append-only and balances are derived from them. You read a balance as of any date, and a correction is a reversing entry that leaves the original in place.

on Fragment: history is the source of truth
balance_in_march = await fragment.get_balance(
    account={"path": f"liabilities/users:{user_id}/available"},
    at="2026-03-31T23:59:59Z",
)

# a correction reverses the bad entry; both stay in the history
await fragment.reverse_ledger_entry(id=bad_entry_id)

The history is the source of truth, not something you reconstruct. Read more in Read balances and Reverse ledger entries.

Step 4 — The model keeps changing

The rule. The money model will change — new account types, fees, currencies, a payment processor — and every past posting has to stay valid and reconcilable.

Pure code. Each change to the balances or transactions shape is a migration, and every backfill that rewrites historical rows is a chance to break reconciliation silently. The schema and the history are the same mutable tables, so changing one risks the other.

On Fragment. You evolve the schema, and Fragment migrates the ledger. Past entries stay valid because nothing rewrites them — they're append-only (Step 3), and balances are re-derived from them, so a new account type or currency adds shapes without touching the old postings. Linking a payment processor is just another account: a linked account maps each external charge to one line by its external_ref, so the same charge — first webhook, retry, or later backfill — reconciles to the same entry instead of posting twice.

schema: link a processor account
{
  "assets": { "banks": { "user-cash": { "linkedAccount": "stripe" } } }
}
on Fragment: reconcile a charge
await fragment.reconcile_tx(
    type="deposit",
    parameters={"user_id": user_id},
    external_ref=charge_id,        # the same deposit entry, driven by the charge
)

Read more in Design your Ledger and Reconcile payments.

What you end up with

Both wallets work the day they're written. The plain one even enforces the floor on every path, because the database does. The difference is everything the database can't enforce — conservation, immutable history, safe schema change — and in the plain wallet each of those lives only in whatever the last agent remembered.

The rules are data now, not habits. An agent can add a money movement, but it adds it as an entry type the ledger validates — verbose JSON it generates in seconds — instead of a code path that quietly creates money or overwrites a balance.

Where the logic ended up

A money-moving app has three places to put logic: the client, your API server, and the ledger that holds the money.

Your API server is thin. Every endpoint authenticates the caller, checks they're allowed, and forwards one entry. It holds no balances, no conservation rule, no history; those live in the ledger. What's left is the part only you can define — who someone is and what they're allowed to touch — which is the last thing to hand to a generated config or a thick client.

Fragment is the system of record for the money. The balances and every movement live there, durable, with consistency you set per account: strong for balances you authorize against, eventual for balances you only report. It's on the write path the way your database is, because for this part of the app it is the database. It's also a hosted service on that path, so it has to earn the trust your own database has — durability, availability, the throughput a ledger needs; Under the hood has that story, worth reading before you route real money through it.

That lets the client carry more. The orchestration that used to need a fat backend service can move toward the client, because each step it calls is a guarded entry, not a chance to corrupt the ledger. The client never holds the rules or the credentials; it talks to your thin API.

Here is the honest version of the choice. If the ledger is incidental to your product — a few credits on a SaaS app — a balances table and a trigger are the right call, and Fragment is overkill. If money movement and its provable correctness are the spine of what you sell, the hand-rolled version is how you accidentally build a worse ledger, maintained by a team that never signed up to. Thick client, thin API, the money in a ledger built to hold it.

Where to go next