Payments & compliance

Ledger wallet

A customer wallet as an append-only ledger with a balance per currency: Stripe Checkout top-ups, orders paid from the wallet, by card or split between them, refunds to source and staff adjustments.

Released
September 28, 2026
npx skills add timerise-ai/ledger-wallet
v0.1.4
Current release
16
Reference docs
5
Non-negotiables
MIT
License

The problem

What problem does Ledger wallet solve?

Gyms, studios, clubs and venues sell prepaid credit. A customer tops up a balance and spends it on bookings, tickets or services. Some orders are paid from the balance, some by card, and some by both.

Storing a number is the easy half. The hard half is keeping it right while requests retry, webhooks arrive twice or never, and a card and a balance pay for the same order. A balance that can be spent twice, or a top-up credited twice, is money lost or a customer wronged.

This skill gives a coding agent the wallet the way we build it: every balance is the sum of an append-only ledger, every movement is keyed by what caused it so a retry changes nothing, and a mixed payment holds the balance part until the card pays. Refunds go back to where the money came from. Staff adjustments need a reason, and the history explains every balance.

The module

What does the skill build?

An agent with this skill builds one module for a Next.js App Router app, on your stack. For Ledger wallet that module consists of:

  • 1. Stripe Checkout top-ups
  • 2. Orders paid from the wallet
  • 3. By card or split between them
  • 4. A hold on the balance part until the card pays
  • 5. Refunds to source
  • 6. Staff adjustments with a reason
  • 7. Every movement keyed by a ref so a retry replays

Provenance

Where do the rules come from?

This skill was written by the engineer who has shipped this module. The earlier implementation it was audited against was the stored-credit balance of a multi-location venue-booking system. The templates hold the properties a wallet has to hold: a top-up is credited once, for what Stripe collected, however often the event arrives; a wallet payment commits with its order or not at all; a balance never goes below zero under concurrent spends; a hold settles exactly once; a refund returns what was paid, per source; a balance in one tenant is never spendable in another; the sum of entries equals the balance. The suites, 97 tests on Firestore and Postgres, state each one, and `references/provenance.md` has the record.

The wallet described here was audited against the earlier implementation: a stored-credit balance in a multi-location booking system, with card top-ups through Stripe Checkout, full and partial payment of orders from the balance, refunds to the balance, and several deployments sharing one database. The audit read every path that credited, debited or displayed a balance, and every screen that showed one.

