@suluk/credits 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +32 -0
- package/src/credits.ts +244 -0
- package/src/index.ts +18 -0
- package/src/schema.ts +32 -0
- package/test/credits.test.ts +137 -0
- package/tsconfig.json +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@suluk/credits",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A metered CREDIT LEDGER: append-only transactions, balance, the ATOMIC debit-if-covers (a single conditional INSERT that can never drive the ledger negative under concurrency), the idempotent debit (INSERT OR IGNORE — the money-OUT double-spend guard for partial refunds), per-key spend attribution, the recent-transactions + activity-log query, and aggregate stats. The package OWNS the credit tables; the app injects a Drizzle handle (D1 in prod, bun:sqlite in tests). The money-correctness core, extracted verbatim from a real app (C046). CANDIDATE tooling.",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MahmoodKhalil57/suluk.git",
|
|
12
|
+
"directory": "tooling/ts/packages/credits"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
|
|
15
|
+
"bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@suluk/drizzle": "^0.1.6",
|
|
23
|
+
"drizzle-orm": "^0.45.2"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/bun": "latest"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"typecheck": "tsc --noEmit -p ."
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/credits.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The credit ledger (C046) — the money-correctness core, extracted verbatim. The package owns the schema; the app injects
|
|
3
|
+
* a Drizzle handle (`DrizzleD1Database` in prod; bun:sqlite bridged to it in tests — the query builders + raw SQL are
|
|
4
|
+
* compatible). The two atomic primitives are the point: `debitIfCovers` (a single conditional INSERT that can never drive
|
|
5
|
+
* the ledger negative under concurrency) and `debitOnceIfCovers` (INSERT OR IGNORE — the money-OUT double-spend guard for
|
|
6
|
+
* partial refunds). The app-specific payment-alert kinds + the user-table count are NOT here (they stay in the app).
|
|
7
|
+
*/
|
|
8
|
+
import { and, desc, eq, lt, sql } from "drizzle-orm";
|
|
9
|
+
import type { DrizzleD1Database } from "drizzle-orm/d1";
|
|
10
|
+
import { rowsChanged } from "@suluk/drizzle";
|
|
11
|
+
import { creditTransaction, creditAmount, creditKey } from "./schema";
|
|
12
|
+
|
|
13
|
+
/** The injected DB handle. Prod is drizzle/d1; tests bridge drizzle/bun-sqlite to this type (a runtime-identity narrow). */
|
|
14
|
+
export type CreditsDB = DrizzleD1Database;
|
|
15
|
+
|
|
16
|
+
export class InsufficientCreditsError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
public readonly balance: number,
|
|
19
|
+
public readonly needed: number,
|
|
20
|
+
) {
|
|
21
|
+
super(`Insufficient credits: have ${balance}, need ${needed}.`);
|
|
22
|
+
this.name = "InsufficientCreditsError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Current balance = sum of all ledger deltas for the user. */
|
|
27
|
+
export async function getBalance(db: CreditsDB, userId: string): Promise<number> {
|
|
28
|
+
const rows = await db
|
|
29
|
+
.select({ bal: sql<number>`coalesce(sum(${creditTransaction.delta}), 0)` })
|
|
30
|
+
.from(creditTransaction)
|
|
31
|
+
.where(eq(creditTransaction.userId, userId));
|
|
32
|
+
return Number(rows[0]?.bal ?? 0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Append one ledger row (the single writer); returns the new row id. `delta` is + on grant/top-up, − on debit. */
|
|
36
|
+
export async function record(db: CreditsDB, userId: string, delta: number, reason: string): Promise<string> {
|
|
37
|
+
const id = crypto.randomUUID();
|
|
38
|
+
await db.insert(creditTransaction).values({ id, userId, delta, reason, createdAt: new Date() });
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Attribute a debit row to the API KEY that spent it (per-key usage + limit join). Best-effort + idempotent (PK on
|
|
43
|
+
* txnId) — attribution is reporting, NOT the money path, so a failure here must never break the debit it rode in on. */
|
|
44
|
+
export async function recordKey(db: CreditsDB, txnId: string, keyId: string | null | undefined): Promise<void> {
|
|
45
|
+
if (!keyId) return;
|
|
46
|
+
try {
|
|
47
|
+
await db.insert(creditKey).values({ txnId, keyId }).onConflictDoNothing().run();
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.warn(`[credit-key] couldn't attribute ${txnId} to ${keyId}:`, e instanceof Error ? e.message : String(e));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* ATOMIC metered debit — append `-amount` ONLY IF the balance still covers it, in ONE conditional INSERT (atomic on both
|
|
55
|
+
* bun:sqlite and D1), then best-effort attribute it. Returns true when debited, false when the balance raced below the
|
|
56
|
+
* cost. Closes the read-then-write window where K concurrent charges each read the same balance, all pass `cost <=
|
|
57
|
+
* balance`, and all append — driving the ledger NEGATIVE. The self-guard rejects a non-positive/non-integer `amount`
|
|
58
|
+
* (a negative would compute delta=+amount and trivially pass the WHERE, MINTING credits).
|
|
59
|
+
*/
|
|
60
|
+
export async function debitIfCovers(db: CreditsDB, userId: string, amount: number, reason: string, keyId?: string | null): Promise<boolean> {
|
|
61
|
+
if (!Number.isInteger(amount) || amount <= 0) return false;
|
|
62
|
+
const id = crypto.randomUUID();
|
|
63
|
+
const createdAt = Math.floor(Date.now() / 1000); // integer({mode:"timestamp"}) stores epoch SECONDS
|
|
64
|
+
const res = await db.run(
|
|
65
|
+
sql`INSERT INTO credit_transaction (id, userId, delta, reason, createdAt)
|
|
66
|
+
SELECT ${id}, ${userId}, ${-amount}, ${reason}, ${createdAt}
|
|
67
|
+
WHERE (SELECT coalesce(sum(delta), 0) FROM credit_transaction WHERE userId = ${userId}) >= ${amount}`,
|
|
68
|
+
);
|
|
69
|
+
if (rowsChanged(res) === 0) return false; // the balance raced below the cost → no debit, no negative ledger
|
|
70
|
+
await recordKey(db, id, keyId);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Total credits a key has spent — SUM(abs(delta)) over its attributed DEBITS (delta < 0). Drives the per-key cap + the
|
|
75
|
+
* keys-page usage column. */
|
|
76
|
+
export async function keySpend(db: CreditsDB, keyId: string): Promise<number> {
|
|
77
|
+
const rows = await db
|
|
78
|
+
.select({ spent: sql<number>`coalesce(sum(-${creditTransaction.delta}), 0)` })
|
|
79
|
+
.from(creditKey)
|
|
80
|
+
.innerJoin(creditTransaction, eq(creditTransaction.id, creditKey.txnId))
|
|
81
|
+
.where(and(eq(creditKey.keyId, keyId), lt(creditTransaction.delta, 0)));
|
|
82
|
+
return Number(rows[0]?.spent ?? 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The outcome of an idempotent debit attempt (see {@link debitOnceIfCovers}). */
|
|
86
|
+
export type DebitOutcome = { outcome: "debited" | "replayed" | "insufficient"; nonce: string };
|
|
87
|
+
|
|
88
|
+
/** The DETERMINISTIC ledger row id an idempotent operation maps to — exported so a caller can pre-check existence at the
|
|
89
|
+
* SAME id {@link debitOnceIfCovers} will use, without re-deriving the format and risking drift. */
|
|
90
|
+
export function nonceFor(reason: string, idemKey: string): string {
|
|
91
|
+
return `${reason}:${idemKey}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Idempotent atomic debit: debit `amount` ONLY if the balance covers it AND this exact logical operation (identified by
|
|
96
|
+
* `idemKey`) hasn't already been debited. The row id is DERIVED from the key (`${reason}:${idemKey}`), so a retry/duplicate
|
|
97
|
+
* collides on the primary key and is IGNORED — it can never mint a second debit. The money-OUT double-spend guard a
|
|
98
|
+
* per-call random nonce lacks for PARTIAL refunds. One statement (INSERT OR IGNORE … WHERE SUM(delta) >= amount), atomic
|
|
99
|
+
* on both engines. Returns `debited` (fresh — `nonce` anchors the downstream Stripe idempotency key), `replayed` (already
|
|
100
|
+
* debited — caller MUST NOT move money again), or `insufficient` (balance no longer covers it).
|
|
101
|
+
*/
|
|
102
|
+
export async function debitOnceIfCovers(db: CreditsDB, userId: string, amount: number, reason: string, idemKey: string): Promise<DebitOutcome> {
|
|
103
|
+
const nonce = nonceFor(reason, idemKey);
|
|
104
|
+
if (!Number.isInteger(amount) || amount <= 0) return { outcome: "insufficient", nonce };
|
|
105
|
+
const createdAt = Math.floor(Date.now() / 1000);
|
|
106
|
+
const res = await db.run(
|
|
107
|
+
sql`INSERT OR IGNORE INTO credit_transaction (id, userId, delta, reason, createdAt)
|
|
108
|
+
SELECT ${nonce}, ${userId}, ${-amount}, ${reason}, ${createdAt}
|
|
109
|
+
WHERE (SELECT coalesce(sum(delta), 0) FROM credit_transaction WHERE userId = ${userId}) >= ${amount}`,
|
|
110
|
+
);
|
|
111
|
+
if (rowsChanged(res) > 0) return { outcome: "debited", nonce };
|
|
112
|
+
// No row inserted: EITHER the id existed (duplicate/retry → replay) OR the balance didn't cover it. Disambiguate by existence.
|
|
113
|
+
const existing = await db.select({ id: creditTransaction.id }).from(creditTransaction).where(eq(creditTransaction.id, nonce)).limit(1);
|
|
114
|
+
return { outcome: existing.length > 0 ? "replayed" : "insufficient", nonce };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Idempotent debit + per-key ATTRIBUTION — the money primitive a per-item bulk charge needs ({@link debitOnceIfCovers}
|
|
119
|
+
* itself does NOT attribute). On a FRESH `debited` it records the spend against `keyId` (the row id is the stable nonce);
|
|
120
|
+
* a `replayed`/`insufficient` attributes nothing.
|
|
121
|
+
*/
|
|
122
|
+
export async function debitOnceAttributed(db: CreditsDB, userId: string, amount: number, reason: string, idemKey: string, keyId?: string | null): Promise<DebitOutcome> {
|
|
123
|
+
const outcome = await debitOnceIfCovers(db, userId, amount, reason, idemKey);
|
|
124
|
+
if (outcome.outcome === "debited") await recordKey(db, outcome.nonce, keyId);
|
|
125
|
+
return outcome;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The signed credit `delta` + recorded `amountCents` for ONE ledger row id, or null if absent. Lets an idempotent replay
|
|
129
|
+
* report the ORIGINAL operation's amounts — never the retry's (possibly tampered) request. */
|
|
130
|
+
export async function ledgerRow(db: CreditsDB, id: string): Promise<{ delta: number; amountCents: number | null } | null> {
|
|
131
|
+
const rows = await db
|
|
132
|
+
.select({ delta: creditTransaction.delta, amountCents: creditAmount.amountCents })
|
|
133
|
+
.from(creditTransaction)
|
|
134
|
+
.leftJoin(creditAmount, eq(creditAmount.txnId, creditTransaction.id))
|
|
135
|
+
.where(eq(creditTransaction.id, id))
|
|
136
|
+
.limit(1);
|
|
137
|
+
const r = rows[0];
|
|
138
|
+
return r ? { delta: r.delta, amountCents: r.amountCents ?? null } : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** One ledger row as a panel shows it (`createdAt` epoch-ms). `amountCents` is the SIGNED cash that moved (+ in, − out),
|
|
142
|
+
* or null for credits-only rows (usage debits, free grants). */
|
|
143
|
+
export interface LedgerEntry {
|
|
144
|
+
id: string;
|
|
145
|
+
delta: number;
|
|
146
|
+
reason: string;
|
|
147
|
+
createdAt: number;
|
|
148
|
+
amountCents: number | null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Annotate a ledger row with the CASH that moved (signed). Idempotent (PK on txnId), best-effort (purely cosmetic). No-op on 0/null. */
|
|
152
|
+
export async function recordAmount(db: CreditsDB, txnId: string, amountCents: number | null | undefined): Promise<void> {
|
|
153
|
+
if (!amountCents) return;
|
|
154
|
+
try {
|
|
155
|
+
await db.insert(creditAmount).values({ txnId, amountCents }).onConflictDoNothing().run();
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.warn(`[credit-amount] couldn't record ${amountCents}¢ for ${txnId}:`, e instanceof Error ? e.message : String(e));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The user's recent ledger rows (grants + debits) with the cash that moved, newest first — the "recent transactions" +
|
|
162
|
+
* the activity log. `limit` is generous (effectively "all" for a normal account). */
|
|
163
|
+
export async function listTransactions(db: CreditsDB, userId: string, limit = 250): Promise<LedgerEntry[]> {
|
|
164
|
+
const rows = await db
|
|
165
|
+
.select({ id: creditTransaction.id, delta: creditTransaction.delta, reason: creditTransaction.reason, createdAt: creditTransaction.createdAt, amountCents: creditAmount.amountCents })
|
|
166
|
+
.from(creditTransaction)
|
|
167
|
+
.leftJoin(creditAmount, eq(creditAmount.txnId, creditTransaction.id))
|
|
168
|
+
.where(eq(creditTransaction.userId, userId))
|
|
169
|
+
.orderBy(desc(creditTransaction.createdAt))
|
|
170
|
+
.limit(limit);
|
|
171
|
+
return rows.map((r) => ({ id: r.id, delta: r.delta, reason: r.reason, createdAt: r.createdAt.getTime(), amountCents: r.amountCents ?? null }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface LedgerStats {
|
|
175
|
+
creditsIssued: number;
|
|
176
|
+
creditsSpent: number;
|
|
177
|
+
balanceOutstanding: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Aggregate ledger stats (granted vs spent, outstanding) — the generic part of an admin dashboard. The user COUNT is the
|
|
181
|
+
* app's (it owns the user table); compose it on top. */
|
|
182
|
+
export async function ledgerStats(db: CreditsDB): Promise<LedgerStats> {
|
|
183
|
+
const ledger = await db
|
|
184
|
+
.select({
|
|
185
|
+
issued: sql<number>`coalesce(sum(case when ${creditTransaction.delta} > 0 then ${creditTransaction.delta} else 0 end), 0)`,
|
|
186
|
+
spent: sql<number>`coalesce(sum(case when ${creditTransaction.delta} < 0 then -${creditTransaction.delta} else 0 end), 0)`,
|
|
187
|
+
balance: sql<number>`coalesce(sum(${creditTransaction.delta}), 0)`,
|
|
188
|
+
})
|
|
189
|
+
.from(creditTransaction);
|
|
190
|
+
return { creditsIssued: Number(ledger[0]?.issued ?? 0), creditsSpent: Number(ledger[0]?.spent ?? 0), balanceOutstanding: Number(ledger[0]?.balance ?? 0) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Idempotent money-IN grant — credit `amount` exactly once, keyed on the ledger row id `idemKey` (a STABLE per-payment
|
|
195
|
+
* anchor: `pi:<id>` / `inv:<id>` / `cs:<id>`), so a webhook redelivery or dashboard "Resend" can NEVER double-credit. The
|
|
196
|
+
* money-IN twin of {@link debitOnceIfCovers} (which guards money-OUT). `legacyKey`, when given, is an ADDITIONAL anchor
|
|
197
|
+
* honoured: if a row already exists under it the money is already credited and we skip — so MOVING the idempotency key
|
|
198
|
+
* across a deploy (e.g. event-id → session-id) can't re-grant an in-flight payment. This is the LEDGER-INTEGRITY
|
|
199
|
+
* chokepoint for every grant: it rejects a non-finite / non-integer / non-positive delta, so an upstream
|
|
200
|
+
* `Number(metadata.credits)` can never let "Infinity" (which would poison every later balance read) or "500.9" (which
|
|
201
|
+
* would break balance == SUM(int delta)) reach the ledger. Returns true ONLY on a FRESH grant; on a fresh grant the cash
|
|
202
|
+
* `amountCents` (when given) is annotated for the $ detail. Use this for Stripe webhook crediting (top-up / subscription).
|
|
203
|
+
*/
|
|
204
|
+
export async function grantOnce(
|
|
205
|
+
db: CreditsDB,
|
|
206
|
+
userId: string,
|
|
207
|
+
amount: number,
|
|
208
|
+
idemKey: string,
|
|
209
|
+
reason = "grant",
|
|
210
|
+
amountCents?: number | null,
|
|
211
|
+
legacyKey?: string,
|
|
212
|
+
): Promise<boolean> {
|
|
213
|
+
if (!Number.isFinite(amount) || !Number.isInteger(amount) || amount <= 0) {
|
|
214
|
+
console.warn(`[credit] rejected non-finite/non-integer/non-positive amount ${amount} for user ${userId} (${idemKey})`);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
if (legacyKey) {
|
|
218
|
+
const prior = await db.select({ id: creditTransaction.id }).from(creditTransaction).where(eq(creditTransaction.id, legacyKey)).limit(1);
|
|
219
|
+
if (prior.length > 0) return false; // already credited under the pre-deploy key → never grant twice across the key move
|
|
220
|
+
}
|
|
221
|
+
const res = await db.insert(creditTransaction).values({ id: idemKey, userId, delta: amount, reason, createdAt: new Date() }).onConflictDoNothing().run();
|
|
222
|
+
const credited = rowsChanged(res) > 0;
|
|
223
|
+
if (credited) await recordAmount(db, idemKey, amountCents); // cosmetic $ annotation, only on a real (idempotent) grant
|
|
224
|
+
return credited;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Grant/top-up credits. Returns the new balance. */
|
|
228
|
+
export async function addCredits(db: CreditsDB, userId: string, amount: number, reason: string): Promise<number> {
|
|
229
|
+
if (!Number.isInteger(amount) || amount <= 0) throw new Error("amount must be a positive integer");
|
|
230
|
+
await record(db, userId, amount, reason);
|
|
231
|
+
return getBalance(db, userId);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Debit credits if the balance covers it; throws InsufficientCreditsError otherwise. Returns the new balance.
|
|
236
|
+
* NOTE: read-then-write — fine at low concurrency; use {@link debitIfCovers} for the concurrency-safe atomic path.
|
|
237
|
+
*/
|
|
238
|
+
export async function debitCredits(db: CreditsDB, userId: string, amount: number, reason: string): Promise<number> {
|
|
239
|
+
if (!Number.isInteger(amount) || amount <= 0) throw new Error("amount must be a positive integer");
|
|
240
|
+
const balance = await getBalance(db, userId);
|
|
241
|
+
if (balance < amount) throw new InsufficientCreditsError(balance, amount);
|
|
242
|
+
await record(db, userId, -amount, reason);
|
|
243
|
+
return balance - amount;
|
|
244
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @suluk/credits — a metered credit ledger (C046, extracted verbatim). The package OWNS the schema (`credit_transaction`
|
|
3
|
+
* + the `credit_amount`/`credit_key` sidecars); the app injects a Drizzle handle (D1 in prod, bun:sqlite in tests). The
|
|
4
|
+
* money-correctness core: the ATOMIC `debitIfCovers` (a conditional INSERT that can't drive the ledger negative under
|
|
5
|
+
* concurrency) + the idempotent `debitOnceIfCovers` (the partial-refund double-spend guard) + per-key spend + the
|
|
6
|
+
* activity-log query. App-specific payment-alert kinds + the user-table count stay in the app.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
type CreditsDB,
|
|
10
|
+
InsufficientCreditsError,
|
|
11
|
+
getBalance, record, recordKey,
|
|
12
|
+
debitIfCovers, keySpend,
|
|
13
|
+
nonceFor, debitOnceIfCovers, debitOnceAttributed, type DebitOutcome,
|
|
14
|
+
ledgerRow, recordAmount, listTransactions, type LedgerEntry,
|
|
15
|
+
ledgerStats, type LedgerStats,
|
|
16
|
+
grantOnce, addCredits, debitCredits,
|
|
17
|
+
} from "./credits";
|
|
18
|
+
export { creditTransaction, creditAmount, creditKey } from "./schema";
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The credit-ledger schema (C046) — owned by @suluk/credits, applied by the app's migrations. The ledger is the single
|
|
3
|
+
* writer of truth: an append-only `credit_transaction` (signed `delta`), a `credit_amount` sidecar for the cash that
|
|
4
|
+
* moved (cosmetic), and a `credit_key` sidecar attributing a debit to the API key that spent it (for per-key headroom).
|
|
5
|
+
*
|
|
6
|
+
* `userId` is a plain column (the app owns the `user` table + any FK / cascade); the two sidecars FK the transaction id.
|
|
7
|
+
* Extracted verbatim from the source schema.
|
|
8
|
+
*/
|
|
9
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
10
|
+
|
|
11
|
+
export const creditTransaction = sqliteTable("credit_transaction", {
|
|
12
|
+
id: text("id").primaryKey(),
|
|
13
|
+
/** the principal / user id (the app owns the user table; add the FK in your migration if you want the cascade). */
|
|
14
|
+
userId: text("userId").notNull(),
|
|
15
|
+
/** + on grant/top-up/subscription, − on debit. */
|
|
16
|
+
delta: integer("delta").notNull(),
|
|
17
|
+
/** the ledger reason, e.g. "signup_grant" | "topup" | "transcribe" — free text (the app's taxonomy). */
|
|
18
|
+
reason: text("reason").notNull(),
|
|
19
|
+
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const creditAmount = sqliteTable("credit_amount", {
|
|
23
|
+
txnId: text("txnId").primaryKey().references(() => creditTransaction.id, { onDelete: "cascade" }),
|
|
24
|
+
/** the SIGNED cash that moved with this row (+ paid in, − refunded out), in cents. */
|
|
25
|
+
amountCents: integer("amountCents").notNull(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const creditKey = sqliteTable("credit_key", {
|
|
29
|
+
txnId: text("txnId").primaryKey().references(() => creditTransaction.id, { onDelete: "cascade" }),
|
|
30
|
+
/** the API key / connection id that spent this debit (an apikey.id, or e.g. `mcp:<userId>:<clientId>`). */
|
|
31
|
+
keyId: text("keyId").notNull(),
|
|
32
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
4
|
+
import {
|
|
5
|
+
getBalance, addCredits, debitIfCovers, debitCredits, debitOnceIfCovers, debitOnceAttributed,
|
|
6
|
+
recordKey, keySpend, listTransactions, ledgerStats, nonceFor, grantOnce, InsufficientCreditsError, type CreditsDB,
|
|
7
|
+
} from "../src/index";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* C046 — the credit ledger, witnessed against a REAL bun:sqlite (the whole point is the atomic SQL). The package owns the
|
|
11
|
+
* schema; we apply it here and bridge bun:sqlite → CreditsDB (a runtime-identity narrow, as the source app does in tests).
|
|
12
|
+
*/
|
|
13
|
+
function freshDb(): CreditsDB {
|
|
14
|
+
const sqlite = new Database(":memory:");
|
|
15
|
+
sqlite.run(`CREATE TABLE credit_transaction (id TEXT PRIMARY KEY, userId TEXT NOT NULL, delta INTEGER NOT NULL, reason TEXT NOT NULL, createdAt INTEGER NOT NULL)`);
|
|
16
|
+
sqlite.run(`CREATE TABLE credit_amount (txnId TEXT PRIMARY KEY REFERENCES credit_transaction(id), amountCents INTEGER NOT NULL)`);
|
|
17
|
+
sqlite.run(`CREATE TABLE credit_key (txnId TEXT PRIMARY KEY REFERENCES credit_transaction(id), keyId TEXT NOT NULL)`);
|
|
18
|
+
return drizzle(sqlite) as unknown as CreditsDB;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let db: CreditsDB;
|
|
22
|
+
const U = "user_1";
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
db = freshDb();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("ledger basics", () => {
|
|
28
|
+
test("addCredits → balance is the sum of deltas", async () => {
|
|
29
|
+
expect(await getBalance(db, U)).toBe(0);
|
|
30
|
+
expect(await addCredits(db, U, 100, "topup")).toBe(100);
|
|
31
|
+
expect(await addCredits(db, U, 50, "topup")).toBe(150);
|
|
32
|
+
expect(await getBalance(db, U)).toBe(150);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("addCredits rejects a non-positive / non-integer amount", async () => {
|
|
36
|
+
await expect(addCredits(db, U, 0, "x")).rejects.toThrow();
|
|
37
|
+
await expect(addCredits(db, U, 1.5, "x")).rejects.toThrow();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("debitIfCovers — the atomic floor (never negative)", () => {
|
|
42
|
+
test("debits when covered; rejects when it would go below zero; balance never negative", async () => {
|
|
43
|
+
await addCredits(db, U, 100, "topup");
|
|
44
|
+
expect(await debitIfCovers(db, U, 30, "transcribe")).toBe(true);
|
|
45
|
+
expect(await getBalance(db, U)).toBe(70);
|
|
46
|
+
expect(await debitIfCovers(db, U, 100, "transcribe")).toBe(false); // 100 > 70 → no debit
|
|
47
|
+
expect(await getBalance(db, U)).toBe(70); // unchanged — the conditional INSERT didn't fire
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("self-guard: a negative amount can't MINT credits", async () => {
|
|
51
|
+
await addCredits(db, U, 10, "topup");
|
|
52
|
+
expect(await debitIfCovers(db, U, -50, "evil")).toBe(false);
|
|
53
|
+
expect(await getBalance(db, U)).toBe(10);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("attributes the debit to a key (keySpend)", async () => {
|
|
57
|
+
await addCredits(db, U, 100, "topup");
|
|
58
|
+
await debitIfCovers(db, U, 40, "transcribe", "key_abc");
|
|
59
|
+
expect(await keySpend(db, "key_abc")).toBe(40);
|
|
60
|
+
expect(await keySpend(db, "key_other")).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("debitCredits — read-then-write, throws when short", () => {
|
|
65
|
+
test("throws InsufficientCreditsError with balance + needed", async () => {
|
|
66
|
+
await addCredits(db, U, 20, "topup");
|
|
67
|
+
expect(await debitCredits(db, U, 15, "use")).toBe(5);
|
|
68
|
+
await expect(debitCredits(db, U, 99, "use")).rejects.toBeInstanceOf(InsufficientCreditsError);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("debitOnceIfCovers — idempotent double-spend guard", () => {
|
|
73
|
+
test("a fresh debit, then a replay of the SAME idemKey does NOT debit again", async () => {
|
|
74
|
+
await addCredits(db, U, 100, "topup");
|
|
75
|
+
const first = await debitOnceIfCovers(db, U, 30, "refund", "stripe_re_1");
|
|
76
|
+
expect(first.outcome).toBe("debited");
|
|
77
|
+
expect(first.nonce).toBe(nonceFor("refund", "stripe_re_1"));
|
|
78
|
+
expect(await getBalance(db, U)).toBe(70);
|
|
79
|
+
|
|
80
|
+
const replay = await debitOnceIfCovers(db, U, 30, "refund", "stripe_re_1"); // retry
|
|
81
|
+
expect(replay.outcome).toBe("replayed");
|
|
82
|
+
expect(await getBalance(db, U)).toBe(70); // NOT 40 — no second debit
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("insufficient when the balance no longer covers a NEW idemKey", async () => {
|
|
86
|
+
await addCredits(db, U, 10, "topup");
|
|
87
|
+
const r = await debitOnceIfCovers(db, U, 50, "refund", "stripe_re_2");
|
|
88
|
+
expect(r.outcome).toBe("insufficient");
|
|
89
|
+
expect(await getBalance(db, U)).toBe(10);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("debitOnceAttributed attributes only the fresh debit", async () => {
|
|
93
|
+
await addCredits(db, U, 100, "topup");
|
|
94
|
+
await debitOnceAttributed(db, U, 25, "bulk", "item_1", "key_x");
|
|
95
|
+
await debitOnceAttributed(db, U, 25, "bulk", "item_1", "key_x"); // replay → no new spend
|
|
96
|
+
expect(await keySpend(db, "key_x")).toBe(25);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("grantOnce — idempotent money-IN (the webhook credit point)", () => {
|
|
101
|
+
test("a fresh grant credits; a replay of the SAME idemKey does NOT credit again", async () => {
|
|
102
|
+
expect(await grantOnce(db, U, 600, "pi:abc", "topup", 2000)).toBe(true);
|
|
103
|
+
expect(await getBalance(db, U)).toBe(600);
|
|
104
|
+
expect(await grantOnce(db, U, 600, "pi:abc", "topup", 2000)).toBe(false); // redelivery / Resend
|
|
105
|
+
expect(await getBalance(db, U)).toBe(600); // NOT 1200 — keyed on pi:abc
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("legacyKey honoured: a grant already recorded under the OLD key is never re-granted under a new one", async () => {
|
|
109
|
+
expect(await grantOnce(db, U, 100, "stripe:evt_1", "topup", 500)).toBe(true); // the pre-deploy grant
|
|
110
|
+
expect(await grantOnce(db, U, 100, "cs:sess_1", "topup", 500, "stripe:evt_1")).toBe(false); // key moved → skip
|
|
111
|
+
expect(await getBalance(db, U)).toBe(100);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("rejects a non-finite / fractional / non-positive amount (ledger stays integer-by-construction)", async () => {
|
|
115
|
+
expect(await grantOnce(db, U, Infinity, "pi:x1", "topup")).toBe(false);
|
|
116
|
+
expect(await grantOnce(db, U, 500.9, "pi:x2", "topup")).toBe(false);
|
|
117
|
+
expect(await grantOnce(db, U, -100, "pi:x3", "topup")).toBe(false);
|
|
118
|
+
expect(await getBalance(db, U)).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("annotates the cash that landed (amountCents) on a fresh grant", async () => {
|
|
122
|
+
await grantOnce(db, U, 600, "pi:amt", "topup", 2000);
|
|
123
|
+
const txns = await listTransactions(db, U);
|
|
124
|
+
expect(txns.find((t) => t.id === "pi:amt")?.amountCents).toBe(2000);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("listTransactions + ledgerStats", () => {
|
|
129
|
+
test("recent transactions newest-first; aggregate stats", async () => {
|
|
130
|
+
await addCredits(db, U, 100, "topup");
|
|
131
|
+
await debitIfCovers(db, U, 30, "transcribe");
|
|
132
|
+
const txns = await listTransactions(db, U);
|
|
133
|
+
expect(txns.length).toBe(2);
|
|
134
|
+
expect(txns.map((t) => t.delta).sort((a, b) => a - b)).toEqual([-30, 100]);
|
|
135
|
+
expect(await ledgerStats(db)).toEqual({ creditsIssued: 100, creditsSpent: 30, balanceOutstanding: 70 });
|
|
136
|
+
});
|
|
137
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }
|