@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.
@@ -62,6 +62,7 @@ function createMocks() {
62
62
  existsByReferenceIdLike: vi.fn(),
63
63
  sumPurchasesForPeriod: vi.fn(),
64
64
  getActiveTenantIdsInWindow: vi.fn(),
65
+ debitCapped: vi.fn(),
65
66
  };
66
67
  const autoTopupEventLog = {
67
68
  writeEvent: vi.fn(),
@@ -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
- const balance = await cfg.ledger.balance(grant.tenantId);
20
- if (balance.isZero()) {
21
- result.skippedZeroBalance++;
22
- continue;
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
- if (err instanceof InsufficientBalanceError) {
37
- result.skippedZeroBalance++;
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) {
@@ -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>;
@@ -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,6 @@
1
+ import type { DrizzleDb } from "./index.js";
2
+ export interface PlatformServiceSeedResult {
3
+ tenantId: string;
4
+ serviceKey: string | null;
5
+ }
6
+ export declare function seedPlatformServiceTenant(db: DrizzleDb): Promise<PlatformServiceSeedResult>;
@@ -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
+ }
@@ -62,6 +62,7 @@ function createMocks() {
62
62
  existsByReferenceIdLike: vi.fn(),
63
63
  sumPurchasesForPeriod: vi.fn(),
64
64
  getActiveTenantIdsInWindow: vi.fn(),
65
+ debitCapped: vi.fn(),
65
66
  };
66
67
  const replayGuard = {
67
68
  isDuplicate: vi.fn(),
@@ -0,0 +1,3 @@
1
+ ALTER TABLE "tenants" DROP CONSTRAINT "chk_tenants_type";
2
+ --> statement-breakpoint
3
+ ALTER TABLE "tenants" ADD CONSTRAINT "chk_tenants_type" CHECK ("tenants"."type" IN ('personal', 'org', 'platform_service'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.39.6",
3
+ "version": "1.40.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -76,6 +76,7 @@ function createMocks() {
76
76
  existsByReferenceIdLike: vi.fn(),
77
77
  sumPurchasesForPeriod: vi.fn(),
78
78
  getActiveTenantIdsInWindow: vi.fn(),
79
+ debitCapped: vi.fn(),
79
80
  };
80
81
 
81
82
  const autoTopupEventLog: IAutoTopupEventLogRepository = {
@@ -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
- const balance = await cfg.ledger.balance(grant.tenantId);
37
- if (balance.isZero()) {
38
- result.skippedZeroBalance++;
39
- continue;
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
- if (err instanceof InsufficientBalanceError) {
56
- result.skippedZeroBalance++;
57
- } else {
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
  });
@@ -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 { rows: Array<{ normal_side: Side }> };
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> {
@@ -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
+ }
@@ -79,6 +79,7 @@ function createMocks() {
79
79
  existsByReferenceIdLike: vi.fn(),
80
80
  sumPurchasesForPeriod: vi.fn(),
81
81
  getActiveTenantIdsInWindow: vi.fn(),
82
+ debitCapped: vi.fn(),
82
83
  };
83
84
 
84
85
  const replayGuard: IWebhookSeenRepository = {