Skip to content

piotrjura/nickel

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

About

simple credit-based billing for AI era

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors