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

@piotrjura/nickel

v0.1.0

Published

Credit-based billing for SaaS apps. Atomic balance operations, pending/settle/refund lifecycle, full audit trail. Built on Drizzle ORM + PostgreSQL.

Readme

nickel

Nickel-and-dime your users — the right way. Credit-based billing for SaaS apps with atomic balance operations, pending/settle/refund lifecycle, and full audit trail. Built on Drizzle ORM + PostgreSQL.

Install

npm install nickel

Peer dependency: drizzle-orm >= 0.35.0

Quick start

1. Add the schema to your Drizzle config

// db/schema.ts
import { creditBalances, creditTransactions } from "nickel/schema";

// Export alongside your own tables
export { creditBalances, creditTransactions };
export const users = pgTable("users", { ... });

Generate and run migrations:

npx drizzle-kit generate
npx drizzle-kit migrate

2. Create a ledger instance

// lib/credits.ts
import { createCreditLedger } from "nickel";
import { db } from "./db";

export const ledger = createCreditLedger({
  db,
  costs: {
    cv_analysis: 3,
    job_analysis: 3,
    generation: 6,
    regeneration: 3,
  },
  packs: [
    {
      id: "starter",
      name: "Starter",
      credits: 48,
      prices: {
        usd: { amount: 499, display: "$4.99", stripePriceId: "price_xxx" },
        eur: { amount: 499, display: "€4.99", stripePriceId: "price_yyy" },
      },
    },
    {
      id: "pro",
      name: "Pro",
      credits: 300,
      popular: true,
      savings: "Save 52%",
      prices: {
        usd: { amount: 1499, display: "$14.99", stripePriceId: "price_aaa" },
        eur: { amount: 1499, display: "€14.99", stripePriceId: "price_bbb" },
      },
    },
  ],
});

The keys in costs become type-safe arguments to deduct() — TypeScript will only accept "cv_analysis" | "job_analysis" | "generation" | "regeneration" as the type parameter.

3. Use it

import { ledger } from "./lib/credits";
import { InsufficientCreditsError } from "nickel";

// Check balance
const balance = await ledger.getBalance(userId); // 0 for unknown users

// Simple deduction (completed immediately)
const { balance, transactionId, amount } = await ledger.deduct(
  userId,
  "cv_analysis", // ← type-safe, must be a key in your costs
  "Analyzed CV for job at Acme Corp",
);

// Add credits (purchases, bonuses, grants)
const newBalance = await ledger.add(userId, 15, "signup_bonus", "Welcome bonus");

Pending transactions (hold → settle)

For long-running operations (LLM calls, PDF rendering), hold credits upfront and settle when done:

const { transactionId } = await ledger.deduct(
  userId,
  "generation",
  "Generating tailored CV",
  { pending: true }, // balance deducted immediately as a hold
);

try {
  const result = await generateCV(userId, jobId);
  await ledger.complete(transactionId, result.id, "generation");
} catch (err) {
  await ledger.refund(transactionId); // credits restored
  throw err;
}

Both complete() and refund() are idempotent — calling them on an already-settled transaction is a safe no-op. You can safely retry without double-completing or double-refunding.


"First free" pattern

Offer the first operation of a type for free:

const isFree = await ledger.isFirstOfType(userId, "cv_analysis");
if (!isFree) {
  await ledger.deduct(userId, "cv_analysis", "CV analysis");
}
await runCvAnalysis(userId);

isFirstOfType checks if any transaction of that type exists for the user (any status — pending, completed, or refunded all count).


Transaction history

const txns = await ledger.getTransactions(userId); // newest first, default limit 50
const recent = await ledger.getTransactions(userId, { limit: 10 });

Each transaction has the full CreditTransaction shape (see Types section below).


Packs

Define credit packs once in your ledger config. Access them anywhere:

// Server-side — includes stripePriceId for Stripe checkout
const pack = ledger.getPack("starter");      // CreditPack | undefined
const allPacks = ledger.getAllPacks();        // CreditPack[]

// Client-side — stripePriceId stripped (safe for browser bundles)
const clientPacks = ledger.getClientPacks();  // ClientCreditPack[]

