@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.
Maintainers
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 nickelPeer 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 migrate2. 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()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.
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()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.
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 createdAudit 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 positiveamountand type"refund"
The balance field on each transaction records the user's balance at that point in time.
License
MIT