The ledger separates what the audit changed, what was kept on purpose, and what has not run in production yet.

  1. Fixed in the templates

    • Cancelling an order refunded its current total, whatever was paid Cancelling a confirmed order credited the order's stored total to the balance, regardless of how, or whether, it had been paid: an order still awaiting payment at the counter became free credit, and the card-paid part of a mixed payment came back as store credit. The stored total could also be raised after payment through a second edit route whose key check passed when the header was absent. Shipped: refundOrder takes the amounts the order recorded when it was paid, per source, and sends each part back to where it came from (split-payment.md, integration.md). Tests: the four refund tests in test/stripe.test.ts.
    • The cancel refund was not atomic and not idempotent The refund read the balance, added to it outside any transaction, and wrote the balance, the entry and the order status as three independent writes. A top-up landing in between was overwritten; the entry and the balance could disagree; two concurrent cancels both refunded. Shipped: every movement goes through postInTx inside one transaction, and the refund's ref is order:<id>:refund (engine.md). Tests: concurrent duplicates credit once, the conformance suite on both real backends.
    • The top-up webhook credited on every delivery The webhook created a new credit for each checkout.session.completed it received, with no check for an earlier delivery and no check of payment_status; asynchronous payment success was not handled. Shipped: the credit's ref is the Checkout session id; credit only on paid or async_payment_succeeded (stripe-top-up.md). Tests: credits once however many times Stripe delivers, does not credit a completed session whose async payment has not settled.
    • The balance was debited before the order existed A full or partial balance payment was debited, and only then was the order created. When creation failed on a capacity conflict, the request answered 409 and the money was gone with no order to recover it from. Shipped: a wallet-only payment is posted inside the order's own transaction; a mixed payment holds instead of debiting (integration.md, split-payment.md). Tests: the order examples in test/postgres.test.ts and test/firestore.test.ts.
    • Paying a pending order from the balance could charge twice The order's pending status was checked outside the transaction that debited, so two concurrent requests both debited. The card session of the same order was not reliably expired, so the card and the balance could both pay it. The failure rollback returned only the latest debit, not the part paid from the balance earlier. Shipped: hold, capture and release keyed by the order, with the Checkout session created first and linked to the hold; capture and release are mutually exclusive. Tests: the holds suite, a racing capture and release settle a hold exactly once on every backend.
    Show 16 more
    • A balance payment fell through to a card payment Choosing to pay from the balance with too little in it silently created a card Checkout instead, including when card payments were switched off. Shipped: planPayment with 'wallet' throws INSUFFICIENT_FUNDS with both numbers; paying the rest by card is a separate, explicit choice. Test: never switches a wallet payment to a card silently.
    • A refund could be recorded without the money Cancelling a paid registration marked it refunded in one transaction and credited the balance in another; a failed credit was logged and dropped, and credits of every kind were recorded as top-ups. Shipped: a refund is one idempotent post that is safe to retry until it succeeds, with its own entry kind (engine.md). The bookable-events adapter keeps that skill's refs (integration.md).
    • One balance was shared by every deployment Balances lived on the customer record, which all deployments sharing the database used. Money paid into one deployment's Stripe account was spendable at another, and credit granted to demo sign-ups was spendable everywhere. Shipped: one wallet per tenant and customer, the tenant always from the session or the webhook endpoint, and a webhook tenant check (data-model.md, stripe-top-up.md). Tests: refs are scoped per tenant, ignores another tenant's session.
    • The ledger could not explain the balance Profile creation, seeds and demo sign-ups wrote balances with no entry; entries had no actor; the entry type did not distinguish a refund from a top-up. The sum of entries did not equal the balance, and nothing checked. Shipped: every change is an entry with a kind, an actor and a reason; seeds and imports post adjustments; reconcile compares, and the staff panel shows the result (operations.md). Test: reconcile is clean after normal use and catches a write that bypassed the ledger.
    • Currency handling Defaults and staff APIs hardcoded a pair of currencies; the preferred currency defaulted to one of them. A zero-decimal currency was stored with two extra digits and rounded at the Stripe boundary, so a top-up of a fractional amount charged and credited a different one. Two formatters produced different output. Top-up amounts were not checked as integers or bounded. Checkout hardcoded a payment-method list that included a method limited to one currency. Shipped: any ISO 4217 code in its real minor unit, a currency list resolved from the skill argument, one formatter, per-currency limits floored at Stripe's minimum, Stripe choosing payment methods (money.md). Tests: test/money.test.ts.
    • The currency choice did not stick The server never stored the customer's choice and always answered with the default; the customer screen applied that answer on every refresh, from a callback with stale dependencies, so the switcher jumped back. Shipped: PUT /api/wallet/preferred-currency, and a panel that reads the preference once (ui.md). Test: the preferred currency defaults to the policy default and must be allowed.
    • Customer screens hid failures A failed top-up request showed nothing; a failed balance request showed zero; the return from Stripe was ignored; history showed one currency at a time without paging; some alerts were in one language only. Shipped: error states with codes and numbers, the return poll, paged history for every currency, keys for every string (ui.md).
    • Operators could see balances and nothing else There was no ledger view, no adjustment with a reason, no record of who changed a balance, and customer lists showed only the default currency, so a customer holding another currency appeared to hold nothing. Customer detail routes by id had no tenant scope, and the wallet's feature flag was enforced by a banner, not by the API. Shipped: the staff routes and panel, customerExists on every [customerId] route, assertWalletEnabled on every customer route (api-routes.md, admin-ui.md). Tests: test/routes.test.ts.
    • Scheduled cleanups were open when their secret was unset The cleanup routes checked the secret only if one was configured. Shipped: the sweeper answers 401 while CRON_SECRET is unset. Test: fails closed when CRON_SECRET is unset.
    • An unused client-side writer was still exported A second copy of the balance functions for the browser wrote its entry outside its transaction, so a retried transaction repeated it. Security rules blocked it and nothing called it, but it was exported next to the server version. Shipped: no client-side writer exists; clients read through the API.
    • A split retried after its Checkout expired held nothing Found by the agent evals of 0.1.2, in the skill's own split payment (see Added). The hold ref was one per order, and a hold settles once, so a second split for the same order replayed the released hold: the new Checkout ran with nothing reserved, and a balance spent in the meantime ended in wallet_short after the card paid. A double click opened a second Checkout against the same hold. Shipped: orderRefs(orderRef, attempt) gives each attempt its own hold ref; startSplitPayment refuses a settled attempt with HOLD_NOT_OPEN and details.nextAttempt, returns the open Checkout of an open one, and expires its own session when a concurrent start won (split-payment.md). Tests: a retry after an expired Checkout holds the wallet part again under the next attempt, a second start of an open attempt returns its Checkout instead of opening another.
    • Reconciliation reported a concurrent movement as drift Found by the agent evals of 0.1.2, in the skill's own reconciliation view. reconcile read the balance and the entry sums as two reads, so a movement committing between them showed a mismatch on the staff panel. Shipped: a mismatch is reported only when two reads in a row agree on it, or after a third (engine.md). Test: reconcile does not report a movement that commits between its two reads.
    • A forged history cursor was a 500 on Postgres Found by the agent evals of 0.1.2, in the skill's own Postgres store. A cursor that decoded to a non-date reached Postgres as an invalid timestamp and failed the query; Firestore and the memory store already read an unknown cursor as none. Shipped: the Postgres store does the same (postgres.md), checked against a real Postgres. Test: pages history newest first without gaps or repeats, on every backend.
    • A redelivered failure skipped a split attempt Found by the agent evals of 0.1.3, in the example split callbacks. 0.1.3 told the host to add 1 to splitAttempt in onSplitFailed, but a redelivered expired event calls it again, and a late redelivery could move the order past an attempt the customer had already started, opening a second Checkout and hold. Shipped: the settlement carries attempt from the session's metadata, and the example sets splitAttempt to max(splitAttempt, attempt + 1), which a redelivery repeats without effect (split-payment.md). Tests: releases the wallet part when Checkout expires and a retry after an expired Checkout holds the wallet part again under the next attempt check the attempt carried.
    • The top-up form stayed busy after an error Found by the agent evals of 0.1.3, in the customer panel. When the top-up route answered an error, the form showed it but left its button disabled until a reload. Shipped: the error branch clears the busy state (ui.md). No test: the suites have no DOM renderer; checked by reading the handler's three exits.
    • A redelivered paid event after a shortfall took the wallet part Found by the agent evals of 0.1.3, in the late-payment path. After wallet_short, a redelivery of the paid event retries the late SPEND, and succeeds if the customer topped up in between, for an order the host has already flagged. Shipped: the rule that the first outcome of a session is final: confirm ignores a flagged order, and the refund of a flagged order includes the entry <holdRef>:late if one exists (split-payment.md). The engine is unchanged; recording the shortfall itself would need an entry kind of its own.
