npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@birtalanrobert/game-economy

v2.1.0

Published

A game's premium currency as an append-only ledger: every purchase, grant, spend and refund, reconcilable to the last unit

Readme

@birtalanrobert/game-economy

A game's premium currency — Silver, Bloodstones, Prisms, Sovereigns — as an append-only ledger: every purchase, grant, spend and refund, reconcilable to the last unit.

Four of the seventeen specifications sell a currency of their own (projects 14, 15, 16 and 17), and each asks for the same thing in nearly the same words: a complete audit trail, because retrofitting an auditable ledger into a live economy is not realistically possible. This is that ledger, written once.

Why a ledger, and why lots

A balance stored as a number cannot say where it came from, and a game is asked that constantly: by a player disputing a spend, by an accountant, by a chargeback. So every movement is an entry with the balance it left, and the balance is their sum. The balance column is a cache written in the same transaction, behind CHECK (balance >= 0): the constraint is the guarantee, the ledger the explanation.

Each credit is also a lot, and each debit records which lots it drew on. That is what makes a refund decidable. "A purchase whose currency is entirely unspent may be refunded" is a rule about that purchase, and a balance alone cannot answer it — a player who bought 100, was given 30 and spent 40 has 90 either way, and only the draws say whether the purchase is whole.

import { allocate, isRefundable } from '@birtalanrobert/game-economy';

// Granted currency first, oldest first within a kind.
allocate(lots, 40, ['grant', 'purchase']); // [{ lotId: 'granted', amount: 30 }, { lotId: 'bought', amount: 10 }]

isRefundable({ kind: 'purchase', amount: 100, remaining: 90 }); // false — 10 of it is spent

The root entry point is framework-free: a page can show what a spend would draw on before anything is spent. The service, the entities and the migration are behind @birtalanrobert/game-economy/nestjs.

The spend order is the game's, and it is required

Which credit a debit takes first decides how long a purchase stays refundable. Spending what was granted first keeps what was bought untouched for longer; spending what was bought first does the opposite. That is a policy, and it differs by game and by jurisdiction, so the package assumes none: spendOrder must name every kind of credit once, and a service built without one refuses to start rather than stranding a kind of currency nobody can spend.

Using it in a NestJS application

import {
  GameEconomyModule,
  GameEconomyService,
  gameEconomyEntities,
  gameEconomyMigrations,
} from '@birtalanrobert/game-economy/nestjs';

@Module({
  imports: [
    DatabaseModule.forRootAsync({
      /* entities: [...gameEconomyEntities], migrations: [...gameEconomyMigrations] */
    }),
    GameEconomyModule.forRoot({ spendOrder: ['grant', 'purchase'] }),
  ],
})
export class AppModule {}

// A chapter's reward, credited with the claim that earned it.
await economy.grant(accountId, {
  currency: 'silver',
  amount: 10,
  reason: 'quest.chapter',
  reference: `world:${worldId}:chapter:1`,
  idempotencyKey: `quest-chapter:${worldId}:${playerId}:1`,
});

Every write — grant, purchase, spend, refund — joins the caller's transaction through @birtalanrobert/database, so a currency credited for a claim that then rolls back was never credited. Every write takes the holder's balance row for that currency first, so one holder's writes take turns: of two spends reaching for the last of a balance, one succeeds and the other is refused with insufficient_balance, never a balance of minus something.

Idempotent by key, and refused when the key means something else

Every write carries an idempotencyKey, unique per holder across every kind. The same key again answers with the entry it wrote — a webhook delivered twice, a request retried, a bonus asked for again — and the same key for a different entry is refused with ledger_key_reused, because answering it with the first entry would quietly lose the second.

Refunds

refund takes a whole purchase back, once: refused with not_refundable when any of it is spent or it was refunded already, and not_found for one that is not the holder's purchase. Whether it is still within a refund window, and the money's own return, are the game's and its payment provider's.

reverse is for money that went back another way — a refund made at the payment provider, a chargeback. It takes whatever of the purchase is left, which may be less than all of it, or nothing, and answers with what it took and what the holder had spent already:

const { entry, taken, spent, earlier } = await economy.reverse(holderId, {
  purchaseId,
  reason: 'stripe.dispute',
  idempotencyKey: `dispute:${eventId}`,
});
// spent > 0: currency the holder used that nothing took back — the game decides what that costs them.

A purchase is given back once, by a refund or a reversal, whichever comes first: a reversal of one given back already takes nothing and names the entry that did (earlier), and two at once leave one of them standing.

Two things it deliberately does not do

  • Move currency between holders. A gift between players is a transfer vector for every kind of abuse these games watch for, and whether a game allows one at all is its own decision. A game that does writes a spend and a grant, each with its reason.
  • Take a foreign key to your accounts. holderId is an id in the game's own terms, so the package can be shared — and an erasure leaves the ledger, which names nobody, standing and reconcilable. That is also why its entries refuse deletes as well as updates, unlike a transition log that cascades from its subject.

Forgetting a holder, whole

The ledger outlives an erasure, but not for ever: accounting rules keep records for a number of years, and once they are gone by the records have no reason left to exist. forget(holderId, before) deletes everything a holder ever had — entries, draws, lots, balances — in one transaction, the caller's when there is one, and answers true once nothing of the holder is left.

It refuses, deleting nothing, while the holder holds anything in any currency, or wrote an entry at or after before — the cut-off the product's period sets, which this package does not choose. It never deletes part of a holder: a ledger missing some of a holder's entries no longer reconciles; one missing all of them has nothing left to reconcile. Use it for a holder nothing writes to any more — an erased account — since a credit in a currency it never held could otherwise land meanwhile.

The guard still refuses every update and every other delete. forget names the holder in a setting local to its transaction, mortar.economy_forgetting, and the trigger lets that holder's entries, and the draws they made, go — a guard against mistakes, not against a role that may set the setting itself.

Checking it

reconcile(holderId, currency) compares the cached balance, the sum of the entries and what is left of the credits, and says whether they agree. It is exposed for operators rather than kept for tests: a cached number can drift, and somebody who suspects one should be able to have it checked.