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

@janindu-pathirana/plankit

v1.1.0

Published

Pricing plans, prices, tiers and features for any Node backend. One command creates the tables in Postgres.

Readme

@janindu-pathirana/plankit

Pricing plans, prices, tiers and features for any Node backend. Install it, run one command, and the tables appear in your Postgres database. Then use the functions to create, update and delete plans.

PlanKit is the catalog of what you sell. It does not take payments, create subscriptions or send invoices. Your backend stores planId / priceId on its own subscription records, and PlanKit keeps those IDs valid and never changes the amount of an existing price.

Install

npm i @janindu-pathirana/plankit
npx plankit migrate      # creates the tables in the "plankit" schema
npx plankit seed         # optional: sample Free / Pro / Business plans

The database URL is read from your .env file by default:

DATABASE_URL=postgresql://user:pass@localhost:5432/mydb

Order of precedence: --url flag (or databaseUrl option), then PLANKIT_DATABASE_URL, then DATABASE_URL (environment first, then the .env file in the current folder).

| Command | What it does | | --- | --- | | plankit migrate | Create or update the tables | | plankit rollback [--steps N] | Undo the last migration(s) | | plankit status | Show applied and pending migrations | | plankit seed [--preset saas] | Insert sample plans (safe to run twice) | | plankit init | Add a plankit-migrate target to project.json (Nx) and DATABASE_URL= to .env.example |

Nx: run plankit init in the project folder, then nx run <project>:plankit-migrate. No Nx plugin is needed.

Migrations take a Postgres advisory lock, so two deploys can't migrate at once.

Use

import { createPlanKit, Currency } from '@janindu-pathirana/plankit';

const pk = createPlanKit();                  // reads DATABASE_URL from .env
// or: createPlanKit({ databaseUrl }) / createPlanKit({ pool })  (your own pg Pool)

await pk.features.create({ code: 'sso', name: 'Single sign-on' });
await pk.features.create({ code: 'seats', name: 'Seats', type: 'limit' });

const pro = await pk.plans.create({
  code: 'pro',
  name: 'Pro',
  status: 'active',
  tierRank: 2,
  features: { sso: false, seats: { limit: 10, displayText: 'Up to 10 seats' } },
  prices: [
    { pricingModel: 'per_seat', unitAmount: 12,  currency: Currency.USD, interval: 'month' },
    { pricingModel: 'per_seat', unitAmount: 120, currency: Currency.USD, interval: 'year' },
  ],
});

await pk.plans.update(pro.id, { name: 'Pro Team', highlight: true });
await pk.prices.replace(pro.prices[0].id, { unitAmount: 15 });     // new price row, old ID stays valid
const pricingPage = await pk.catalog.listPublic({ currency: 'usd', interval: 'month' });
const total = pk.calculator.amountFor(pro.prices[0], 12);          // 180, pure, no database

Amounts are decimals in whole currency units: 49.99, or 0.0004 for a price per API call. They are stored exactly (Postgres numeric(24,8), up to 8 decimal places and 15 significant digits), never as binary floats, and the calculator does exact decimal math. PlanKit does not convert or round by currency: the currency is a label on the price.

Currencies

Every price has a currency from the plankit.currency table, which holds all ISO 4217 codes. The same list ships as an enum:

import { Currency, CURRENCIES, currencyInfo } from '@janindu-pathirana/plankit';

Currency.USD;                         // 'usd'
currencyInfo('jpy')?.name;            // 'Yen'
await pk.currencies.list({ activeOnly: true });
await pk.currencies.create({ code: 'pts', name: 'Loyalty points', minorUnit: 0 });   // your own currency
await pk.currencies.update('eur', { active: false });                                 // switch a built-in one off

Built-in currencies cannot be edited or deleted (only switched off). Custom ones cannot be deleted while a price uses them.

Pricing models

Every price points at a row in the plankit.pricing_model table. The row says what the model fixes (billing scheme, type, kind, usage type) and which fields are required, and prices.add() checks your input against it.

| pricingModel | Example | Required besides currency | | --- | --- | --- | | flat_rate | $49/month | unitAmount, interval | | per_seat | $12/user/month | unitAmount, interval | | tiered_volume | whole quantity at one band's rate | tiers, interval | | tiered_graduated | each band at its own rate, summed | tiers, interval | | package | $10 per 1,000 emails | unitAmount, packageSize, billingScheme: 'package' | | usage_based | $0.002 per API call | meterId or meterCode, billingScheme | | base_plus_overage | $99 includes 10k calls, then $0.01 | meterCode, includedUnits, billingScheme (add the $99 as a separate flat_rate price) | | good_better_best | Free / Pro / Business | any scheme; order plans with tierRank | | feature_based | SSO only on Business | any scheme; use plan features | | freemium | Free plan with limits | interval (amount is fixed at 0) | | free_trial | 14 days free | trialDays, billingScheme (your app enforces the trial) | | addon | +$5 per 100 GB | billingScheme | | credit_pack | 1,000 credits for $20 | unitAmount, creditAmount (one-time) | | one_time | $299 lifetime | unitAmount (one-time) | | outcome_based | $50 per qualified lead | meterCode, billingScheme | | custom_enterprise | negotiated | unitAmount, interval; customerRef optional |

Intervals are day, week, month, year, with intervalCount (3 + month = quarterly). Add one price per currency and per interval. You can add your own models with pk.pricingModels.create(...).

Functions

