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.
npm install nickelPeer dependency: drizzle-orm >= 0.35.0
// 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// 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.
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");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.
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).
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).
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";nickel is payment-provider agnostic — no Stripe dependency. You call ledger.add() in your webhook handler. Here's a complete Stripe example:
// 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 });
}// 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");
}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;
}
}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;
}
}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)
};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;
};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
};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;
};class InsufficientCreditsError extends Error {
name: "InsufficientCreditsError";
available: number; // user's current balance
required: number; // how many credits the operation needs
}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%"
};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;
};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;
};nickel creates two tables. Import from nickel/schema and include in your Drizzle schema — migrations are generated by drizzle-kit.
| 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. |
| 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. |
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);// 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";import { creditBalances, creditTransactions } from "nickel/schema";Use this in your db/schema.ts file so drizzle-kit can generate migrations.
import { toClientPacks, getPackById } from "nickel/client";
import type {
CreditPack,
CreditPackPrice,
ClientCreditPack,
ClientCreditPackPrice,
} from "nickel/client";deduct()usesSELECT ... FOR UPDATEinside 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()usesINSERT ... ON CONFLICT DO UPDATE(upsert). Creates the balance row for new users automatically. Concurrent adds are serialized by the implicit row lock.refund()usesSELECT ... FOR UPDATEon the transaction row, restores credits, marks as refunded, and creates a refund audit entry — all in one transaction.
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()withstripeSessionId— if the samestripeSessionIdis passed twice (e.g., Stripe webhook retry), the second call returns the current balance without adding credits. The unique constraint onstripe_session_idprevents duplicates.add()withoutstripeSessionId— NOT idempotent. Each call adds credits. This is by design for bonuses, admin grants, etc.
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
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 positiveamountand type"refund"
The balance field on each transaction records the user's balance at that point in time.
MIT