For client-only code that doesn't need the full ledger:

import { toClientPacks, getPackById } from "nickel/client";
import type { ClientCreditPack } from "nickel/client";

Stripe integration

nickel is payment-provider agnostic — no Stripe dependency. You call ledger.add() in your webhook handler. Here's a complete Stripe example:

Checkout route

// app/api/credits/checkout/route.ts
import Stripe from "stripe";
import { ledger } from "@/lib/credits";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const { packId, currency } = await req.json();
  const pack = ledger.getPack(packId);
  if (!pack) return Response.json({ error: "Invalid pack" }, { status: 400 });

  const price = pack.prices[currency];
  if (!price?.stripePriceId) {
    return Response.json({ error: "Invalid currency" }, { status: 400 });
  }

  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: price.stripePriceId, quantity: 1 }],
    metadata: { userId, packId, credits: String(pack.credits) },
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?purchased=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/dashboard/credits`,
  });

  return Response.json({ url: session.url });
}

Webhook handler

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { ledger } from "@/lib/credits";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get("stripe-signature")!;
  const event = stripe.webhooks.constructEvent(
    body,
    sig,
    process.env.STRIPE_WEBHOOK_SECRET!,
  );

  if (event.type === "checkout.session.completed") {
    const session = event.data.object as Stripe.Checkout.Session;
    const { userId, packId, credits } = session.metadata!;

    // Add credits — idempotent when stripeSessionId is provided.
    // If Stripe sends the webhook twice, the second call returns the
    // existing balance instead of double-crediting (unique constraint
    // on stripe_session_id).
    await ledger.add(
      userId,
      Number(credits),
      "purchase",
      `Purchased ${packId} pack`,
      { stripeSessionId: session.id },
    );

    // Optionally store the monetary amount for revenue tracking
    if (session.amount_total && session.currency) {
      await ledger.updateTransactionMonetary(
        session.id,
        session.amount_total,
        session.currency,
      );
    }
  }

  return new Response("ok");
}

Handling insufficient credits in API routes

import { InsufficientCreditsError } from "nickel";

export async function POST(req: Request) {
  try {
    await ledger.deduct(userId, "analysis", "CV analysis");
    // ... do the work
  } catch (err) {
    if (err instanceof InsufficientCreditsError) {
      return Response.json(
        {
          code: "INSUFFICIENT_CREDITS",
          available: err.available, // current balance
          required: err.required,   // how many credits the operation costs
        },
        { status: 402 },
      );
    }
    throw err;
  }
}

Handling insufficient credits on the client

const res = await fetch("/api/analyze", { method: "POST", ... });
if (!res.ok) {
  const data = await res.json();
  if (data.code === "INSUFFICIENT_CREDITS") {
    window.location.href = `/credits?required=${data.required}&available=${data.available}`;
    return;
  }
}

Complete type reference

LedgerConfig<TCosts>

type LedgerConfig<TCosts extends Record<string, number>> = {
  db: any;                  // Drizzle PostgreSQL database instance
  costs: TCosts;            // Map of operation type → credit cost
  packs?: CreditPack[];     // Credit pack definitions (optional)
  logger?: LedgerLogger;    // Custom logger (optional, defaults to silent)
};

CreditLedger<TCosts> (returned by createCreditLedger)

type CreditLedger<TCosts extends Record<string, number>> = {
  getBalance(userId: string): Promise<number>;
  hasEnoughCredits(userId: string, amount: number): Promise<boolean>;
  deduct(
    userId: string,
    type: keyof TCosts & string,  // type-safe — only keys from your costs config
    reason: string,
    opts?: { pending?: boolean },
  ): Promise<DeductResult>;
  add(
    userId: string,
    amount: number,
    type: string,                 // any string — "purchase", "signup_bonus", "admin_grant", etc.
    reason: string,
    opts?: { stripeSessionId?: string },
  ): Promise<number>;             // returns new balance
  complete(transactionId: string, resultId: string, resultType: string): Promise<void>;
  refund(transactionId: string): Promise<void>;
  isFirstOfType(userId: string, type: string): Promise<boolean>;
  getTransactions(userId: string, opts?: { limit?: number }): Promise<CreditTransaction[]>;
  updateTransactionMonetary(stripeSessionId: string, amount: number, currency: string): Promise<void>;
  getPack(id: string): CreditPack | undefined;
  getAllPacks(): CreditPack[];
  getClientPacks(): ClientCreditPack[];
  costs: TCosts;
};

DeductResult

type DeductResult = {
  balance: number;        // new balance after deduction
  transactionId: string;  // use with complete() or refund() for pending transactions
  amount: number;         // the amount that was deducted
};

CreditTransaction

type TransactionStatus = "pending" | "completed" | "refunded";

type CreditTransaction = {
  id: string;
  userId: string;
  amount: number;                    // negative for deductions, positive for additions
  balance: number;                   // balance after this transaction
  type: string;                      // operation type or "purchase", "refund", etc.
  reason: string;                    // human-readable description
  status: TransactionStatus;
  stripeSessionId: string | null;    // set on purchases for idempotency
  monetaryAmount: number | null;     // price in smallest currency unit (cents)
  currency: string | null;           // "usd", "eur", "pln", etc.
  resultId: string | null;           // links to the entity produced (set via complete())
  resultType: string | null;         // type of entity produced (set via complete())
  settledAt: Date | null;            // null while pending
  createdAt: Date;
  updatedAt: Date;
};

InsufficientCreditsError

class InsufficientCreditsError extends Error {
  name: "InsufficientCreditsError";
  available: number;  // user's current balance
  required: number;   // how many credits the operation needs
}

CreditPack<TCurrency>

type CreditPackPrice = {
  amount: number;          // price in smallest currency unit (cents, grosze)
  display: string;         // human-readable: "$4.99"
  stripePriceId?: string;  // Stripe Price ID (omit if not using Stripe)
};

type CreditPack<TCurrency extends string = string> = {
  id: string;
  name: string;
  credits: number;
  prices: Record<TCurrency, CreditPackPrice>;
  popular?: boolean;
  bestValue?: boolean;
  savings?: string;        // e.g. "Save 52%"
};

ClientCreditPack<TCurrency> (browser-safe, no stripePriceId)

type ClientCreditPackPrice = {
  amount: number;
  display: string;
};

type ClientCreditPack<TCurrency extends string = string> = {
  id: string;
  name: string;
  credits: number;
  prices: Record<TCurrency, ClientCreditPackPrice>;
  popular?: boolean;
  bestValue?: boolean;
  savings?: string;
};

LedgerLogger

type LedgerLogger = {
  info(msg: string, data?: Record<string, unknown>): void;
  warn(msg: string, data?: Record<string, unknown>): void;
  error(msg: string, data?: Record<string, unknown>): void;
};

Database schema

nickel creates two tables. Import from nickel/schema and include in your Drizzle schema — migrations are generated by drizzle-kit.

credit_balances

| Column | Type | Notes | |---|---|---| | user_id | text | Primary key. Your user ID (not a foreign key — nickel doesn't know your user table). | | balance | integer | Current credit balance. Default 0. | | updated_at | timestamp | Last modification time. |

credit_transactions

| Column | Type | Notes | |---|---|---| | id | text | Primary key. UUID generated in JavaScript via crypto.randomUUID(). | | user_id | text | User who owns this transaction. Indexed. | | amount | integer | Negative for deductions, positive for additions/refunds. | | balance | integer | User's balance after this transaction (running balance). | | type | text | Operation type (your cost keys, or "purchase", "refund", "signup_bonus", etc.). Indexed. | | reason | text | Human-readable description. | | status | text | "pending", "completed", or "refunded". Default "completed". Indexed. | | stripe_session_id | text | Stripe Checkout session ID. Unique index (where not null) — prevents duplicate webhook credits. | | monetary_amount | integer | Payment amount in smallest currency unit (cents). Set via updateTransactionMonetary(). | | currency | text | Currency code ("usd", "eur", etc.). Set via updateTransactionMonetary(). | | result_id | text | ID of the entity produced. Set via complete(). | | result_type | text | Type of entity produced. Set via complete(). | | settled_at | timestamp | When the transaction was settled. null while "pending". | | created_at | timestamp | Row creation time. | | updated_at | timestamp | Row update time. |

Custom queries

The schema tables are exported for direct Drizzle queries:

import { creditBalances, creditTransactions } from "nickel/schema";
import { eq, and, gte, sql } from "drizzle-orm";

// Usage by type this month
const usage = await db
  .select({
    type: creditTransactions.type,
    count: sql<number>`count(*)`,
    totalCredits: sql<number>`sum(abs(amount))`,
  })
  .from(creditTransactions)
  .where(
    and(
      eq(creditTransactions.userId, userId),
      gte(creditTransactions.createdAt, startOfMonth),
    ),
  )
  .groupBy(creditTransactions.type);

// Revenue this month
const revenue = await db
  .select({
    total: sql<number>`sum(monetary_amount)`,
    currency: creditTransactions.currency,
  })
  .from(creditTransactions)
  .where(
    and(
      eq(creditTransactions.type, "purchase"),
      gte(creditTransactions.createdAt, startOfMonth),
    ),
  )
  .groupBy(creditTransactions.currency);

Entry points

nickel (server-side — main entry point)

// Functions
import { createCreditLedger } from "nickel";
import { getPackById, toClientPacks } from "nickel";

// Error class
import { InsufficientCreditsError } from "nickel";

// Schema (also available via nickel/schema)
import { creditBalances, creditTransactions } from "nickel";

// Types
import type {
  CreditLedger,
  LedgerConfig,
  CreditTransaction,
  TransactionStatus,
  DeductResult,
  CreditPack,
  CreditPackPrice,
  ClientCreditPack,
  ClientCreditPackPrice,
  LedgerLogger,
} from "nickel";

nickel/schema (universal — Drizzle table definitions)

import { creditBalances, creditTransactions } from "nickel/schema";

Use this in your db/schema.ts file so drizzle-kit can generate migrations.

nickel/client (browser-safe — no drizzle-orm, no server code)

import { toClientPacks, getPackById } from "nickel/client";
import type {
  CreditPack,
  CreditPackPrice,
  ClientCreditPack,
  ClientCreditPackPrice,
} from "nickel/client";

Behavior details

Atomicity

  • deduct() uses SELECT ... FOR UPDATE inside a PostgreSQL transaction. Two concurrent deductions for the same user are serialized — the second blocks until the first commits or rolls back. No over-deduction is possible.
  • add() uses INSERT ... ON CONFLICT DO UPDATE (upsert). Creates the balance row for new users automatically. Concurrent adds are serialized by the implicit row lock.
  • refund() uses SELECT ... FOR UPDATE on the transaction row, restores credits, marks as refunded, and creates a refund audit entry — all in one transaction.

Idempotency

  • complete() — no-op if the transaction is already completed or refunded.
  • refund() — no-op if the transaction is already completed or refunded. Does not double-refund.
  • add() with stripeSessionId — if the same stripeSessionId is passed twice (e.g., Stripe webhook retry), the second call returns the current balance without adding credits. The unique constraint on stripe_session_id prevents duplicates.
  • add() without stripeSessionId — NOT idempotent. Each call adds credits. This is by design for bonuses, admin grants, etc.

Pending transaction lifecycle

deduct({ pending: true })
    |
    v
Balance deducted immediately (hold)
Transaction status = "pending", settledAt = null
    |
    |---> complete(txId, resultId, resultType)
    |       Status -> "completed", settledAt set, result linked
    |       Balance unchanged (already deducted)
    |
    +---> refund(txId)
            Status -> "refunded", settledAt set
            Balance restored
            New "refund" audit transaction created

Audit trail

Every operation creates an immutable credit_transactions row:

  • Deductions — negative amount, status "completed" (or "pending")
  • Additions — positive amount, status "completed"
  • Refunds — original transaction marked "refunded" + new transaction with positive amount and type "refund"

The balance field on each transaction records the user's balance at that point in time.


License

MIT