npm package: @piotrjura/nickel
Description: Credit-based billing for SaaS apps. Atomic balance operations, pending/settle/refund lifecycle, full audit trail. Built on Drizzle ORM + PostgreSQL.
Latest version: 0.1.0

# 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

```bash
npm install nickel
```

Peer dependency: `drizzle-orm >= 0.35.0`

## Quick start

### 1. Add the schema to your Drizzle config

```ts
// 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:

```bash
npx drizzle-kit generate
npx drizzle-kit migrate
```

### 2. Create a ledger instance

```ts
// 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

```ts
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:

```ts
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:

```ts
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

```ts
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:

```ts
// 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:

```ts
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

```ts
// 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

```ts
// 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

```ts
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

```ts
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>`

```ts
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`)

```ts
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`

```ts
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`

```ts
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`

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

### `CreditPack<TCurrency>`

```ts
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`)

```ts
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`

```ts
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:

```ts
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)

```ts
// 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)

```ts
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)

```ts
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