Read the full record in provenance.md

Non-negotiables

What are the 5 rules the module never breaks?

Every module built from this skill holds these, whoever builds it. The same list is in the skill's README and SKILL.md, so the agent reads it before it writes a line.

  1. Every balance change is an entry, posted through the wallet.

    Seeds, imports and corrections included, because a balance written anywhere else can no longer be explained or reconciled. The reconciliation test holds that a write outside the ledger is detected.

  2. Tenant and customer come from the session or the webhook endpoint, never the request body.

    A body field lets anyone move money in someone else's wallet or another tenant's. Tests hold that refs are scoped per tenant, that another tenant's session is not credited, and that another tenant's customer is a 404.

  3. Money moves once, with what it pays for.

    A wallet payment commits in the order's transaction; a mixed payment holds and captures only when the card pays, because a debit before the order exists has nothing to recover it from. The order tests on Firestore and Postgres hold that a failed order moves no money.

  4. Credit only what Stripe collected.

    The route that opens Checkout credits nothing; the webhook credits amount_total once, on a paid session, because the redirect is not proof of payment. Tests hold that a resent event and an unsettled async payment change nothing.

  5. Refund what was paid, to where it came from.

    The amounts the order recorded when it was paid, per source, never its current total, because a total can change after payment and an unpaid order has nothing to refund. The refund tests hold both parts, their idempotency and the unpaid case.

Fit

When should you use it, and when not?

Use it for

  • Customers hold credit and spend it: prepaid visits, top-ups, store credit from refunds or goodwill.
  • An order is paid from the wallet, by card, or by both, and refunds must follow the money back.
  • Balances in several currencies, with the supported list chosen per app.
  • Auditing an existing balance module: provenance.md has the audit ledger and the order of work.