| Service | Functions | | --- | --- | | plans | create (with nested prices and features, one transaction), update, get, getByCode, list, duplicate, archive, activate, delete (soft), restore, hardDelete | | prices | add, replace, deactivate, activate, get, listForPlan, setTiers, delete | | features | create, update, get, getByCode, list, delete, setPlanFeatures, getPlanFeatures | | pricingModels | list, get, getByCode, create, update, delete | | meters | create, update, get, getByCode, list, delete | | currencies | list, get, create, update, delete | | catalog | listPublic, comparePlans, sync | | calculator | amountFor(price, qty), breakdown(price, qty) | | entitlements | featuresFor, hasFeature, limitFor | | migrations | up, down, status (the CLI's work, from code) |

All inputs are checked with zod. Errors are typed: PlanNotFoundError, PlanCodeTakenError, InvalidTierConfigError, InvalidPriceConfigError, CurrencyNotFoundError, PriceImmutableError, PlanInUseError, StaleVersionError, ValidationError, and more (all exported).

Syncing a catalog

seed skips plans that already exist, so it cannot add features to a live plan. catalog.sync takes your whole catalog as data and makes the database match it. Run it on every deploy; the second run changes nothing.

const report = await pk.catalog.sync(
  {
    features: [
      { code: 'waiter_role', name: 'Waiter accounts', requiresLimit: { staff: 1 } },
      { code: 'waiter_orders', name: 'Waiters take orders', requires: ['waiter_role'] },
      { code: 'kitchen_role', name: 'Kitchen accounts', requiresAny: ['qr_ordering', 'waiter_orders'] },
      { code: 'staff', name: 'Staff accounts', type: 'limit', unitLabel: 'accounts' },
      // ...
    ],
    plans: [
      {
        code: 'pro', name: 'Pro', tierRank: 1,
        features: {
          waiter_role: { enabled: true, displayText: 'Waiter accounts' },
          waiter_orders: false, // turning a feature off means listing it as false
          staff: { limit: 5 },
        },
      },
    ],
  },
  { dryRun: true }, // returns { created, updated, unchanged, warnings } and writes nothing
);
  • requires means all of these must be on in the same plan; requiresAny means at least one; requiresLimit: { staff: 1 } means that limit must be at least 1. A broken rule throws DependencyError before anything is written.
  • Features and plans not in the definition are left alone. Add { prune: true } to remove plan features that are not listed; without it you get a warning per plan that has extras.
  • Prices are never deleted. A changed amount is applied with prices.replace, in the same transaction as the feature changes.
  • Only one sync runs at a time (database lock), and everything rolls back if any step fails.
  • CLI: plankit sync catalog.json --dry-run.

Rules worth knowing

  • Prices are immutable. prices.replace(id, { unitAmount }) deactivates the old price and creates a new one, returning { oldPriceId, newPriceId }. The old ID stays valid for existing subscribers. Database triggers also block in-place changes to a price and to the tiers of a price that has been active.
  • Soft delete by default. plans.delete hides a plan; restore brings it back. hardDelete needs the isPlanInUse hook.
  • Optimistic locking. plans.update(id, { ..., expectedVersion }) throws StaleVersionError if the plan changed meanwhile.
  • Only active, public plans appear in catalog.listPublic.
  • entitlements.limitFor returns a number, null for unlimited, or undefined when the plan doesn't have the feature (or it is disabled).

Hooks

const pk = createPlanKit({
  hooks: {
    isPlanInUse: (planId) => subscriptions.exists({ planId }),
    onCatalogChanged: (change) => cache.invalidate('pricing'), // change.event: 'plan.created', 'price.replaced', ...
  },
});

Calculator

| Scheme | Rule | | --- | --- | | flat | unitAmount | | per_unit | unitAmount x qty | | package | ceil(qty / packageSize) x unitAmount | | tiered + volume | the band holding qty: qty x unitAmount + flatAmount | | tiered + graduated | per band: unitsInBand x unitAmount + flatAmount, summed |

includedUnits is subtracted from the quantity first. A flat price is charged in full whatever the quantity; for every other scheme a billable quantity of 0 costs 0. The result is the exact decimal total, with no rounding. Example tiers 1-5 @ $7, 6-10 @ $6.50, 11+ @ $6 with quantity 12: volume = 72, graduated = 79.50.

Upgrading from 0.1

0.2 changes amounts from whole minor units (4900) to decimals in whole units (49). plankit migrate changes the column types but does not rescale existing numbers, so a price stored as 4900 now reads as 4900. Update or re-enter existing prices (create new ones with prices.replace) and re-seed development databases.

Tables

All in the plankit schema, so they never clash with your own: pricing_model, currency, plan, price, price_tier, feature, plan_feature, meter, plus migrations (the log). Native Postgres enums cover small fixed lists (plan status, interval, feature type, ...).

Develop

npm install
createdb plankit_test
PLANKIT_TEST_DATABASE_URL=postgresql://localhost:5432/plankit_test npm test   # the URL above is the default
npm run build

Tests wipe and recreate the plankit schema tables in the test database, so use a throwaway one.

Releasing

Log in once with npm login, then from a clean main:

npm run release -- patch --dry-run   # checks, tests, build, shows what would ship; changes nothing
npm run release -- patch             # also bumps the version, publishes, pushes the tag

The script stops unless you are on main, the tree is clean, main is not behind origin/main, and you are logged in to npm. It runs typecheck, tests (against the test database) and build, shows the files in the package, and asks you to type yes before it changes anything. Use minor, major or an exact version like 1.0.0 in place of patch.

License

MIT