@hachej/boring-core 0.1.42 → 0.1.44

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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ERROR_CODES,
3
3
  HttpError
4
- } from "./chunk-H5KU6R6Y.js";
4
+ } from "./chunk-LIBHVT7V.js";
5
5
  import {
6
6
  __export
7
7
  } from "./chunk-MLKGABMK.js";
@@ -13,13 +13,13 @@ function createDatabase(config) {
13
13
  if (!config.databaseUrl) {
14
14
  throw new Error("databaseUrl is required to create a database connection");
15
15
  }
16
- const sql6 = postgres(config.databaseUrl, {
16
+ const sql7 = postgres(config.databaseUrl, {
17
17
  max: 10,
18
18
  idle_timeout: 20,
19
19
  connect_timeout: 10
20
20
  });
21
- const db = drizzle(sql6);
22
- return { db, sql: sql6 };
21
+ const db = drizzle(sql7);
22
+ return { db, sql: sql7 };
23
23
  }
24
24
 
25
25
  // src/server/db/migrate.ts
@@ -539,10 +539,14 @@ var schema_exports = {};
539
539
  __export(schema_exports, {
540
540
  accounts: () => accounts,
541
541
  accountsRelations: () => accountsRelations,
542
+ creditGrants: () => creditGrants,
543
+ creditPurchases: () => creditPurchases,
542
544
  idempotencyKeys: () => idempotencyKeys,
543
545
  sessions: () => sessions,
544
546
  sessionsRelations: () => sessionsRelations,
545
547
  telemetryEvents: () => telemetryEvents,
548
+ usageLedger: () => usageLedger,
549
+ usageReservations: () => usageReservations,
546
550
  userSettings: () => userSettings,
547
551
  userSettingsRelations: () => userSettingsRelations,
548
552
  users: () => users,
@@ -644,7 +648,7 @@ var accountsRelations = relations(accounts, ({ one }) => ({
644
648
  }));
645
649
 
646
650
  // src/server/db/schema.ts
647
- import { pgTable as pgTable2, text as text2, uuid as uuid2, jsonb, timestamp as timestamp2, primaryKey, index as index2, integer, boolean as boolean2, uniqueIndex, check, customType } from "drizzle-orm/pg-core";
651
+ import { pgTable as pgTable2, text as text2, uuid as uuid2, jsonb, timestamp as timestamp2, primaryKey, index as index2, integer, bigint, boolean as boolean2, uniqueIndex, check, customType } from "drizzle-orm/pg-core";
648
652
  import { relations as relations2, sql as sql3 } from "drizzle-orm";
649
653
  var bytea = customType({
650
654
  dataType() {
@@ -867,6 +871,120 @@ var idempotencyKeys = pgTable2(
867
871
  index2("idempotency_keys_created_at_idx").on(table.createdAt)
868
872
  ]
869
873
  );
874
+ var creditGrants = pgTable2(
875
+ "boring_credit_grants",
876
+ {
877
+ id: uuid2("id").default(sql3`gen_random_uuid()`).primaryKey(),
878
+ userId: text2("user_id").notNull(),
879
+ amountMicros: bigint("amount_micros", { mode: "number" }).notNull(),
880
+ reason: text2("reason").notNull(),
881
+ expiresAt: timestamp2("expires_at"),
882
+ createdAt: timestamp2("created_at").defaultNow().notNull()
883
+ },
884
+ (table) => [
885
+ uniqueIndex("boring_credit_grants_user_reason_idx").on(table.userId, table.reason),
886
+ check("boring_credit_grants_amount_check", sql3`${table.amountMicros} > 0`)
887
+ ]
888
+ );
889
+ var creditPurchases = pgTable2(
890
+ "boring_credit_purchases",
891
+ {
892
+ orderId: text2("order_id").primaryKey(),
893
+ userId: text2("user_id"),
894
+ amountMicros: bigint("amount_micros", { mode: "number" }),
895
+ status: text2("status").notNull().default("granted"),
896
+ source: text2("source").notNull().default("lemonsqueezy"),
897
+ createdAt: timestamp2("created_at").defaultNow().notNull(),
898
+ refundedAt: timestamp2("refunded_at"),
899
+ /** Cumulative credit micros already revoked for this order (supports
900
+ * repeated partial refunds without double-debiting). */
901
+ refundedMicros: bigint("refunded_micros", { mode: "number" }),
902
+ /** Pending refund fraction in parts-per-million (fraction × 1e6), set when a
903
+ * partial refund arrives before the grant; applied at grant time. */
904
+ pendingRefundPpm: bigint("pending_refund_ppm", { mode: "number" }),
905
+ /** Provider identity captured at grant time, for audit/reconcile and to match
906
+ * a later refund against the order we actually credited. */
907
+ storeId: text2("store_id"),
908
+ testMode: boolean2("test_mode"),
909
+ currency: text2("currency"),
910
+ variantId: text2("variant_id")
911
+ },
912
+ (table) => [
913
+ index2("boring_credit_purchases_user_idx").on(table.userId),
914
+ check("boring_credit_purchases_amount_check", sql3`${table.amountMicros} IS NULL OR ${table.amountMicros} > 0`),
915
+ check("boring_credit_purchases_status_check", sql3`${table.status} IN ('granted', 'refunded', 'refund_pending')`),
916
+ // A granted row must carry the credited user + amount; a pre-grant tombstone
917
+ // ('refunded' full, or 'refund_pending' partial) may omit them.
918
+ check(
919
+ "boring_credit_purchases_granted_check",
920
+ sql3`${table.status} IN ('refunded', 'refund_pending') OR (${table.userId} IS NOT NULL AND ${table.amountMicros} IS NOT NULL)`
921
+ )
922
+ ]
923
+ );
924
+ var usageReservations = pgTable2(
925
+ "boring_usage_reservations",
926
+ {
927
+ id: uuid2("id").default(sql3`gen_random_uuid()`).primaryKey(),
928
+ userId: text2("user_id").notNull(),
929
+ workspaceId: text2("workspace_id"),
930
+ sessionId: text2("session_id"),
931
+ runId: text2("run_id").notNull(),
932
+ source: text2("source").notNull().default(""),
933
+ amountMicros: bigint("amount_micros", { mode: "number" }).notNull(),
934
+ status: text2("status").notNull().default("active"),
935
+ // Durable terminal-charge intent: set true when the coordinator decided this
936
+ // run must be charged the fallback hold (started/successful run with no billable
937
+ // usage, or a failed usage write) BEFORE attempting the charge. If that charge
938
+ // write then fails transiently, the stale-expiry sweep still charges the hold
939
+ // (a marked reservation with zero billed rows is NOT freed) — so a started run
940
+ // can't go free on a brief finalization-time DB outage.
941
+ chargeOnExpire: boolean2("charge_on_expire").notNull().default(false),
942
+ createdAt: timestamp2("created_at").defaultNow().notNull(),
943
+ expiresAt: timestamp2("expires_at").notNull()
944
+ },
945
+ (table) => [
946
+ uniqueIndex("boring_usage_reservations_active_run_idx").on(table.runId).where(sql3`${table.status} = 'active'`),
947
+ index2("boring_usage_reservations_user_status_idx").on(table.userId, table.status, table.expiresAt),
948
+ check("boring_usage_reservations_amount_check", sql3`${table.amountMicros} > 0`),
949
+ check(
950
+ "boring_usage_reservations_status_check",
951
+ sql3`${table.status} IN ('active', 'settled', 'released', 'expired')`
952
+ )
953
+ ]
954
+ );
955
+ var usageLedger = pgTable2(
956
+ "boring_usage_ledger",
957
+ {
958
+ /** Caller-provided stable usage id; the idempotency key for inserts. */
959
+ id: text2("id").primaryKey(),
960
+ userId: text2("user_id").notNull(),
961
+ workspaceId: text2("workspace_id"),
962
+ sessionId: text2("session_id"),
963
+ runId: text2("run_id"),
964
+ messageId: text2("message_id"),
965
+ source: text2("source").notNull().default(""),
966
+ provider: text2("provider"),
967
+ model: text2("model"),
968
+ inputTokens: bigint("input_tokens", { mode: "number" }).notNull().default(0),
969
+ outputTokens: bigint("output_tokens", { mode: "number" }).notNull().default(0),
970
+ cacheReadTokens: bigint("cache_read_tokens", { mode: "number" }).notNull().default(0),
971
+ cacheWriteTokens: bigint("cache_write_tokens", { mode: "number" }).notNull().default(0),
972
+ providerCostMicros: bigint("provider_cost_micros", { mode: "number" }).notNull().default(0),
973
+ billedCostMicros: bigint("billed_cost_micros", { mode: "number" }).notNull(),
974
+ stopReason: text2("stop_reason"),
975
+ metadata: jsonb("metadata").notNull().default({}),
976
+ createdAt: timestamp2("created_at").defaultNow().notNull()
977
+ },
978
+ (table) => [
979
+ index2("boring_usage_ledger_user_created_idx").on(table.userId, table.createdAt),
980
+ index2("boring_usage_ledger_run_idx").on(table.runId),
981
+ check("boring_usage_ledger_billed_check", sql3`${table.billedCostMicros} >= 0`),
982
+ check(
983
+ "boring_usage_ledger_tokens_check",
984
+ sql3`${table.inputTokens} >= 0 AND ${table.outputTokens} >= 0 AND ${table.cacheReadTokens} >= 0 AND ${table.cacheWriteTokens} >= 0 AND ${table.providerCostMicros} >= 0`
985
+ )
986
+ ]
987
+ );
870
988
  var telemetryEvents = pgTable2(
871
989
  "telemetry_events",
872
990
  {
@@ -1681,6 +1799,525 @@ var PostgresUserStore = class {
1681
1799
  }
1682
1800
  };
1683
1801
 
1802
+ // src/server/db/stores/PostgresMeteringStore.ts
1803
+ import { and as and2, desc as desc2, eq as eq3, gt, inArray, isNull as isNull2, lt, lte, or, sql as sql6 } from "drizzle-orm";
1804
+ var InsufficientCreditError = class extends Error {
1805
+ constructor(availableMicros, requiredMicros) {
1806
+ super("insufficient credit");
1807
+ this.availableMicros = availableMicros;
1808
+ this.requiredMicros = requiredMicros;
1809
+ this.name = "InsufficientCreditError";
1810
+ }
1811
+ availableMicros;
1812
+ requiredMicros;
1813
+ statusCode = 402;
1814
+ // Matches @hachej/boring-agent's canonical ErrorCode so the agent route
1815
+ // preserves it (instead of degrading to INTERNAL_ERROR) when a sink throws
1816
+ // this across the boundary.
1817
+ code = "PAYMENT_REQUIRED";
1818
+ };
1819
+ var PostgresMeteringStore = class {
1820
+ constructor(db) {
1821
+ this.db = db;
1822
+ }
1823
+ db;
1824
+ /** Idempotently create a grant keyed by (userId, reason). */
1825
+ async grantOnce(input) {
1826
+ if (!Number.isSafeInteger(input.amountMicros) || input.amountMicros <= 0) {
1827
+ throw new Error("grant amountMicros must be a positive integer");
1828
+ }
1829
+ const rows = await this.db.insert(creditGrants).values({
1830
+ userId: input.userId,
1831
+ reason: input.reason,
1832
+ amountMicros: input.amountMicros,
1833
+ expiresAt: input.expiresAt ?? null
1834
+ }).onConflictDoNothing({ target: [creditGrants.userId, creditGrants.reason] }).returning({ id: creditGrants.id });
1835
+ return { created: rows.length > 0 };
1836
+ }
1837
+ /**
1838
+ * Credit a purchase exactly once GLOBALLY per order id. The order id is the
1839
+ * primary key, so a webhook retry or a delivery misrouted to a different user
1840
+ * can never double-credit. A per-order advisory lock serializes this against
1841
+ * revokePurchase so a refund that arrives BEFORE order_created (out-of-order
1842
+ * delivery) leaves a 'refunded' tombstone that blocks this grant — the user
1843
+ * never keeps credits for a refunded order. Returns `granted: false` when the
1844
+ * order was already processed or has been refunded.
1845
+ */
1846
+ async grantPurchaseOnce(input) {
1847
+ if (!input.orderId) throw new Error("grantPurchaseOnce requires an orderId");
1848
+ if (!Number.isSafeInteger(input.amountMicros) || input.amountMicros <= 0) {
1849
+ throw new Error("purchase amountMicros must be a positive integer");
1850
+ }
1851
+ const source = input.source ?? "lemonsqueezy";
1852
+ const identity = { storeId: input.storeId ?? null, testMode: input.testMode ?? null, currency: input.currency ?? null, variantId: input.variantId ?? null };
1853
+ return this.db.transaction(async (tx) => {
1854
+ await tx.execute(sql6`SELECT pg_advisory_xact_lock(hashtext(${`purchase:${input.orderId}`}))`);
1855
+ const existing = await tx.select({
1856
+ status: creditPurchases.status,
1857
+ userId: creditPurchases.userId,
1858
+ amountMicros: creditPurchases.amountMicros,
1859
+ pendingRefundPpm: creditPurchases.pendingRefundPpm,
1860
+ storeId: creditPurchases.storeId,
1861
+ testMode: creditPurchases.testMode,
1862
+ currency: creditPurchases.currency,
1863
+ variantId: creditPurchases.variantId
1864
+ }).from(creditPurchases).where(eq3(creditPurchases.orderId, input.orderId)).limit(1);
1865
+ const prior = existing[0];
1866
+ if (prior && prior.status !== "refund_pending") {
1867
+ if (prior.status === "granted" && (prior.userId !== input.userId || prior.amountMicros !== input.amountMicros || prior.storeId !== (input.storeId ?? null) || prior.testMode !== (input.testMode ?? null) || prior.currency !== (input.currency ?? null) || prior.variantId !== (input.variantId ?? null))) {
1868
+ throw new Error(
1869
+ `purchase ${input.orderId} already granted (user ${prior.userId}, ${prior.amountMicros} micros, store ${prior.storeId}, testMode ${prior.testMode}, variant ${prior.variantId}); refusing conflicting re-grant (user ${input.userId}, ${input.amountMicros} micros, store ${input.storeId ?? null}, testMode ${input.testMode ?? null}, variant ${input.variantId ?? null})`
1870
+ );
1871
+ }
1872
+ return { granted: false };
1873
+ }
1874
+ const insertGrant = async () => {
1875
+ const grantRows = await tx.insert(creditGrants).values({ userId: input.userId, reason: `purchase:${input.orderId}`, amountMicros: input.amountMicros }).onConflictDoNothing({ target: [creditGrants.userId, creditGrants.reason] }).returning({ id: creditGrants.id });
1876
+ if (grantRows.length === 0) {
1877
+ throw new Error(`purchase ${input.orderId} claimed but a credit grant already existed`);
1878
+ }
1879
+ };
1880
+ if (prior?.status === "refund_pending") {
1881
+ const pendingPpm = prior.pendingRefundPpm ?? 0;
1882
+ const revoke = Math.min(input.amountMicros, Math.round(input.amountMicros * pendingPpm / 1e6));
1883
+ await tx.update(creditPurchases).set({ userId: input.userId, amountMicros: input.amountMicros, status: revoke >= input.amountMicros ? "refunded" : "granted", pendingRefundPpm: null, refundedMicros: revoke > 0 ? revoke : null, refundedAt: revoke > 0 ? /* @__PURE__ */ new Date() : null, ...identity }).where(eq3(creditPurchases.orderId, input.orderId));
1884
+ await insertGrant();
1885
+ if (revoke > 0) {
1886
+ await this.insertVerifiedLedgerDebit(tx, {
1887
+ id: `refund:${input.orderId}:${revoke}`,
1888
+ userId: input.userId,
1889
+ amountMicros: revoke,
1890
+ metadata: { kind: "purchase_refund", orderId: input.orderId, refundedToMicros: revoke, appliedAtGrant: true }
1891
+ });
1892
+ }
1893
+ return { granted: true };
1894
+ }
1895
+ await tx.insert(creditPurchases).values({
1896
+ orderId: input.orderId,
1897
+ userId: input.userId,
1898
+ amountMicros: input.amountMicros,
1899
+ status: "granted",
1900
+ source,
1901
+ ...identity
1902
+ });
1903
+ await insertGrant();
1904
+ return { granted: true };
1905
+ });
1906
+ }
1907
+ /**
1908
+ * Revoke a refunded/disputed purchase. Under the same per-order advisory lock
1909
+ * as grantPurchaseOnce, supports repeated PARTIAL refunds:
1910
+ * - `refundFraction` is the cumulative fraction of the order (by money) that
1911
+ * has been refunded — i.e. LS `refunded_amount / total` (both tax-inclusive),
1912
+ * which maps the refund onto the same basis as the credited amount. The
1913
+ * revoked credits = round(creditedMicros × fraction), capped at credited.
1914
+ * The method debits only the delta since the last refund. Omit (undefined)
1915
+ * for a full refund of the entire credited amount.
1916
+ * - granted order → debit the delta, track cumulative `refunded_micros`, and
1917
+ * mark 'refunded' once fully revoked; returns revoked=true when a debit was
1918
+ * posted.
1919
+ * - not yet seen → write a 'refunded' tombstone so a later order_created
1920
+ * cannot grant; returns revoked=false (nothing was credited yet).
1921
+ * - already fully refunded / no new delta → no-op; returns revoked=false.
1922
+ */
1923
+ async revokePurchase(orderId, opts = {}) {
1924
+ if (!orderId) throw new Error("revokePurchase requires an orderId");
1925
+ const source = opts.source ?? "lemonsqueezy-refund";
1926
+ const allowTombstone = opts.allowTombstone === true;
1927
+ if (opts.refundFraction !== void 0 && (!Number.isFinite(opts.refundFraction) || opts.refundFraction < 0)) {
1928
+ throw new Error("revokePurchase refundFraction must be a non-negative number");
1929
+ }
1930
+ return this.db.transaction(async (tx) => {
1931
+ await tx.execute(sql6`SELECT pg_advisory_xact_lock(hashtext(${`purchase:${orderId}`}))`);
1932
+ const existing = await tx.select({
1933
+ userId: creditPurchases.userId,
1934
+ amountMicros: creditPurchases.amountMicros,
1935
+ status: creditPurchases.status,
1936
+ refundedMicros: creditPurchases.refundedMicros,
1937
+ pendingRefundPpm: creditPurchases.pendingRefundPpm,
1938
+ storeId: creditPurchases.storeId,
1939
+ testMode: creditPurchases.testMode,
1940
+ currency: creditPurchases.currency
1941
+ }).from(creditPurchases).where(eq3(creditPurchases.orderId, orderId)).limit(1);
1942
+ const fraction = opts.refundFraction ?? 1;
1943
+ const row = existing[0];
1944
+ if (!row) {
1945
+ if (!allowTombstone) return { revoked: false };
1946
+ const tombstoneIdentity = { storeId: opts.expectedStoreId ?? null, testMode: opts.expectedTestMode ?? null };
1947
+ await tx.insert(creditPurchases).values(
1948
+ fraction >= 1 ? { orderId, status: "refunded", source, refundedAt: /* @__PURE__ */ new Date(), ...tombstoneIdentity } : { orderId, status: "refund_pending", source, refundedAt: /* @__PURE__ */ new Date(), pendingRefundPpm: Math.round(fraction * 1e6), ...tombstoneIdentity }
1949
+ );
1950
+ return { revoked: false };
1951
+ }
1952
+ if (row.status === "refund_pending") {
1953
+ if (fraction >= 1) {
1954
+ await tx.update(creditPurchases).set({ status: "refunded", pendingRefundPpm: null, refundedAt: /* @__PURE__ */ new Date() }).where(eq3(creditPurchases.orderId, orderId));
1955
+ } else {
1956
+ const newPpm = Math.max(row.pendingRefundPpm ?? 0, Math.round(fraction * 1e6));
1957
+ await tx.update(creditPurchases).set({ pendingRefundPpm: newPpm, refundedAt: /* @__PURE__ */ new Date() }).where(eq3(creditPurchases.orderId, orderId));
1958
+ }
1959
+ return { revoked: false };
1960
+ }
1961
+ if (row.storeId != null && opts.expectedStoreId != null && row.storeId !== opts.expectedStoreId || row.testMode != null && opts.expectedTestMode != null && row.testMode !== opts.expectedTestMode || row.currency != null && opts.expectedCurrency != null && row.currency.toUpperCase() !== opts.expectedCurrency.toUpperCase()) {
1962
+ throw new Error(`refund identity mismatch for ${orderId}: credited row (store=${row.storeId}, testMode=${row.testMode}, currency=${row.currency}) does not match expected (store=${opts.expectedStoreId}, testMode=${opts.expectedTestMode}, currency=${opts.expectedCurrency})`);
1963
+ }
1964
+ if (row.userId) {
1965
+ await tx.execute(sql6`SELECT pg_advisory_xact_lock(hashtext(${row.userId}))`);
1966
+ }
1967
+ const credited = row.amountMicros ?? 0;
1968
+ const alreadyRefunded = row.refundedMicros ?? 0;
1969
+ const target = Math.min(credited, Math.round(credited * Math.min(1, fraction)));
1970
+ const delta = target - alreadyRefunded;
1971
+ const fullyRefunded = target >= credited;
1972
+ if (delta <= 0) {
1973
+ if (fullyRefunded && row.status !== "refunded") {
1974
+ await tx.update(creditPurchases).set({ status: "refunded", refundedAt: /* @__PURE__ */ new Date() }).where(eq3(creditPurchases.orderId, orderId));
1975
+ }
1976
+ return { revoked: false };
1977
+ }
1978
+ const inserted = await this.insertVerifiedLedgerDebit(tx, {
1979
+ id: `refund:${orderId}:${target}`,
1980
+ userId: row.userId,
1981
+ amountMicros: delta,
1982
+ source,
1983
+ metadata: { kind: "purchase_refund", orderId, refundedToMicros: target }
1984
+ });
1985
+ await tx.update(creditPurchases).set({ refundedMicros: target, status: fullyRefunded ? "refunded" : row.status, refundedAt: /* @__PURE__ */ new Date() }).where(eq3(creditPurchases.orderId, orderId));
1986
+ return { revoked: inserted };
1987
+ });
1988
+ }
1989
+ /**
1990
+ * Insert a refund debit idempotently by id. If the id already exists (manual
1991
+ * repair / corrupted retry), VERIFY the existing row is the same debit (user +
1992
+ * amount) — throw on mismatch so a caller never records a refund the balance was
1993
+ * not actually debited for. Returns true iff a new debit row was written.
1994
+ */
1995
+ async insertVerifiedLedgerDebit(tx, input) {
1996
+ const rows = await tx.insert(usageLedger).values({
1997
+ id: input.id,
1998
+ userId: input.userId,
1999
+ runId: input.runId ?? null,
2000
+ source: input.source ?? "lemonsqueezy-refund",
2001
+ billedCostMicros: input.amountMicros,
2002
+ providerCostMicros: 0,
2003
+ metadata: input.metadata
2004
+ }).onConflictDoNothing({ target: usageLedger.id }).returning({ id: usageLedger.id });
2005
+ if (rows.length > 0) return true;
2006
+ const existing = await tx.select({ userId: usageLedger.userId, billedCostMicros: usageLedger.billedCostMicros }).from(usageLedger).where(eq3(usageLedger.id, input.id)).limit(1);
2007
+ const e = existing[0];
2008
+ if (!e || e.userId !== input.userId || e.billedCostMicros !== input.amountMicros) {
2009
+ throw new Error(`ledger debit conflict for ${input.id}: existing debit does not match (refusing to record a debit that wasn't actually applied)`);
2010
+ }
2011
+ return false;
2012
+ }
2013
+ /** Total billed micros already recorded for a run (for fallback top-up so a
2014
+ * partial-success run isn't charged the hold ON TOP of its real usage). */
2015
+ async billedMicrosForRun(userId, runId) {
2016
+ const rows = await this.db.select({ total: sql6`coalesce(sum(${usageLedger.billedCostMicros}), 0)` }).from(usageLedger).where(and2(eq3(usageLedger.userId, userId), eq3(usageLedger.runId, runId)));
2017
+ return Number(rows[0]?.total ?? 0);
2018
+ }
2019
+ /** Total billed micros for a specific RESERVATION (run attempt). Preferred over
2020
+ * billedMicrosForRun for fallback top-up: runId is reused on client-nonce
2021
+ * replay, so summing by runId would count a prior attempt's billing and let a
2022
+ * later reusing attempt settle free. */
2023
+ async billedMicrosForReservation(userId, reservationId) {
2024
+ const rows = await this.db.select({ total: sql6`coalesce(sum(${usageLedger.billedCostMicros}), 0)` }).from(usageLedger).where(and2(eq3(usageLedger.userId, userId), sql6`${usageLedger.metadata}->>'reservationId' = ${reservationId}`));
2025
+ return Number(rows[0]?.total ?? 0);
2026
+ }
2027
+ /** Durably record that an ACTIVE reservation must be charged the fallback hold if
2028
+ * it expires, BEFORE attempting the actual fallback charge. Committed on its own so
2029
+ * the intent survives a subsequent failed charge write — the expiry sweep then
2030
+ * charges a marked reservation even with zero billed rows (no free started run on a
2031
+ * brief finalization-time DB outage). Idempotent; a no-op on a non-active row. */
2032
+ async markReservationFallbackCharge(userId, reservationId) {
2033
+ await this.db.update(usageReservations).set({ chargeOnExpire: true }).where(and2(eq3(usageReservations.id, reservationId), eq3(usageReservations.userId, userId), eq3(usageReservations.status, "active")));
2034
+ }
2035
+ async getBalance(userId, now = /* @__PURE__ */ new Date()) {
2036
+ return this.computeBalance(this.db, userId, now);
2037
+ }
2038
+ /** Most-recent credit ledger for the account activity view: grants/purchases
2039
+ * (positive) merged with usage/refund/fallback debits (negative), newest first,
2040
+ * scoped to the user and capped at `limit` (clamped 1..50). Descriptions are
2041
+ * generic/sanitized; zero-amount usage rows (zero-token) are omitted as noise. */
2042
+ async listLedger(userId, limit) {
2043
+ const cap = Math.min(50, Math.max(1, Number.isFinite(limit) ? Math.trunc(limit) : 1));
2044
+ const [grants, usage] = await Promise.all([
2045
+ this.db.select({ id: creditGrants.id, amountMicros: creditGrants.amountMicros, reason: creditGrants.reason, createdAt: creditGrants.createdAt }).from(creditGrants).where(eq3(creditGrants.userId, userId)).orderBy(desc2(creditGrants.createdAt)).limit(cap),
2046
+ this.db.select({ id: usageLedger.id, billedCostMicros: usageLedger.billedCostMicros, source: usageLedger.source, createdAt: usageLedger.createdAt }).from(usageLedger).where(and2(eq3(usageLedger.userId, userId), gt(usageLedger.billedCostMicros, 0))).orderBy(desc2(usageLedger.createdAt)).limit(cap)
2047
+ ]);
2048
+ const entries = [
2049
+ ...grants.map((g) => ({
2050
+ id: opaqueLedgerId("g", g.id),
2051
+ ...describeGrant(g.reason),
2052
+ amountMicros: g.amountMicros,
2053
+ createdAt: g.createdAt.toISOString()
2054
+ })),
2055
+ ...usage.map((u) => ({
2056
+ id: opaqueLedgerId("u", u.id),
2057
+ ...describeUsage(u.source),
2058
+ amountMicros: -u.billedCostMicros,
2059
+ createdAt: u.createdAt.toISOString()
2060
+ }))
2061
+ ];
2062
+ return entries.sort((a, b) => a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0).slice(0, cap);
2063
+ }
2064
+ /**
2065
+ * Reserve credit for a run. Serialized per user (advisory transaction
2066
+ * lock) so concurrent reservations cannot jointly overdraw. Idempotent per
2067
+ * runId: re-reserving while a reservation is still active returns the
2068
+ * existing reservation instead of double-holding. Throws
2069
+ * InsufficientCreditError when the available balance is below
2070
+ * minAvailableMicros (default: the reservation amount itself).
2071
+ */
2072
+ async reserve(input, now = /* @__PURE__ */ new Date()) {
2073
+ if (!Number.isSafeInteger(input.amountMicros) || input.amountMicros <= 0) {
2074
+ throw new Error("reserve amountMicros must be a positive integer");
2075
+ }
2076
+ if (!Number.isFinite(input.ttlSeconds) || input.ttlSeconds <= 0) {
2077
+ throw new Error("reserve ttlSeconds must be positive");
2078
+ }
2079
+ if (input.minAvailableMicros !== void 0 && (!Number.isSafeInteger(input.minAvailableMicros) || input.minAvailableMicros < 0)) {
2080
+ throw new Error("reserve minAvailableMicros must be a non-negative integer");
2081
+ }
2082
+ const minAvailable = input.minAvailableMicros ?? input.amountMicros;
2083
+ const expiresAt = new Date(now.getTime() + input.ttlSeconds * 1e3);
2084
+ return this.db.transaction(async (tx) => {
2085
+ await tx.execute(sql6`SELECT pg_advisory_xact_lock(hashtext(${input.userId}))`);
2086
+ await this.expireUserStaleReservations(tx, input.userId, now);
2087
+ const existing = await tx.select({ id: usageReservations.id }).from(usageReservations).where(and2(
2088
+ eq3(usageReservations.runId, input.runId),
2089
+ eq3(usageReservations.userId, input.userId),
2090
+ eq3(usageReservations.status, "active")
2091
+ )).limit(1);
2092
+ const existingId = existing[0]?.id;
2093
+ if (existingId) return { reservationId: existingId };
2094
+ const balance = await this.computeBalance(tx, input.userId, now);
2095
+ if (balance.availableMicros < minAvailable) {
2096
+ throw new InsufficientCreditError(balance.availableMicros, minAvailable);
2097
+ }
2098
+ const rows = await tx.insert(usageReservations).values({
2099
+ userId: input.userId,
2100
+ workspaceId: input.workspaceId ?? null,
2101
+ sessionId: input.sessionId ?? null,
2102
+ runId: input.runId,
2103
+ source: input.source ?? "",
2104
+ amountMicros: input.amountMicros,
2105
+ status: "active",
2106
+ expiresAt
2107
+ }).returning({ id: usageReservations.id });
2108
+ const reservationId = rows[0]?.id;
2109
+ if (!reservationId) throw new Error("reservation insert returned no id");
2110
+ return { reservationId };
2111
+ });
2112
+ }
2113
+ /** Idempotent ledger insert; returns whether a new row was written. Serialized
2114
+ * under the user's advisory lock so a positive debit is ordered with that user's
2115
+ * reserve()/expiry: a debit that pushes the balance below the floor is visible to a
2116
+ * concurrent reserve (which then waits and is refused), keeping the documented
2117
+ * "overshoot → next run refused" boundary even for over-budget/misrouted runs. */
2118
+ async recordUsage(input) {
2119
+ if (!Number.isSafeInteger(input.billedCostMicros) || input.billedCostMicros < 0) {
2120
+ throw new Error("billedCostMicros must be a non-negative integer");
2121
+ }
2122
+ return this.db.transaction(async (tx) => {
2123
+ await tx.execute(sql6`SELECT pg_advisory_xact_lock(hashtext(${input.userId}))`);
2124
+ const rows = await tx.insert(usageLedger).values({
2125
+ id: input.usageId,
2126
+ userId: input.userId,
2127
+ workspaceId: input.workspaceId ?? null,
2128
+ sessionId: input.sessionId ?? null,
2129
+ runId: input.runId ?? null,
2130
+ messageId: input.messageId ?? null,
2131
+ source: input.source ?? "",
2132
+ provider: input.provider ?? null,
2133
+ model: input.model ?? null,
2134
+ inputTokens: input.inputTokens ?? 0,
2135
+ outputTokens: input.outputTokens ?? 0,
2136
+ cacheReadTokens: input.cacheReadTokens ?? 0,
2137
+ cacheWriteTokens: input.cacheWriteTokens ?? 0,
2138
+ providerCostMicros: input.providerCostMicros ?? 0,
2139
+ billedCostMicros: input.billedCostMicros,
2140
+ stopReason: input.stopReason ?? null,
2141
+ metadata: input.metadata ?? {}
2142
+ }).onConflictDoNothing({ target: usageLedger.id }).returning({ id: usageLedger.id });
2143
+ if (rows.length > 0) return { inserted: true };
2144
+ const existing = await tx.select({
2145
+ userId: usageLedger.userId,
2146
+ runId: usageLedger.runId,
2147
+ messageId: usageLedger.messageId,
2148
+ source: usageLedger.source,
2149
+ provider: usageLedger.provider,
2150
+ model: usageLedger.model,
2151
+ inputTokens: usageLedger.inputTokens,
2152
+ outputTokens: usageLedger.outputTokens,
2153
+ cacheReadTokens: usageLedger.cacheReadTokens,
2154
+ cacheWriteTokens: usageLedger.cacheWriteTokens,
2155
+ providerCostMicros: usageLedger.providerCostMicros,
2156
+ billedCostMicros: usageLedger.billedCostMicros,
2157
+ stopReason: usageLedger.stopReason,
2158
+ reservationId: sql6`${usageLedger.metadata}->>'reservationId'`
2159
+ }).from(usageLedger).where(eq3(usageLedger.id, input.usageId)).limit(1);
2160
+ const e = existing[0];
2161
+ const incomingReservationId = input.metadata?.reservationId ?? null;
2162
+ const matches = e && e.userId === input.userId && e.runId === (input.runId ?? null) && e.messageId === (input.messageId ?? null) && e.source === (input.source ?? "") && e.provider === (input.provider ?? null) && e.model === (input.model ?? null) && e.inputTokens === (input.inputTokens ?? 0) && e.outputTokens === (input.outputTokens ?? 0) && e.cacheReadTokens === (input.cacheReadTokens ?? 0) && e.cacheWriteTokens === (input.cacheWriteTokens ?? 0) && e.providerCostMicros === (input.providerCostMicros ?? 0) && e.billedCostMicros === input.billedCostMicros && e.stopReason === (input.stopReason ?? null) && e.reservationId === incomingReservationId;
2163
+ if (!matches) {
2164
+ throw new Error(`usage ledger id collision for ${input.usageId}: existing row does not match this usage (refusing to silently drop the debit or corrupt the audit trail)`);
2165
+ }
2166
+ return { inserted: false };
2167
+ });
2168
+ }
2169
+ /**
2170
+ * Finish a reservation. Settling also recovers reservations that expired
2171
+ * before a delayed settlement retry, so charged usage never leaves a
2172
+ * reservation dangling. Releasing only touches active rows. Idempotent:
2173
+ * repeat calls are no-ops.
2174
+ *
2175
+ * A runId may have more than one row (an expired row plus a fresh active
2176
+ * retry), so the runId fallback resolves to the single newest matching row
2177
+ * — a settle never flips both the dead and the live reservation together.
2178
+ */
2179
+ async finishReservation(input, status) {
2180
+ if (!input.reservationId && !input.runId) {
2181
+ throw new Error("finishReservation requires reservationId or runId");
2182
+ }
2183
+ if (!input.reservationId && !input.userId) {
2184
+ throw new Error("finishReservation by runId requires userId");
2185
+ }
2186
+ const matchable = status === "settled" ? ["active", "expired"] : ["active"];
2187
+ return this.db.transaction(async (tx) => {
2188
+ let userId = input.userId;
2189
+ if (!userId && input.reservationId) {
2190
+ const owner = await tx.select({ userId: usageReservations.userId }).from(usageReservations).where(eq3(usageReservations.id, input.reservationId)).limit(1);
2191
+ userId = owner[0]?.userId;
2192
+ }
2193
+ if (userId) await tx.execute(sql6`SELECT pg_advisory_xact_lock(hashtext(${userId}))`);
2194
+ let targetId = input.reservationId;
2195
+ if (!targetId) {
2196
+ const lookup = [
2197
+ eq3(usageReservations.runId, input.runId),
2198
+ inArray(usageReservations.status, matchable)
2199
+ ];
2200
+ if (input.userId) lookup.push(eq3(usageReservations.userId, input.userId));
2201
+ const found = await tx.select({ id: usageReservations.id }).from(usageReservations).where(and2(...lookup));
2202
+ if (found.length === 0) return { updated: false };
2203
+ if (found.length > 1) {
2204
+ throw new Error("finishReservation by runId is ambiguous (multiple rows); pass reservationId");
2205
+ }
2206
+ targetId = found[0]?.id;
2207
+ if (!targetId) return { updated: false };
2208
+ }
2209
+ const conditions = [eq3(usageReservations.id, targetId), inArray(usageReservations.status, matchable)];
2210
+ if (input.userId) conditions.push(eq3(usageReservations.userId, input.userId));
2211
+ const rows = await tx.update(usageReservations).set({ status }).where(and2(...conditions)).returning({ id: usageReservations.id });
2212
+ return { updated: rows.length > 0 };
2213
+ });
2214
+ }
2215
+ /** Expire stale active reservations without charging. Returns the count. */
2216
+ async expireStaleReservations(now = /* @__PURE__ */ new Date()) {
2217
+ const users2 = await this.db.selectDistinct({ userId: usageReservations.userId }).from(usageReservations).where(and2(eq3(usageReservations.status, "active"), lt(usageReservations.expiresAt, now)));
2218
+ let total = 0;
2219
+ for (const { userId } of users2) {
2220
+ total += await this.db.transaction(async (tx) => {
2221
+ await tx.execute(sql6`SELECT pg_advisory_xact_lock(hashtext(${userId}))`);
2222
+ return this.expireUserStaleReservations(tx, userId, now);
2223
+ });
2224
+ }
2225
+ return total;
2226
+ }
2227
+ /**
2228
+ * Expire one user's stale active reservations under the SINGLE charge-aware
2229
+ * policy. CALLER MUST already hold pg_advisory_xact_lock(hashtext(userId)).
2230
+ * A reservation that reached TTL without an explicit settle/release had a failed
2231
+ * finalization: if it has POSITIVE billed usage (`billedTotal > 0`) OR carries the
2232
+ * durable `charge_on_expire` marker, the run did chargeable work, so top it up to
2233
+ * the hold (idempotent) rather than free it; a reservation with only zero-billed
2234
+ * rows and no marker is a non-billable/pre-execution abandon and is freed (so a
2235
+ * user who closed the tab isn't over-charged).
2236
+ */
2237
+ async expireUserStaleReservations(tx, userId, now) {
2238
+ const stale = await tx.update(usageReservations).set({ status: "expired" }).where(and2(eq3(usageReservations.userId, userId), eq3(usageReservations.status, "active"), lte(usageReservations.expiresAt, now))).returning({ id: usageReservations.id, runId: usageReservations.runId, amountMicros: usageReservations.amountMicros, chargeOnExpire: usageReservations.chargeOnExpire });
2239
+ for (const r of stale) {
2240
+ const usage = await tx.select({ total: sql6`coalesce(sum(${usageLedger.billedCostMicros}), 0)` }).from(usageLedger).where(and2(eq3(usageLedger.userId, userId), sql6`${usageLedger.metadata}->>'reservationId' = ${r.id}`));
2241
+ const billedTotal = Number(usage[0]?.total ?? 0);
2242
+ if (r.chargeOnExpire) {
2243
+ const topUp = Math.max(0, r.amountMicros - billedTotal);
2244
+ if (topUp > 0) {
2245
+ await this.insertVerifiedLedgerDebit(tx, {
2246
+ id: `usage-fallback:${r.id}`,
2247
+ userId,
2248
+ runId: r.runId,
2249
+ amountMicros: topUp,
2250
+ source: "pi-chat-expired",
2251
+ metadata: { kind: "reservation_expired_fallback", reservationId: r.id }
2252
+ });
2253
+ }
2254
+ }
2255
+ }
2256
+ return stale.length;
2257
+ }
2258
+ async computeBalance(executor, userId, now) {
2259
+ const [granted, used, reserved] = await Promise.all([
2260
+ this.sumGrants(executor, userId, now),
2261
+ this.sumUsage(executor, userId),
2262
+ this.sumActiveReservations(executor, userId, now)
2263
+ ]);
2264
+ const remainingMicros = granted - used;
2265
+ return {
2266
+ userId,
2267
+ grantedMicros: granted,
2268
+ usedMicros: used,
2269
+ remainingMicros,
2270
+ activeReservedMicros: reserved,
2271
+ availableMicros: remainingMicros - reserved
2272
+ };
2273
+ }
2274
+ async sumGrants(executor, userId, now) {
2275
+ const rows = await executor.select({ total: sql6`coalesce(sum(${creditGrants.amountMicros}), 0)` }).from(creditGrants).where(and2(eq3(creditGrants.userId, userId), or(isNull2(creditGrants.expiresAt), gt(creditGrants.expiresAt, now))));
2276
+ return toSafeMicros(rows[0]?.total, "grant total");
2277
+ }
2278
+ async sumUsage(executor, userId) {
2279
+ const rows = await executor.select({ total: sql6`coalesce(sum(${usageLedger.billedCostMicros}), 0)` }).from(usageLedger).where(eq3(usageLedger.userId, userId));
2280
+ return toSafeMicros(rows[0]?.total, "usage total");
2281
+ }
2282
+ async sumActiveReservations(executor, userId, now) {
2283
+ const rows = await executor.select({ total: sql6`coalesce(sum(${usageReservations.amountMicros}), 0)` }).from(usageReservations).where(
2284
+ and2(
2285
+ eq3(usageReservations.userId, userId),
2286
+ eq3(usageReservations.status, "active"),
2287
+ gt(usageReservations.expiresAt, now)
2288
+ )
2289
+ );
2290
+ return toSafeMicros(rows[0]?.total, "active reservation total");
2291
+ }
2292
+ };
2293
+ function toSafeMicros(value, label) {
2294
+ const parsed = BigInt(value ?? "0");
2295
+ if (parsed > BigInt(Number.MAX_SAFE_INTEGER)) {
2296
+ throw new Error(`metering ${label} exceeds the safe integer range (${parsed})`);
2297
+ }
2298
+ return Number(parsed);
2299
+ }
2300
+ function opaqueLedgerId(prefix, raw) {
2301
+ let hash = 2166136261;
2302
+ for (let i = 0; i < raw.length; i += 1) {
2303
+ hash ^= raw.charCodeAt(i);
2304
+ hash = Math.imul(hash, 16777619);
2305
+ }
2306
+ return `${prefix}_${(hash >>> 0).toString(16).padStart(8, "0")}`;
2307
+ }
2308
+ function describeGrant(reason) {
2309
+ if (reason === "signup_grant") return { kind: "grant", description: "Signup grant" };
2310
+ if (reason.startsWith("purchase:")) return { kind: "purchase", description: "Credit purchase" };
2311
+ return { kind: "grant", description: "Credit grant" };
2312
+ }
2313
+ function describeUsage(source) {
2314
+ if (source === "lemonsqueezy-refund") return { kind: "refund", description: "Refund" };
2315
+ if (source.startsWith("pi-chat-fallback") || source.startsWith("pi-chat-expired")) {
2316
+ return { kind: "fallback", description: "Usage reconciliation" };
2317
+ }
2318
+ return { kind: "usage", description: "Agent usage" };
2319
+ }
2320
+
1684
2321
  export {
1685
2322
  users,
1686
2323
  verification_tokens,
@@ -1690,6 +2327,10 @@ export {
1690
2327
  workspaceRuntimes,
1691
2328
  workspaceInvites,
1692
2329
  idempotencyKeys,
2330
+ creditGrants,
2331
+ creditPurchases,
2332
+ usageReservations,
2333
+ usageLedger,
1693
2334
  telemetryEvents,
1694
2335
  schema_exports,
1695
2336
  createDatabase,
@@ -1697,5 +2338,7 @@ export {
1697
2338
  LocalUserStore,
1698
2339
  LocalWorkspaceStore,
1699
2340
  PostgresWorkspaceStore,
1700
- PostgresUserStore
2341
+ PostgresUserStore,
2342
+ InsufficientCreditError,
2343
+ PostgresMeteringStore
1701
2344
  };