Not for

  • Event registration, tickets, no-show depositsInsteadThe sibling `bookable-events` skill, which takes this wallet as its BalanceLedger
  • Marketplace payouts, connected accounts, subscriptionsInsteadThe sibling `stripe-connect-subscriptions` skill
  • Walk-up touchscreen bookingInsteadThe sibling `booking-kiosk` skill
  • Loyalty points, gift cards, vouchersInsteadA loyalty or voucher system with its own expiry and liability rules
  • Currency conversion, FX walletsInsteadA treasury or FX provider; this wallet never converts

Build it yourself

How do I install it?

One command. The skills.sh CLI installs the skill into every skills-compatible agent it finds.

$ npx skills add timerise-ai/ledger-wallet

Claude Code

Invoke with /ledger-wallet

Codex CLI

Invoke with $ledger-wallet

Gemini CLI

Invoke with /skills

Name the agents instead with -a, for example npx skills add timerise-ai/ledger-wallet -a claude-code -a codex. Or clone the repository into your agent's skills folder. Nothing in it is agent-specific.

What is inside the repository (23 entries)
  • SKILL.mdEntry point: when (not) to use, architecture and the adaptation contract, six critical facts, five hard rules, quick start, reference directory
  • README.mdThis front door
  • CHANGELOG.mdKeep a Changelog, one section per release, newest first
  • CLAUDE.mdWhat this repository is and the conventions for editing the skill itself
  • LICENSEMIT
  • references/data-model.mdRename table, entry kinds and refs, invariants, types, error codes
  • references/money.mdResolving the currency list, minor units for 0, 2 and 3 decimals, Stripe amounts and minimums, limits
  • references/engine.mdThe pure core, the store port, the wallet service and its behavior table
  • references/firestore.mdFirestore store, sharing a transaction, indexes, security rules
  • references/postgres.mdSchema, store over a minimal client interface, how each race resolves, driver traps
  • references/stripe-top-up.mdCheckout top-up, the webhook handler and route, the return page, setup checklist
  • references/split-payment.mdWallet, card or both; holds; host callbacks; refunds to source; the sweeper
  • references/integration.mdPaying inside the order's transaction on both stores, order fields, the bookable-events adapter
  • references/api-routes.mdRoute surface, the host seam file, shared helpers, schemas, every route handler, error contract
  • references/ui.mdCustomer wallet panel, history, data hooks, client seam, the strings table
  • references/admin-ui.mdStaff panel, adjustment dialog, design decisions, extensions
  • references/operations.mdConfiguration, scheduled work, what to watch, procedures, seeds and imports, go-live
  • references/testing.mdVitest config, the in-memory store, the money and engine suites
  • references/testing-payments.mdThe Stripe suite with a fake Stripe, the route suite with the seam mocked
  • references/testing-stores.mdThe store conformance suite and the order atomicity tests on real backends
  • references/provenance.mdThe engineering ledger: what the audit changed and how the templates verify it, what was kept on purpose, what is new in the skill, and the order of work on an existing module
  • evals/The prompts an operator types after installing (prompts.md) and one file per agent eval: the skill installed into an empty Next.js app, one prompt, no help, then type-checked, built and tested
  • .github/workflows/agent-eval.ymlThe caller of the index's eval workflow, copied verbatim from the standard: prompt 1 in Claude Code, Codex CLI and Gemini CLI on every published release, any prompt on a maintainer's dispatch

Recent releases

  1. v0.1.4September 28, 2026

    Fix release, from scoring the prompt-1 agent eval runs against 0.1.3. Apps built from 0.1.3 should copy in lib/wallet/split.ts, lib/wallet/webhook.ts and components/wallet/WalletPanel.tsx, and raise splitAttempt to attempt + 1 in onSplitFailed instead of adding 1.

  2. v0.1.3September 28, 2026

    Fix release, from scoring the prompt-1 agent eval runs against 0.1.2. Apps built from 0.1.2 should copy in lib/wallet/split.ts, lib/wallet/wallet.ts and lib/wallet/postgres-store.ts, and keep a splitAttempt on each order.

  3. v0.1.2September 28, 2026

    Documentation only; the skill content is unchanged from 0.1.1.

After installing

What do I tell my agent?

