@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.
- package/dist/PostgresMeteringStore-CzNv6xil.d.ts +224 -0
- package/dist/app/front/index.d.ts +216 -3
- package/dist/app/front/index.js +834 -43
- package/dist/app/server/index.d.ts +3 -3
- package/dist/app/server/index.js +33 -8
- package/dist/{authHook-DUqyxueY.d.ts → authHook-CzBsMwwM.d.ts} +2 -2
- package/dist/{chunk-C3YMOITB.js → chunk-I56OTSPB.js} +649 -6
- package/dist/{chunk-H5KU6R6Y.js → chunk-LIBHVT7V.js} +5 -1
- package/dist/{chunk-GZVKZD4P.js → chunk-UM5SHYIS.js} +11 -2
- package/dist/{chunk-MLTJKZL4.js → chunk-VYXEXOCO.js} +21 -10
- package/dist/{connection-AL8KSENV.d.ts → connection-C5SiqoNc.d.ts} +1 -1
- package/dist/front/index.d.ts +15 -2
- package/dist/front/index.js +2 -2
- package/dist/server/db/index.d.ts +4 -4
- package/dist/server/db/index.js +6 -2
- package/dist/server/index.d.ts +594 -7
- package/dist/server/index.js +1467 -4
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/{types-CbMOXLBf.d.ts → types-CWtJ4kgd.d.ts} +3 -0
- package/drizzle/0011_usage_metering.sql +57 -0
- package/drizzle/0012_credit_purchases.sql +9 -0
- package/drizzle/0013_credit_purchase_lifecycle.sql +28 -0
- package/drizzle/0014_reservation_charge_on_expire.sql +7 -0
- package/drizzle/meta/_journal.json +28 -0
- package/package.json +4 -4
- package/dist/migrate-B4dwdtGP.d.ts +0 -8
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ERROR_CODES,
|
|
3
3
|
HttpError
|
|
4
|
-
} from "./chunk-
|
|
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
|
|
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(
|
|
22
|
-
return { db, sql:
|
|
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
|
};
|