@wopr-network/platform-core 1.39.6 → 1.40.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/dist/billing/stripe/stripe-payment-processor.test.js +1 -0
- package/dist/credits/credit-expiry-cron.js +11 -17
- package/dist/credits/ledger.d.ts +7 -0
- package/dist/credits/ledger.js +106 -0
- package/dist/credits/ledger.test.js +56 -0
- package/dist/db/schema/tenants.js +2 -2
- package/dist/db/seed-platform-service-tenant.d.ts +6 -0
- package/dist/db/seed-platform-service-tenant.js +42 -0
- package/dist/monetization/stripe/stripe-payment-processor.test.js +1 -0
- package/drizzle/migrations/0013_platform_service_tenant_type.sql +3 -0
- package/package.json +1 -1
- package/src/billing/stripe/stripe-payment-processor.test.ts +1 -0
- package/src/credits/credit-expiry-cron.ts +12 -18
- package/src/credits/ledger.test.ts +70 -0
- package/src/credits/ledger.ts +140 -1
- package/src/db/schema/tenants.ts +2 -2
- package/src/db/seed-platform-service-tenant.ts +55 -0
- package/src/monetization/stripe/stripe-payment-processor.test.ts +1 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { logger } from "../config/logger.js";
|
|
2
|
-
import { InsufficientBalanceError } from "./ledger.js";
|
|
3
2
|
/**
|
|
4
3
|
* Sweep expired credit grants and debit the original grant amount
|
|
5
4
|
* (or remaining balance if partially consumed).
|
|
@@ -16,31 +15,26 @@ export async function runCreditExpiryCron(cfg) {
|
|
|
16
15
|
const expiredGrants = await cfg.ledger.expiredCredits(cfg.now);
|
|
17
16
|
for (const grant of expiredGrants) {
|
|
18
17
|
try {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
// Debit the lesser of the original grant amount or current balance
|
|
25
|
-
const debitAmount = balance.lessThan(grant.amount) ? balance : grant.amount;
|
|
26
|
-
await cfg.ledger.debit(grant.tenantId, debitAmount, "credit_expiry", {
|
|
18
|
+
// debitCapped never throws InsufficientBalanceError — it caps at the
|
|
19
|
+
// available balance and returns null when balance is zero. The catch
|
|
20
|
+
// below handles unexpected errors (DB failures, constraint violations).
|
|
21
|
+
const entry = await cfg.ledger.debitCapped(grant.tenantId, grant.amount, "credit_expiry", {
|
|
27
22
|
description: `Expired credit grant reclaimed: ${grant.entryId}`,
|
|
28
23
|
referenceId: `expiry:${grant.entryId}`,
|
|
29
24
|
});
|
|
25
|
+
if (entry === null) {
|
|
26
|
+
result.skippedZeroBalance++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
30
29
|
result.processed++;
|
|
31
30
|
if (!result.expired.includes(grant.tenantId)) {
|
|
32
31
|
result.expired.push(grant.tenantId);
|
|
33
32
|
}
|
|
34
33
|
}
|
|
35
34
|
catch (err) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
41
|
-
logger.error("Credit expiry failed", { tenantId: grant.tenantId, entryId: grant.entryId, error: msg });
|
|
42
|
-
result.errors.push(`${grant.tenantId}:${grant.entryId}: ${msg}`);
|
|
43
|
-
}
|
|
35
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
36
|
+
logger.error("Credit expiry failed", { tenantId: grant.tenantId, entryId: grant.entryId, error: msg });
|
|
37
|
+
result.errors.push(`${grant.tenantId}:${grant.entryId}: ${msg}`);
|
|
44
38
|
}
|
|
45
39
|
}
|
|
46
40
|
if (result.processed > 0) {
|
package/dist/credits/ledger.d.ts
CHANGED
|
@@ -154,6 +154,12 @@ export interface ILedger {
|
|
|
154
154
|
sumPurchasesForPeriod(startTs: string, endTs: string): Promise<Credit>;
|
|
155
155
|
/** Get distinct tenantIds with a purchase entry in [startTs, endTs). */
|
|
156
156
|
getActiveTenantIdsInWindow(startTs: string, endTs: string): Promise<string[]>;
|
|
157
|
+
/**
|
|
158
|
+
* Debit up to maxAmount from a tenant, capped at their current balance.
|
|
159
|
+
* Reads balance inside the transaction (TOCTOU-safe). Returns null if
|
|
160
|
+
* balance is zero (nothing to debit).
|
|
161
|
+
*/
|
|
162
|
+
debitCapped(tenantId: string, maxAmount: Credit, type: DebitType, opts?: DebitOpts): Promise<JournalEntry | null>;
|
|
157
163
|
}
|
|
158
164
|
export declare class DrizzleLedger implements ILedger {
|
|
159
165
|
private readonly db;
|
|
@@ -177,6 +183,7 @@ export declare class DrizzleLedger implements ILedger {
|
|
|
177
183
|
post(input: PostEntryInput): Promise<JournalEntry>;
|
|
178
184
|
credit(tenantId: string, amount: Credit, type: CreditType, opts?: CreditOpts): Promise<JournalEntry>;
|
|
179
185
|
debit(tenantId: string, amount: Credit, type: DebitType, opts?: DebitOpts): Promise<JournalEntry>;
|
|
186
|
+
debitCapped(tenantId: string, maxAmount: Credit, type: DebitType, opts?: DebitOpts): Promise<JournalEntry | null>;
|
|
180
187
|
balance(tenantId: string): Promise<Credit>;
|
|
181
188
|
accountBalance(accountCode: string): Promise<Credit>;
|
|
182
189
|
hasReferenceId(referenceId: string): Promise<boolean>;
|
package/dist/credits/ledger.js
CHANGED
|
@@ -327,6 +327,112 @@ export class DrizzleLedger {
|
|
|
327
327
|
],
|
|
328
328
|
});
|
|
329
329
|
}
|
|
330
|
+
async debitCapped(tenantId, maxAmount, type, opts) {
|
|
331
|
+
const creditAccountCode = DEBIT_TYPE_ACCOUNT[type];
|
|
332
|
+
const tenantAccountCode = `2000:${tenantId}`;
|
|
333
|
+
// Everything happens inside ONE transaction so the FOR UPDATE lock is held
|
|
334
|
+
// continuously from balance read through journal posting (TOCTOU-safe).
|
|
335
|
+
return this.db.transaction(async (tx) => {
|
|
336
|
+
// Step 1: Lock BOTH accounts in accountCode-sorted order to match post()'s
|
|
337
|
+
// lock ordering and prevent ABBA deadlocks. For type="refund" the credit
|
|
338
|
+
// account is "1000" which sorts before "2000:<tenant>", so we must lock it
|
|
339
|
+
// first — the same order post() would use.
|
|
340
|
+
const sortedCodes = [tenantAccountCode, creditAccountCode].sort();
|
|
341
|
+
const lockedIds = new Map();
|
|
342
|
+
for (const code of sortedCodes) {
|
|
343
|
+
if (code.startsWith("2000:")) {
|
|
344
|
+
lockedIds.set(code, await this.ensureTenantAccountLocked(tx, code.slice(5)));
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
lockedIds.set(code, await this.resolveAccountLocked(tx, code));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const tenantAccountId = lockedIds.get(tenantAccountCode);
|
|
351
|
+
const creditAccountId = lockedIds.get(creditAccountCode);
|
|
352
|
+
if (!tenantAccountId || !creditAccountId) {
|
|
353
|
+
throw new Error("Failed to resolve account IDs during debitCapped");
|
|
354
|
+
}
|
|
355
|
+
// Step 2: Read balance while holding the lock.
|
|
356
|
+
const balRows = (await tx.execute(sql `SELECT ab.balance FROM account_balances ab
|
|
357
|
+
INNER JOIN accounts a ON a.id = ab.account_id
|
|
358
|
+
WHERE a.code = ${tenantAccountCode}`));
|
|
359
|
+
const currentBalance = Credit.fromRaw(Number(balRows.rows[0]?.balance ?? 0));
|
|
360
|
+
if (currentBalance.isZero()) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
// Step 3: Cap at lesser of maxAmount and currentBalance.
|
|
364
|
+
const debitAmount = currentBalance.lessThan(maxAmount) ? currentBalance : maxAmount;
|
|
365
|
+
// Step 4: Insert journal entry header.
|
|
366
|
+
const entryId = crypto.randomUUID();
|
|
367
|
+
const now = new Date().toISOString();
|
|
368
|
+
await tx.insert(journalEntries).values({
|
|
369
|
+
id: entryId,
|
|
370
|
+
postedAt: now,
|
|
371
|
+
entryType: type,
|
|
372
|
+
description: opts?.description ?? null,
|
|
373
|
+
referenceId: opts?.referenceId ?? null,
|
|
374
|
+
tenantId,
|
|
375
|
+
metadata: { attributedUserId: opts?.attributedUserId ?? null },
|
|
376
|
+
createdBy: opts?.createdBy ?? "system",
|
|
377
|
+
});
|
|
378
|
+
// Step 5: Both accounts already locked in step 1 — no additional locking needed.
|
|
379
|
+
// Step 6: Insert journal lines.
|
|
380
|
+
// Debit line (tenant liability account — reduces balance)
|
|
381
|
+
const debitLineId = crypto.randomUUID();
|
|
382
|
+
await tx.insert(journalLines).values({
|
|
383
|
+
id: debitLineId,
|
|
384
|
+
journalEntryId: entryId,
|
|
385
|
+
accountId: tenantAccountId,
|
|
386
|
+
amount: debitAmount.toRaw(),
|
|
387
|
+
side: "debit",
|
|
388
|
+
});
|
|
389
|
+
// Credit line (revenue/target account — increases balance)
|
|
390
|
+
const creditLineId = crypto.randomUUID();
|
|
391
|
+
await tx.insert(journalLines).values({
|
|
392
|
+
id: creditLineId,
|
|
393
|
+
journalEntryId: entryId,
|
|
394
|
+
accountId: creditAccountId,
|
|
395
|
+
amount: debitAmount.toRaw(),
|
|
396
|
+
side: "credit",
|
|
397
|
+
});
|
|
398
|
+
// Step 7: Update materialized balances.
|
|
399
|
+
// Tenant account is liability (normal_side=credit): debit decreases balance.
|
|
400
|
+
await tx
|
|
401
|
+
.update(accountBalances)
|
|
402
|
+
.set({
|
|
403
|
+
balance: sql `${accountBalances.balance} + ${-debitAmount.toRaw()}`,
|
|
404
|
+
lastUpdated: sql `(now())`,
|
|
405
|
+
})
|
|
406
|
+
.where(eq(accountBalances.accountId, tenantAccountId));
|
|
407
|
+
// Credit-side account: look up its normal_side to determine delta direction.
|
|
408
|
+
const acctRow = (await tx.execute(sql `SELECT normal_side FROM accounts WHERE id = ${creditAccountId}`));
|
|
409
|
+
const normalSide = acctRow.rows[0]?.normal_side;
|
|
410
|
+
if (!normalSide)
|
|
411
|
+
throw new Error(`Account ${creditAccountId} missing normal_side`);
|
|
412
|
+
const creditDelta = "credit" === normalSide ? debitAmount.toRaw() : -debitAmount.toRaw();
|
|
413
|
+
await tx
|
|
414
|
+
.update(accountBalances)
|
|
415
|
+
.set({
|
|
416
|
+
balance: sql `${accountBalances.balance} + ${creditDelta}`,
|
|
417
|
+
lastUpdated: sql `(now())`,
|
|
418
|
+
})
|
|
419
|
+
.where(eq(accountBalances.accountId, creditAccountId));
|
|
420
|
+
// Step 8: Return the journal entry.
|
|
421
|
+
return {
|
|
422
|
+
id: entryId,
|
|
423
|
+
postedAt: now,
|
|
424
|
+
entryType: type,
|
|
425
|
+
tenantId,
|
|
426
|
+
description: opts?.description ?? null,
|
|
427
|
+
referenceId: opts?.referenceId ?? null,
|
|
428
|
+
metadata: { attributedUserId: opts?.attributedUserId ?? null },
|
|
429
|
+
lines: [
|
|
430
|
+
{ accountCode: tenantAccountCode, amount: debitAmount, side: "debit" },
|
|
431
|
+
{ accountCode: creditAccountCode, amount: debitAmount, side: "credit" },
|
|
432
|
+
],
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
}
|
|
330
436
|
// -- Queries -------------------------------------------------------------
|
|
331
437
|
async balance(tenantId) {
|
|
332
438
|
const code = `2000:${tenantId}`;
|
|
@@ -578,4 +578,60 @@ describe("DrizzleLedger", () => {
|
|
|
578
578
|
expect(tb.balanced).toBe(true);
|
|
579
579
|
});
|
|
580
580
|
});
|
|
581
|
+
// -----------------------------------------------------------------------
|
|
582
|
+
// debitCapped()
|
|
583
|
+
// -----------------------------------------------------------------------
|
|
584
|
+
describe("debitCapped()", () => {
|
|
585
|
+
it("caps debit at current balance when maxAmount exceeds balance", async () => {
|
|
586
|
+
await ledger.credit("tenant-cap", Credit.fromCents(300), "promo", {
|
|
587
|
+
description: "Test grant",
|
|
588
|
+
});
|
|
589
|
+
const entry = await ledger.debitCapped("tenant-cap", Credit.fromCents(500), // maxAmount > balance
|
|
590
|
+
"credit_expiry", { description: "Expiry test", referenceId: "expiry:test-cap" });
|
|
591
|
+
if (entry === null)
|
|
592
|
+
throw new Error("expected non-null entry");
|
|
593
|
+
// Should have debited 300 (the balance), not 500 (the maxAmount)
|
|
594
|
+
const debitLine = entry.lines.find((l) => l.accountCode === "2000:tenant-cap" && l.side === "debit");
|
|
595
|
+
if (!debitLine)
|
|
596
|
+
throw new Error("expected debit line");
|
|
597
|
+
expect(debitLine.amount.toCents()).toBe(300);
|
|
598
|
+
const balance = await ledger.balance("tenant-cap");
|
|
599
|
+
expect(balance.toCents()).toBe(0);
|
|
600
|
+
});
|
|
601
|
+
it("returns null when balance is zero", async () => {
|
|
602
|
+
const entry = await ledger.debitCapped("tenant-zero", Credit.fromCents(500), "credit_expiry", {
|
|
603
|
+
description: "Expiry test",
|
|
604
|
+
referenceId: "expiry:test-zero",
|
|
605
|
+
});
|
|
606
|
+
expect(entry).toBeNull();
|
|
607
|
+
});
|
|
608
|
+
it("debits exact maxAmount when balance exceeds it", async () => {
|
|
609
|
+
await ledger.credit("tenant-over", Credit.fromCents(1000), "purchase", {
|
|
610
|
+
description: "Big deposit",
|
|
611
|
+
});
|
|
612
|
+
const entry = await ledger.debitCapped("tenant-over", Credit.fromCents(300), "credit_expiry", {
|
|
613
|
+
description: "Partial expiry",
|
|
614
|
+
referenceId: "expiry:test-over",
|
|
615
|
+
});
|
|
616
|
+
if (entry === null)
|
|
617
|
+
throw new Error("expected non-null entry");
|
|
618
|
+
const debitLine = entry.lines.find((l) => l.accountCode === "2000:tenant-over" && l.side === "debit");
|
|
619
|
+
if (!debitLine)
|
|
620
|
+
throw new Error("expected debit line");
|
|
621
|
+
expect(debitLine.amount.toCents()).toBe(300);
|
|
622
|
+
const balance = await ledger.balance("tenant-over");
|
|
623
|
+
expect(balance.toCents()).toBe(700);
|
|
624
|
+
});
|
|
625
|
+
it("books balance correctly after capped debit (trial balance holds)", async () => {
|
|
626
|
+
await ledger.credit("tenant-tb", Credit.fromCents(200), "promo", {
|
|
627
|
+
description: "Small grant",
|
|
628
|
+
});
|
|
629
|
+
await ledger.debitCapped("tenant-tb", Credit.fromCents(500), "credit_expiry", {
|
|
630
|
+
description: "Expiry",
|
|
631
|
+
referenceId: "expiry:test-tb",
|
|
632
|
+
});
|
|
633
|
+
const tb = await ledger.trialBalance();
|
|
634
|
+
expect(tb.balanced).toBe(true);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
581
637
|
});
|
|
@@ -4,7 +4,7 @@ export const tenants = pgTable("tenants", {
|
|
|
4
4
|
id: text("id").primaryKey(), // nanoid or crypto.randomUUID()
|
|
5
5
|
name: text("name").notNull(),
|
|
6
6
|
slug: text("slug").unique(),
|
|
7
|
-
type: text("type").notNull(), // "personal" | "org"
|
|
7
|
+
type: text("type").notNull(), // "personal" | "org" | "platform_service"
|
|
8
8
|
ownerId: text("owner_id").notNull(), // user who created it
|
|
9
9
|
billingEmail: text("billing_email"),
|
|
10
10
|
createdAt: bigint("created_at", { mode: "number" })
|
|
@@ -14,5 +14,5 @@ export const tenants = pgTable("tenants", {
|
|
|
14
14
|
index("idx_tenants_slug").on(table.slug),
|
|
15
15
|
index("idx_tenants_owner").on(table.ownerId),
|
|
16
16
|
index("idx_tenants_type").on(table.type),
|
|
17
|
-
check("chk_tenants_type", sql `${table.type} IN ('personal', 'org')`),
|
|
17
|
+
check("chk_tenants_type", sql `${table.type} IN ('personal', 'org', 'platform_service')`),
|
|
18
18
|
]);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotent seed: creates the holyship-platform internal billing tenant
|
|
3
|
+
* with a stable service key for platform-to-gateway LLM billing.
|
|
4
|
+
*
|
|
5
|
+
* Safe to call on every boot — skips if the tenant already exists.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import { eq } from "drizzle-orm";
|
|
9
|
+
import { gatewayServiceKeys } from "./schema/gateway-service-keys.js";
|
|
10
|
+
import { tenants } from "./schema/tenants.js";
|
|
11
|
+
const PLATFORM_TENANT_ID = "holyship-platform";
|
|
12
|
+
const PLATFORM_TENANT_SLUG = "holyship-platform";
|
|
13
|
+
const PLATFORM_INSTANCE_ID = "holyship-platform-internal";
|
|
14
|
+
export async function seedPlatformServiceTenant(db) {
|
|
15
|
+
// Check if tenant already exists
|
|
16
|
+
const existing = await db.select({ id: tenants.id }).from(tenants).where(eq(tenants.id, PLATFORM_TENANT_ID)).limit(1);
|
|
17
|
+
if (existing.length > 0) {
|
|
18
|
+
return { tenantId: PLATFORM_TENANT_ID, serviceKey: null };
|
|
19
|
+
}
|
|
20
|
+
// Create the platform_service tenant
|
|
21
|
+
await db.insert(tenants).values({
|
|
22
|
+
id: PLATFORM_TENANT_ID,
|
|
23
|
+
name: "Holy Ship Platform",
|
|
24
|
+
slug: PLATFORM_TENANT_SLUG,
|
|
25
|
+
type: "platform_service",
|
|
26
|
+
ownerId: "system",
|
|
27
|
+
billingEmail: null,
|
|
28
|
+
createdAt: Date.now(),
|
|
29
|
+
});
|
|
30
|
+
// Generate a service key and store its hash
|
|
31
|
+
const rawKey = `sk-hs-${randomBytes(24).toString("hex")}`;
|
|
32
|
+
const keyHash = createHash("sha256").update(rawKey).digest("hex");
|
|
33
|
+
await db.insert(gatewayServiceKeys).values({
|
|
34
|
+
id: crypto.randomUUID(),
|
|
35
|
+
keyHash,
|
|
36
|
+
tenantId: PLATFORM_TENANT_ID,
|
|
37
|
+
instanceId: PLATFORM_INSTANCE_ID,
|
|
38
|
+
createdAt: Date.now(),
|
|
39
|
+
revokedAt: null,
|
|
40
|
+
});
|
|
41
|
+
return { tenantId: PLATFORM_TENANT_ID, serviceKey: rawKey };
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { logger } from "../config/logger.js";
|
|
2
2
|
import type { ILedger } from "./ledger.js";
|
|
3
|
-
import { InsufficientBalanceError } from "./ledger.js";
|
|
4
3
|
|
|
5
4
|
export interface CreditExpiryCronConfig {
|
|
6
5
|
ledger: ILedger;
|
|
@@ -33,32 +32,27 @@ export async function runCreditExpiryCron(cfg: CreditExpiryCronConfig): Promise<
|
|
|
33
32
|
|
|
34
33
|
for (const grant of expiredGrants) {
|
|
35
34
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Debit the lesser of the original grant amount or current balance
|
|
43
|
-
const debitAmount = balance.lessThan(grant.amount) ? balance : grant.amount;
|
|
44
|
-
|
|
45
|
-
await cfg.ledger.debit(grant.tenantId, debitAmount, "credit_expiry", {
|
|
35
|
+
// debitCapped never throws InsufficientBalanceError — it caps at the
|
|
36
|
+
// available balance and returns null when balance is zero. The catch
|
|
37
|
+
// below handles unexpected errors (DB failures, constraint violations).
|
|
38
|
+
const entry = await cfg.ledger.debitCapped(grant.tenantId, grant.amount, "credit_expiry", {
|
|
46
39
|
description: `Expired credit grant reclaimed: ${grant.entryId}`,
|
|
47
40
|
referenceId: `expiry:${grant.entryId}`,
|
|
48
41
|
});
|
|
49
42
|
|
|
43
|
+
if (entry === null) {
|
|
44
|
+
result.skippedZeroBalance++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
50
48
|
result.processed++;
|
|
51
49
|
if (!result.expired.includes(grant.tenantId)) {
|
|
52
50
|
result.expired.push(grant.tenantId);
|
|
53
51
|
}
|
|
54
52
|
} catch (err) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
59
|
-
logger.error("Credit expiry failed", { tenantId: grant.tenantId, entryId: grant.entryId, error: msg });
|
|
60
|
-
result.errors.push(`${grant.tenantId}:${grant.entryId}: ${msg}`);
|
|
61
|
-
}
|
|
53
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
54
|
+
logger.error("Credit expiry failed", { tenantId: grant.tenantId, entryId: grant.entryId, error: msg });
|
|
55
|
+
result.errors.push(`${grant.tenantId}:${grant.entryId}: ${msg}`);
|
|
62
56
|
}
|
|
63
57
|
}
|
|
64
58
|
|
|
@@ -714,4 +714,74 @@ describe("DrizzleLedger", () => {
|
|
|
714
714
|
expect(tb.balanced).toBe(true);
|
|
715
715
|
});
|
|
716
716
|
});
|
|
717
|
+
|
|
718
|
+
// -----------------------------------------------------------------------
|
|
719
|
+
// debitCapped()
|
|
720
|
+
// -----------------------------------------------------------------------
|
|
721
|
+
|
|
722
|
+
describe("debitCapped()", () => {
|
|
723
|
+
it("caps debit at current balance when maxAmount exceeds balance", async () => {
|
|
724
|
+
await ledger.credit("tenant-cap", Credit.fromCents(300), "promo", {
|
|
725
|
+
description: "Test grant",
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
const entry = await ledger.debitCapped(
|
|
729
|
+
"tenant-cap",
|
|
730
|
+
Credit.fromCents(500), // maxAmount > balance
|
|
731
|
+
"credit_expiry",
|
|
732
|
+
{ description: "Expiry test", referenceId: "expiry:test-cap" },
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
if (entry === null) throw new Error("expected non-null entry");
|
|
736
|
+
// Should have debited 300 (the balance), not 500 (the maxAmount)
|
|
737
|
+
const debitLine = entry.lines.find((l) => l.accountCode === "2000:tenant-cap" && l.side === "debit");
|
|
738
|
+
if (!debitLine) throw new Error("expected debit line");
|
|
739
|
+
expect(debitLine.amount.toCents()).toBe(300);
|
|
740
|
+
|
|
741
|
+
const balance = await ledger.balance("tenant-cap");
|
|
742
|
+
expect(balance.toCents()).toBe(0);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("returns null when balance is zero", async () => {
|
|
746
|
+
const entry = await ledger.debitCapped("tenant-zero", Credit.fromCents(500), "credit_expiry", {
|
|
747
|
+
description: "Expiry test",
|
|
748
|
+
referenceId: "expiry:test-zero",
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
expect(entry).toBeNull();
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it("debits exact maxAmount when balance exceeds it", async () => {
|
|
755
|
+
await ledger.credit("tenant-over", Credit.fromCents(1000), "purchase", {
|
|
756
|
+
description: "Big deposit",
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
const entry = await ledger.debitCapped("tenant-over", Credit.fromCents(300), "credit_expiry", {
|
|
760
|
+
description: "Partial expiry",
|
|
761
|
+
referenceId: "expiry:test-over",
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
if (entry === null) throw new Error("expected non-null entry");
|
|
765
|
+
const debitLine = entry.lines.find((l) => l.accountCode === "2000:tenant-over" && l.side === "debit");
|
|
766
|
+
if (!debitLine) throw new Error("expected debit line");
|
|
767
|
+
expect(debitLine.amount.toCents()).toBe(300);
|
|
768
|
+
|
|
769
|
+
const balance = await ledger.balance("tenant-over");
|
|
770
|
+
expect(balance.toCents()).toBe(700);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it("books balance correctly after capped debit (trial balance holds)", async () => {
|
|
774
|
+
await ledger.credit("tenant-tb", Credit.fromCents(200), "promo", {
|
|
775
|
+
description: "Small grant",
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
await ledger.debitCapped("tenant-tb", Credit.fromCents(500), "credit_expiry", {
|
|
779
|
+
description: "Expiry",
|
|
780
|
+
referenceId: "expiry:test-tb",
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const tb = await ledger.trialBalance();
|
|
784
|
+
expect(tb.balanced).toBe(true);
|
|
785
|
+
});
|
|
786
|
+
});
|
|
717
787
|
});
|
package/src/credits/ledger.ts
CHANGED
|
@@ -281,6 +281,13 @@ export interface ILedger {
|
|
|
281
281
|
|
|
282
282
|
/** Get distinct tenantIds with a purchase entry in [startTs, endTs). */
|
|
283
283
|
getActiveTenantIdsInWindow(startTs: string, endTs: string): Promise<string[]>;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Debit up to maxAmount from a tenant, capped at their current balance.
|
|
287
|
+
* Reads balance inside the transaction (TOCTOU-safe). Returns null if
|
|
288
|
+
* balance is zero (nothing to debit).
|
|
289
|
+
*/
|
|
290
|
+
debitCapped(tenantId: string, maxAmount: Credit, type: DebitType, opts?: DebitOpts): Promise<JournalEntry | null>;
|
|
284
291
|
}
|
|
285
292
|
|
|
286
293
|
// ---------------------------------------------------------------------------
|
|
@@ -463,7 +470,9 @@ export class DrizzleLedger implements ILedger {
|
|
|
463
470
|
// We store balance in "normal" direction, so:
|
|
464
471
|
const acctRow = (await tx.execute(
|
|
465
472
|
sql`SELECT normal_side FROM accounts WHERE id = ${accountId}`,
|
|
466
|
-
)) as unknown as {
|
|
473
|
+
)) as unknown as {
|
|
474
|
+
rows: Array<{ normal_side: Side }>;
|
|
475
|
+
};
|
|
467
476
|
const normalSide = acctRow.rows[0]?.normal_side;
|
|
468
477
|
if (!normalSide) throw new Error(`Account ${accountId} missing normal_side`);
|
|
469
478
|
|
|
@@ -553,6 +562,136 @@ export class DrizzleLedger implements ILedger {
|
|
|
553
562
|
});
|
|
554
563
|
}
|
|
555
564
|
|
|
565
|
+
async debitCapped(
|
|
566
|
+
tenantId: string,
|
|
567
|
+
maxAmount: Credit,
|
|
568
|
+
type: DebitType,
|
|
569
|
+
opts?: DebitOpts,
|
|
570
|
+
): Promise<JournalEntry | null> {
|
|
571
|
+
const creditAccountCode = DEBIT_TYPE_ACCOUNT[type];
|
|
572
|
+
const tenantAccountCode = `2000:${tenantId}`;
|
|
573
|
+
|
|
574
|
+
// Everything happens inside ONE transaction so the FOR UPDATE lock is held
|
|
575
|
+
// continuously from balance read through journal posting (TOCTOU-safe).
|
|
576
|
+
return this.db.transaction(async (tx) => {
|
|
577
|
+
// Step 1: Lock BOTH accounts in accountCode-sorted order to match post()'s
|
|
578
|
+
// lock ordering and prevent ABBA deadlocks. For type="refund" the credit
|
|
579
|
+
// account is "1000" which sorts before "2000:<tenant>", so we must lock it
|
|
580
|
+
// first — the same order post() would use.
|
|
581
|
+
const sortedCodes = [tenantAccountCode, creditAccountCode].sort();
|
|
582
|
+
const lockedIds = new Map<string, string>();
|
|
583
|
+
for (const code of sortedCodes) {
|
|
584
|
+
if (code.startsWith("2000:")) {
|
|
585
|
+
lockedIds.set(code, await this.ensureTenantAccountLocked(tx, code.slice(5)));
|
|
586
|
+
} else {
|
|
587
|
+
lockedIds.set(code, await this.resolveAccountLocked(tx, code));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const tenantAccountId = lockedIds.get(tenantAccountCode);
|
|
591
|
+
const creditAccountId = lockedIds.get(creditAccountCode);
|
|
592
|
+
if (!tenantAccountId || !creditAccountId) {
|
|
593
|
+
throw new Error("Failed to resolve account IDs during debitCapped");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Step 2: Read balance while holding the lock.
|
|
597
|
+
const balRows = (await tx.execute(
|
|
598
|
+
sql`SELECT ab.balance FROM account_balances ab
|
|
599
|
+
INNER JOIN accounts a ON a.id = ab.account_id
|
|
600
|
+
WHERE a.code = ${tenantAccountCode}`,
|
|
601
|
+
)) as unknown as { rows: Array<{ balance: number }> };
|
|
602
|
+
const currentBalance = Credit.fromRaw(Number(balRows.rows[0]?.balance ?? 0));
|
|
603
|
+
|
|
604
|
+
if (currentBalance.isZero()) {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Step 3: Cap at lesser of maxAmount and currentBalance.
|
|
609
|
+
const debitAmount = currentBalance.lessThan(maxAmount) ? currentBalance : maxAmount;
|
|
610
|
+
|
|
611
|
+
// Step 4: Insert journal entry header.
|
|
612
|
+
const entryId = crypto.randomUUID();
|
|
613
|
+
const now = new Date().toISOString();
|
|
614
|
+
|
|
615
|
+
await tx.insert(journalEntries).values({
|
|
616
|
+
id: entryId,
|
|
617
|
+
postedAt: now,
|
|
618
|
+
entryType: type,
|
|
619
|
+
description: opts?.description ?? null,
|
|
620
|
+
referenceId: opts?.referenceId ?? null,
|
|
621
|
+
tenantId,
|
|
622
|
+
metadata: { attributedUserId: opts?.attributedUserId ?? null },
|
|
623
|
+
createdBy: opts?.createdBy ?? "system",
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Step 5: Both accounts already locked in step 1 — no additional locking needed.
|
|
627
|
+
|
|
628
|
+
// Step 6: Insert journal lines.
|
|
629
|
+
// Debit line (tenant liability account — reduces balance)
|
|
630
|
+
const debitLineId = crypto.randomUUID();
|
|
631
|
+
await tx.insert(journalLines).values({
|
|
632
|
+
id: debitLineId,
|
|
633
|
+
journalEntryId: entryId,
|
|
634
|
+
accountId: tenantAccountId,
|
|
635
|
+
amount: debitAmount.toRaw(),
|
|
636
|
+
side: "debit",
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// Credit line (revenue/target account — increases balance)
|
|
640
|
+
const creditLineId = crypto.randomUUID();
|
|
641
|
+
await tx.insert(journalLines).values({
|
|
642
|
+
id: creditLineId,
|
|
643
|
+
journalEntryId: entryId,
|
|
644
|
+
accountId: creditAccountId,
|
|
645
|
+
amount: debitAmount.toRaw(),
|
|
646
|
+
side: "credit",
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// Step 7: Update materialized balances.
|
|
650
|
+
// Tenant account is liability (normal_side=credit): debit decreases balance.
|
|
651
|
+
await tx
|
|
652
|
+
.update(accountBalances)
|
|
653
|
+
.set({
|
|
654
|
+
balance: sql`${accountBalances.balance} + ${-debitAmount.toRaw()}`,
|
|
655
|
+
lastUpdated: sql`(now())`,
|
|
656
|
+
})
|
|
657
|
+
.where(eq(accountBalances.accountId, tenantAccountId));
|
|
658
|
+
|
|
659
|
+
// Credit-side account: look up its normal_side to determine delta direction.
|
|
660
|
+
const acctRow = (await tx.execute(
|
|
661
|
+
sql`SELECT normal_side FROM accounts WHERE id = ${creditAccountId}`,
|
|
662
|
+
)) as unknown as {
|
|
663
|
+
rows: Array<{ normal_side: Side }>;
|
|
664
|
+
};
|
|
665
|
+
const normalSide = acctRow.rows[0]?.normal_side;
|
|
666
|
+
if (!normalSide) throw new Error(`Account ${creditAccountId} missing normal_side`);
|
|
667
|
+
|
|
668
|
+
const creditDelta = "credit" === normalSide ? debitAmount.toRaw() : -debitAmount.toRaw();
|
|
669
|
+
|
|
670
|
+
await tx
|
|
671
|
+
.update(accountBalances)
|
|
672
|
+
.set({
|
|
673
|
+
balance: sql`${accountBalances.balance} + ${creditDelta}`,
|
|
674
|
+
lastUpdated: sql`(now())`,
|
|
675
|
+
})
|
|
676
|
+
.where(eq(accountBalances.accountId, creditAccountId));
|
|
677
|
+
|
|
678
|
+
// Step 8: Return the journal entry.
|
|
679
|
+
return {
|
|
680
|
+
id: entryId,
|
|
681
|
+
postedAt: now,
|
|
682
|
+
entryType: type,
|
|
683
|
+
tenantId,
|
|
684
|
+
description: opts?.description ?? null,
|
|
685
|
+
referenceId: opts?.referenceId ?? null,
|
|
686
|
+
metadata: { attributedUserId: opts?.attributedUserId ?? null } as Record<string, unknown>,
|
|
687
|
+
lines: [
|
|
688
|
+
{ accountCode: tenantAccountCode, amount: debitAmount, side: "debit" as Side },
|
|
689
|
+
{ accountCode: creditAccountCode, amount: debitAmount, side: "credit" as Side },
|
|
690
|
+
],
|
|
691
|
+
};
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
556
695
|
// -- Queries -------------------------------------------------------------
|
|
557
696
|
|
|
558
697
|
async balance(tenantId: string): Promise<Credit> {
|
package/src/db/schema/tenants.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const tenants = pgTable(
|
|
|
7
7
|
id: text("id").primaryKey(), // nanoid or crypto.randomUUID()
|
|
8
8
|
name: text("name").notNull(),
|
|
9
9
|
slug: text("slug").unique(),
|
|
10
|
-
type: text("type").notNull(), // "personal" | "org"
|
|
10
|
+
type: text("type").notNull(), // "personal" | "org" | "platform_service"
|
|
11
11
|
ownerId: text("owner_id").notNull(), // user who created it
|
|
12
12
|
billingEmail: text("billing_email"),
|
|
13
13
|
createdAt: bigint("created_at", { mode: "number" })
|
|
@@ -18,6 +18,6 @@ export const tenants = pgTable(
|
|
|
18
18
|
index("idx_tenants_slug").on(table.slug),
|
|
19
19
|
index("idx_tenants_owner").on(table.ownerId),
|
|
20
20
|
index("idx_tenants_type").on(table.type),
|
|
21
|
-
check("chk_tenants_type", sql`${table.type} IN ('personal', 'org')`),
|
|
21
|
+
check("chk_tenants_type", sql`${table.type} IN ('personal', 'org', 'platform_service')`),
|
|
22
22
|
],
|
|
23
23
|
);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotent seed: creates the holyship-platform internal billing tenant
|
|
3
|
+
* with a stable service key for platform-to-gateway LLM billing.
|
|
4
|
+
*
|
|
5
|
+
* Safe to call on every boot — skips if the tenant already exists.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import { eq } from "drizzle-orm";
|
|
9
|
+
import type { DrizzleDb } from "./index.js";
|
|
10
|
+
import { gatewayServiceKeys } from "./schema/gateway-service-keys.js";
|
|
11
|
+
import { tenants } from "./schema/tenants.js";
|
|
12
|
+
|
|
13
|
+
const PLATFORM_TENANT_ID = "holyship-platform";
|
|
14
|
+
const PLATFORM_TENANT_SLUG = "holyship-platform";
|
|
15
|
+
const PLATFORM_INSTANCE_ID = "holyship-platform-internal";
|
|
16
|
+
|
|
17
|
+
export interface PlatformServiceSeedResult {
|
|
18
|
+
tenantId: string;
|
|
19
|
+
serviceKey: string | null; // null if tenant + key already existed
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function seedPlatformServiceTenant(db: DrizzleDb): Promise<PlatformServiceSeedResult> {
|
|
23
|
+
// Check if tenant already exists
|
|
24
|
+
const existing = await db.select({ id: tenants.id }).from(tenants).where(eq(tenants.id, PLATFORM_TENANT_ID)).limit(1);
|
|
25
|
+
|
|
26
|
+
if (existing.length > 0) {
|
|
27
|
+
return { tenantId: PLATFORM_TENANT_ID, serviceKey: null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Create the platform_service tenant
|
|
31
|
+
await db.insert(tenants).values({
|
|
32
|
+
id: PLATFORM_TENANT_ID,
|
|
33
|
+
name: "Holy Ship Platform",
|
|
34
|
+
slug: PLATFORM_TENANT_SLUG,
|
|
35
|
+
type: "platform_service",
|
|
36
|
+
ownerId: "system",
|
|
37
|
+
billingEmail: null,
|
|
38
|
+
createdAt: Date.now(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Generate a service key and store its hash
|
|
42
|
+
const rawKey = `sk-hs-${randomBytes(24).toString("hex")}`;
|
|
43
|
+
const keyHash = createHash("sha256").update(rawKey).digest("hex");
|
|
44
|
+
|
|
45
|
+
await db.insert(gatewayServiceKeys).values({
|
|
46
|
+
id: crypto.randomUUID(),
|
|
47
|
+
keyHash,
|
|
48
|
+
tenantId: PLATFORM_TENANT_ID,
|
|
49
|
+
instanceId: PLATFORM_INSTANCE_ID,
|
|
50
|
+
createdAt: Date.now(),
|
|
51
|
+
revokedAt: null,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return { tenantId: PLATFORM_TENANT_ID, serviceKey: rawKey };
|
|
55
|
+
}
|