An append-only loyalty point ledger with derived balances and country-specific reward rules.
A small, focused demonstration of one idea: the ledger is the source of truth, and balances are computed - never stored and mutated. This is the same pattern behind the loyalty wallet I built for GlamGuider, a consumer platform with 90K+ registered users and 80K+ active wallets; this repo extracts the core design into ~150 lines you can read in five minutes.
Most naive point systems store a balance number on the user and add/subtract from it:
user.balance += 50; // fragileThe problem: if anything goes wrong - a race condition, a half-applied transaction, a bug in one code path - the stored balance silently drifts away from reality, and you can't tell why a balance is what it is.
This ledger never does that. It stores an immutable list of transactions and derives the balance by summing them:
balanceOf(userId) = sum of every transaction for that userevent → rule engine → grant log (append-only) → derived balance → wallet API
Consequences of this design:
- Auditable - every balance can be explained by replaying its history.
- Correct by construction - there's no separate balance to drift out of sync.
- Append-only - mistakes are fixed by adding a compensating entry, never by editing history (the same principle as accounting double-entry or event sourcing).
Reward amounts depend on the user's country, kept as data rather than scattered conditionals - so adding a country is a config change, not a code change:
IN: { signup: 50, review: 10, referral: 100 }
US: { signup: 100, review: 20, referral: 200 }
// unknown countries fall back to DEFAULTIn production this is exactly how multi-region reward rules stay maintainable: the rule table grows, the engine doesn't change.
npm test # 8 passing tests
npm run demo # see the ledger in actionExample demo output:
alice balance: 70 # IN: signup(50) + review(10) + review(10)
bob balance: 120 # US: signup(100) + review(20)
alice redeems 40 → balance: 30
alice redeems 1000 → blocked: Insufficient balance
audit trail:
+50 (earn:signup:IN)
+10 (earn:review:IN)
+10 (earn:review:IN)
-40 (discount-coupon)
Balance is the sum of the trail above - never stored, always derived.
A balance can never go negative. redeem() checks the derived balance before appending a debit and refuses if there aren't enough points. Everything else is just credits and debits on an immutable log.
| In this repo | In production (80K+ wallets) |
|---|---|
transactions array |
append-only DB collection (no UPDATE/DELETE) |
balanceOf() summing in memory |
same query, with periodic balance snapshots for performance |
| frozen transaction objects | DB-level immutability constraints |
| in-process call | a service behind an API, one transaction per request |
At high volume you add balance snapshots (store a checkpoint every N transactions and sum only entries after it) so you don't replay all history on every read - but the source of truth stays the log. A streak & milestone engine sits on top of the same grant log in production: streaks are just queries over the event history, which the ledger already preserves for free.
src/ledger.js - the append-only ledger + derived balance
src/rules.js - country-specific reward rules (as data)
test/ledger.test.js - 8 tests covering earning, redeeming, audit trail, immutability
demo.js - runnable walkthrough
Built by Mahesh Konar as a focused demonstration of ledger design. MIT licensed.