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.
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:
refundOrdertakes 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 intest/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
postInTxinside one transaction, and the refund's ref isorder:<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.completedit received, with no check for an earlier delivery and no check ofpayment_status; asynchronous payment success was not handled. Shipped: the credit's ref is the Checkout session id; credit only onpaidorasync_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.tsandtest/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
holdssuite,a racing capture and release settle a hold exactly onceon every backend.
Show 16 moreShow fewer
- 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:
planPaymentwith'wallet'throwsINSUFFICIENT_FUNDSwith 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;
reconcilecompares, 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,
customerExistson every[customerId]route,assertWalletEnabledon 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_SECRETis 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_shortafter 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;startSplitPaymentrefuses a settled attempt withHOLD_NOT_OPENanddetails.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.
reconcileread 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
splitAttemptinonSplitFailed, but a redeliveredexpiredevent 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 carriesattemptfrom the session's metadata, and the example setssplitAttempttomax(splitAttempt, attempt + 1), which a redelivery repeats without effect (split-payment.md). Tests:releases the wallet part when Checkout expiresanda retry after an expired Checkout holds the wallet part again under the next attemptcheck 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 lateSPEND, 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:confirmignores a flagged order, and the refund of a flagged order includes the entry<holdRef>:lateif one exists (split-payment.md). The engine is unchanged; recording the shortfall itself would need an entry kind of its own.
- 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:
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.
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.
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.
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.
Credit only what Stripe collected.
The route that opens Checkout credits nothing; the webhook credits
amount_totalonce, 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.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-walletClaude 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 directoryREADME.mdThis front doorCHANGELOG.mdKeep a Changelog, one section per release, newest firstCLAUDE.mdWhat this repository is and the conventions for editing the skill itselfLICENSEMITreferences/data-model.mdRename table, entry kinds and refs, invariants, types, error codesreferences/money.mdResolving the currency list, minor units for 0, 2 and 3 decimals, Stripe amounts and minimums, limitsreferences/engine.mdThe pure core, the store port, the wallet service and its behavior tablereferences/firestore.mdFirestore store, sharing a transaction, indexes, security rulesreferences/postgres.mdSchema, store over a minimal client interface, how each race resolves, driver trapsreferences/stripe-top-up.mdCheckout top-up, the webhook handler and route, the return page, setup checklistreferences/split-payment.mdWallet, card or both; holds; host callbacks; refunds to source; the sweeperreferences/integration.mdPaying inside the order's transaction on both stores, order fields, the bookable-events adapterreferences/api-routes.mdRoute surface, the host seam file, shared helpers, schemas, every route handler, error contractreferences/ui.mdCustomer wallet panel, history, data hooks, client seam, the strings tablereferences/admin-ui.mdStaff panel, adjustment dialog, design decisions, extensionsreferences/operations.mdConfiguration, scheduled work, what to watch, procedures, seeds and imports, go-livereferences/testing.mdVitest config, the in-memory store, the money and engine suitesreferences/testing-payments.mdThe Stripe suite with a fake Stripe, the route suite with the seam mockedreferences/testing-stores.mdThe store conformance suite and the order atomicity tests on real backendsreferences/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 moduleevals/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
- 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.
- 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.
- 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.
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.
Sell prepaid credit packs, keep balances in PLN and EUR on Firestore, and show each customer the full history of every movement.
FirestoreLet 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.
- Built, checks pass
Gemini CLI0.61.0
gemini-3.8-flash
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
What we saw
Rubric 8/8, scored from the JSON summary. The shipped files as written,
vitest.config.mtsand the Postgres-only test files at the documented 79 and 1 skipped, identity a signedHttpOnlycookie 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 withoutCRON_SECRET, and the identity seam. - Built, checks pass
Codex CLIcodex-cli 0.158.0
gpt-6-astra
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
What we saw
Rubric 8/8, scored from the transcript. Only
host.tsandhost-client.tsamong 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 withCRON_SECRETrefusing 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. - Built, checks pass
Claude Code2.1.284
claude-opus-5-5
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
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
HttpOnlycookie 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 withCRON_SECRETfailing 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:
Step 1
Brief
Tell us what the module must connect to. Takes minutes, in a chat.
Step 2
Prototype in 48 hours
A clickable prototype of your system and a quote, at no cost.
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-walletBuild 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
Generated from the skill's own files at commit 2c3d090. Every rule above links to where the repository says it. All skills