Say what you need in your own words; the skill supplies the how. These are starting points, and the ones we tested say how it went.

  1. Give customers a wallet on Postgres: top up with Stripe Checkout, pay orders from the balance, by card or split between both, and send refunds back where the money came from.

  2. Sell prepaid credit packs, keep balances in PLN and EUR on Firestore, and show each customer the full history of every movement.

    Firestore
  3. Let staff issue store credit as a refund or a goodwill gesture, with the reason recorded on every entry.

Tested

How does it do in each agent?

We install the skill into an empty Next.js app, give the agent one of the prompts above and no further help, then type-check, build and run the tests it left behind. Nothing is fixed by hand before the checks, and a failing run is published like a passing one. The procedure and every result are public, and the first prompt runs again before each release.

  • Gemini CLI0.61.0

    gemini-3.8-flash

    Built, checks pass
    Give customers a wallet on Postgres: top up with Stripe Checkout, pay orders from the balance, by card or split between both, and send refunds back where the money came from.
    Typecheck: passBuild: passTests: pass
    Time
    10 min
    Changed
    47 files, +5,045 lines
    Stack
    Postgres
    Skill
    v0.1.4
    Run
    Sep 28, 2026

    Result fileAgent log

    What we saw

    Rubric 8/8, scored from the JSON summary. The shipped files as written, vitest.config.mts and the Postgres-only test files at the documented 79 and 1 skipped, identity a signed HttpOnly cookie with header and default identities refused, and a handover naming the currency default, the per-tenant webhook with its four events, the 15-minute cron failing closed without CRON_SECRET, and the identity seam.

  • Codex CLIcodex-cli 0.158.0

    gpt-6-astra

    Built, checks pass
    Give customers a wallet on Postgres: top up with Stripe Checkout, pay orders from the balance, by card or split between both, and send refunds back where the money came from.
    Typecheck: passBuild: passTests: pass
    Time
    10 min
    Changed
    78 files, +6,135 lines
    Stack
    Postgres
    Skill
    v0.1.4
    Run
    Sep 28, 2026

    Result fileAgent log

    What we saw

    Rubric 8/8, scored from the transcript. Only host.ts and host-client.ts among the shipped files were written; the skill's suite ran at its Postgres-only count (79 and 1 skipped) before its own tests were added, and the final message now carries the handover: USD default, both webhook URLs with their four events, the 15-minute crons with CRON_SECRET refusing when unset, and signed-cookie identity. Its README keeps a list of template caveats (the staff panel has no retry after a first network failure, the retired-currency history selector is limited), none of them edited.

  • Claude Code2.1.284

    claude-opus-5-5

    Built, checks pass
    Give customers a wallet on Postgres: top up with Stripe Checkout, pay orders from the balance, by card or split between both, and send refunds back where the money came from.
    Typecheck: passBuild: passTests: pass
    Time
    10 min
    Changed
    76 files, +6,620 lines
    Stack
    Postgres
    Skill
    v0.1.4
    Run
    Sep 28, 2026

    Result fileAgent log

    What we saw

    Rubric 8/8, scored from the JSON summary. Templates used as written with nothing patched, the skill's suite beside 12 tests of its own (100 with Postgres), card-only orders on a webhook of their own, identity a signed HttpOnly cookie with every route 401 until a sign-in exists, and a handover naming USD as the default, both endpoints with their four events, the cron with CRON_SECRET failing closed, and the identity seam. It kept the example split callbacks unused because their refund flag drops the card payment's details.

Build it with Timerise

How long does it take, and what does it cost?

We quote this module per project. The price depends on what it has to connect to. The path to a number is short and free:

  1. Step 1

    Brief

    Tell us what the module must connect to. Takes minutes, in a chat.

  2. Step 2

    Prototype in 48 hours

    A clickable prototype of your system and a quote, at no cost.

  3. Step 3

    Build and handoff

    One project price. Source code, documentation and IP are yours.

Two ways to get Ledger wallet

Build it yourself

Install the skill. Your own agent builds the module.

  • MIT licensed, no strings
  • Runs in Claude Code, Codex CLI and Gemini CLI
  • The same rules our engineers build by
$ npx skills add timerise-ai/ledger-wallet

Build it with Timerise

Send a brief. We build Ledger wallet into a system you own.

  • Clickable prototype and a quote within 48 hours, free
  • One project price, no subscription, no commission
  • Source code, documentation and IP handed over
3 years of support included.

Generated from the skill's own files at commit 2c3d090. Every rule above links to where the repository says it. All skills