@suluk/credits - v0.1.0
    Preparing search index...

    @suluk/credits - v0.1.0

    Suluk

    @suluk/credits

    A metered credit ledger whose money-correctness core can't go negative under concurrency or double-spend a partial refund — the app just injects a Drizzle handle.

    Part of Suluk — one typed OpenAPI v4 contract projecting into every full-stack layer.


    CANDIDATE tooling — not official OpenAPI. Suluk is a single-contributor candidate for OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable to ratify anything on the SIG's behalf.

    bun add @suluk/credits
    

    An append-only credit ledger, extracted verbatim from a real app (C046). The package owns the tables (credit_transaction plus the credit_amount / credit_key sidecars) and exports the Drizzle schema; the app injects the DB handle — DrizzleD1Database in production, bun:sqlite bridged to it in tests. Balance is the sum of every row's delta (+ on grant/top-up, on debit), so there is exactly one writer and the ledger is auditable end-to-end.

    The point is the two atomic primitives:

    • debitIfCovers — a single conditional INSERT that can never drive the ledger negative under concurrency (no read-then-write race). Returns false instead of overdrawing.
    • debitOnceIfCoversINSERT OR IGNORE at a deterministic id: the money-OUT double-spend guard for partial refunds and at-least-once webhooks. Returns { outcome: "debited" | "replayed" | "insufficient" }.

    Grants use the same discipline: grantOnce(db, userId, amount, idemKey) is idempotent on the idempotency key, so a redelivered signup / top-up event never grants twice.

    import {
    getBalance, grantOnce, debitIfCovers, debitOnceIfCovers,
    listTransactions, InsufficientCreditsError, creditTransaction,
    } from "@suluk/credits";

    // Grant 100 credits, idempotently — a replayed event is a no-op (returns false).
    await grantOnce(db, "user_42", 100, "signup:user_42", "signup-grant");

    // Atomic spend: debits only if the balance covers it; never goes negative under concurrency.
    const ok = await debitIfCovers(db, "user_42", 5, "ask", /* keyId */ "key_abc");
    if (!ok) throw new InsufficientCreditsError(await getBalance(db, "user_42"), 5);

    // Idempotent spend (partial refund / at-least-once webhook): safe to retry at the same key.
    const res = await debitOnceIfCovers(db, "user_42", 3, "refund", "evt_123");
    res.outcome; // "debited" | "replayed" | "insufficient"

    console.log(await getBalance(db, "user_42")); // current balance (Σ deltas)
    console.table(await listTransactions(db, "user_42")); // recent activity log

    The package owns the schema — wire it into your migrations:

    import { creditTransaction, creditAmount, creditKey } from "@suluk/credits";
    // include these tables in your drizzle-kit migration; `userId` is a plain column
    // (the app owns the `user` table — add the FK + onDelete cascade in your migration).
    Export What it does
    getBalance(db, userId) Current balance = Σ of the user's ledger deltas.
    record / addCredits / debitCredits The single-writer append + the non-atomic grant/debit conveniences (return the new balance).
    debitIfCovers(db, userId, amount, reason, keyId?) Atomic debit-if-covers — a conditional INSERT that can't go negative.
    grantOnce(db, userId, amount, idemKey, reason?, …) Idempotent grant (dedupes on the idempotency key).
    debitOnceIfCovers / debitOnceAttributed Idempotent debit → DebitOutcome — the partial-refund double-spend guard.
    nonceFor(reason, idemKey) The deterministic row id an idempotent op maps to (pre-check without drift).
    recordKey / keySpend(db, keyId) Attribute a debit to the API key that spent it; per-key spend total.
    listTransactions / ledgerRow / ledgerStats The activity-log query, one row, and aggregate stats.
    recordAmount The cosmetic $ annotation sidecar on a row.
    creditTransaction / creditAmount / creditKey The Drizzle schema the package owns.
    InsufficientCreditsError Thrown by the app when a debit isn't covered.

    Types CreditsDB, DebitOutcome, LedgerEntry, and LedgerStats are exported alongside.

    This package owns the money-correctness core and nothing above it. It stores the ledger (in a DB you inject) but imposes no pricing, plans, or limits — those are the app's policy. Per-key spend attribution is best-effort reporting that rides alongside a debit and must never break it.

    It pairs with @suluk/keys (the delegation-chain algebra joins this ledger for pooled subtree headroom) and @suluk/billing (whose webhook dispatch composes grantOnce here to credit a paid invoice). It depends only on @suluk/drizzle + drizzle-orm.

    Apache-2.0