@absolutejs/billing 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -0
- package/README.md +5 -1
- package/changelog.json +16 -0
- package/dist/creditWork.d.ts +25 -0
- package/dist/creditWork.js +460 -0
- package/dist/creditWork.js.map +12 -0
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/ledger.js +6 -6
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.js +211 -129
- package/dist/manifest.js.map +3 -3
- package/dist/prepaid.d.ts +101 -0
- package/dist/prepaid.js +271 -0
- package/dist/prepaid.js.map +10 -0
- package/dist/prepaidPostgres.d.ts +11 -0
- package/dist/prepaidPostgres.js +335 -0
- package/dist/prepaidPostgres.js.map +11 -0
- package/docs/prepaid-credits.md +29 -0
- package/package.json +29 -8
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/prepaid.ts
|
|
18
|
+
var integer = (value) => {
|
|
19
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
20
|
+
throw new Error("Credits must be nonnegative safe integers");
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
var periodIdentity = (value) => {
|
|
24
|
+
if (!Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value)
|
|
25
|
+
throw new Error("Credit period ID must be an ISO UTC start timestamp");
|
|
26
|
+
return value;
|
|
27
|
+
};
|
|
28
|
+
var identity = (value) => {
|
|
29
|
+
if (!value || value.length > 256)
|
|
30
|
+
throw new Error("Credit identity is invalid");
|
|
31
|
+
return value;
|
|
32
|
+
};
|
|
33
|
+
var validateCreditAccount = (account) => {
|
|
34
|
+
periodIdentity(account.periodId);
|
|
35
|
+
if (account.periodEnd !== null && periodIdentity(account.periodEnd) <= account.periodId)
|
|
36
|
+
throw new Error("Credit period end must follow its start");
|
|
37
|
+
for (const key of [
|
|
38
|
+
"periodAllowance",
|
|
39
|
+
"periodRemaining",
|
|
40
|
+
"purchasedRemaining",
|
|
41
|
+
"promotionalRemaining",
|
|
42
|
+
"debt",
|
|
43
|
+
"reserved",
|
|
44
|
+
"consumed"
|
|
45
|
+
])
|
|
46
|
+
integer(account[key]);
|
|
47
|
+
integer(account.periodRemaining + account.purchasedRemaining + account.promotionalRemaining);
|
|
48
|
+
if (account.periodRemaining > account.periodAllowance)
|
|
49
|
+
throw new Error("Period balance exceeds its allowance");
|
|
50
|
+
};
|
|
51
|
+
var availableCredits = (account) => Math.max(0, account.periodRemaining + account.purchasedRemaining + account.promotionalRemaining - account.debt);
|
|
52
|
+
var migrateLegacyCreditAccount = (input) => {
|
|
53
|
+
integer(input.periodAllowance);
|
|
54
|
+
integer(input.consumed);
|
|
55
|
+
if (!Number.isSafeInteger(input.bonusCredits))
|
|
56
|
+
throw new Error("Invalid legacy bonus credits");
|
|
57
|
+
const account = {
|
|
58
|
+
periodId: input.periodId,
|
|
59
|
+
periodEnd: input.periodEnd ?? null,
|
|
60
|
+
periodAllowance: input.periodAllowance,
|
|
61
|
+
periodRemaining: Math.max(0, input.periodAllowance - input.consumed),
|
|
62
|
+
purchasedRemaining: 0,
|
|
63
|
+
promotionalRemaining: Math.max(0, input.bonusCredits - Math.max(0, input.consumed - input.periodAllowance)),
|
|
64
|
+
debt: Math.max(0, -input.bonusCredits) + Math.max(0, input.consumed - input.periodAllowance - Math.max(0, input.bonusCredits)),
|
|
65
|
+
reserved: 0,
|
|
66
|
+
consumed: input.consumed
|
|
67
|
+
};
|
|
68
|
+
validateCreditAccount(account);
|
|
69
|
+
return account;
|
|
70
|
+
};
|
|
71
|
+
var allocationTotal = (value) => value.period + value.promotional + value.purchased;
|
|
72
|
+
var take = (account, credits) => {
|
|
73
|
+
let owed = account.debt;
|
|
74
|
+
for (const key of [
|
|
75
|
+
"periodRemaining",
|
|
76
|
+
"promotionalRemaining",
|
|
77
|
+
"purchasedRemaining"
|
|
78
|
+
]) {
|
|
79
|
+
const covered = Math.min(account[key], owed);
|
|
80
|
+
account[key] -= covered;
|
|
81
|
+
owed -= covered;
|
|
82
|
+
}
|
|
83
|
+
account.debt = owed;
|
|
84
|
+
const period = Math.min(account.periodRemaining, credits);
|
|
85
|
+
const promotional = Math.min(account.promotionalRemaining, credits - period);
|
|
86
|
+
const purchased = Math.min(account.purchasedRemaining, credits - period - promotional);
|
|
87
|
+
account.periodRemaining -= period;
|
|
88
|
+
account.promotionalRemaining -= promotional;
|
|
89
|
+
account.purchasedRemaining -= purchased;
|
|
90
|
+
return { period, promotional, purchased };
|
|
91
|
+
};
|
|
92
|
+
var grant = (account, key, credits) => {
|
|
93
|
+
const covered = Math.min(account.debt, credits);
|
|
94
|
+
account.debt -= covered;
|
|
95
|
+
account[key] = integer(account[key] + credits - covered);
|
|
96
|
+
};
|
|
97
|
+
var normalize = (command) => {
|
|
98
|
+
switch (command.kind) {
|
|
99
|
+
case "grant":
|
|
100
|
+
case "reverse":
|
|
101
|
+
if (command.bucket !== "purchased" && command.bucket !== "promotional")
|
|
102
|
+
throw new Error("Invalid credit bucket");
|
|
103
|
+
return {
|
|
104
|
+
kind: command.kind,
|
|
105
|
+
bucket: command.bucket,
|
|
106
|
+
credits: integer(command.credits)
|
|
107
|
+
};
|
|
108
|
+
case "period":
|
|
109
|
+
return {
|
|
110
|
+
kind: command.kind,
|
|
111
|
+
periodId: periodIdentity(command.periodId),
|
|
112
|
+
periodEnd: command.periodEnd === null ? null : periodIdentity(command.periodEnd),
|
|
113
|
+
allowance: integer(command.allowance)
|
|
114
|
+
};
|
|
115
|
+
case "reserve":
|
|
116
|
+
case "settle":
|
|
117
|
+
return {
|
|
118
|
+
kind: command.kind,
|
|
119
|
+
reservationId: identity(command.reservationId),
|
|
120
|
+
credits: integer(command.credits)
|
|
121
|
+
};
|
|
122
|
+
case "release":
|
|
123
|
+
return {
|
|
124
|
+
kind: command.kind,
|
|
125
|
+
reservationId: identity(command.reservationId)
|
|
126
|
+
};
|
|
127
|
+
case "debit":
|
|
128
|
+
return {
|
|
129
|
+
kind: command.kind,
|
|
130
|
+
credits: integer(command.credits),
|
|
131
|
+
allowDebt: command.allowDebt === true,
|
|
132
|
+
...command.reference === undefined ? {} : { reference: identity(command.reference) }
|
|
133
|
+
};
|
|
134
|
+
default:
|
|
135
|
+
throw new Error("Invalid credit command");
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
var createCreditAccountLedger = (store) => ({
|
|
139
|
+
initialize: async (accountId, seed) => {
|
|
140
|
+
identity(accountId);
|
|
141
|
+
validateCreditAccount(seed);
|
|
142
|
+
await store.initialize(accountId, seed);
|
|
143
|
+
},
|
|
144
|
+
receipt: (accountId, operationId) => store.transaction(identity(accountId), (tx) => tx.receipt(identity(operationId))),
|
|
145
|
+
balance: (accountId) => store.read(identity(accountId)),
|
|
146
|
+
execute: async (accountId, operationId, input) => {
|
|
147
|
+
identity(accountId);
|
|
148
|
+
identity(operationId);
|
|
149
|
+
const command = normalize(input);
|
|
150
|
+
return store.transaction(accountId, async (tx) => {
|
|
151
|
+
const previous = await tx.receipt(operationId);
|
|
152
|
+
if (previous) {
|
|
153
|
+
if (JSON.stringify(normalize(previous.command)) !== JSON.stringify(command))
|
|
154
|
+
throw new Error("Credit operation ID reused with different input");
|
|
155
|
+
return previous;
|
|
156
|
+
}
|
|
157
|
+
const account = { ...tx.account };
|
|
158
|
+
validateCreditAccount(account);
|
|
159
|
+
let reservation;
|
|
160
|
+
switch (command.kind) {
|
|
161
|
+
case "grant":
|
|
162
|
+
grant(account, command.bucket === "purchased" ? "purchasedRemaining" : "promotionalRemaining", command.credits);
|
|
163
|
+
break;
|
|
164
|
+
case "reverse": {
|
|
165
|
+
const key = command.bucket === "purchased" ? "purchasedRemaining" : "promotionalRemaining";
|
|
166
|
+
const covered = Math.min(account[key], command.credits);
|
|
167
|
+
account[key] -= covered;
|
|
168
|
+
account.debt += command.credits - covered;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "period":
|
|
172
|
+
if (command.periodId < account.periodId)
|
|
173
|
+
throw new Error("Credit period cannot move backwards");
|
|
174
|
+
if (command.periodId === account.periodId) {
|
|
175
|
+
const increase = Math.max(0, command.allowance - account.periodAllowance);
|
|
176
|
+
if (account.periodEnd === null)
|
|
177
|
+
account.periodEnd = command.periodEnd;
|
|
178
|
+
account.periodAllowance += increase;
|
|
179
|
+
grant(account, "periodRemaining", increase);
|
|
180
|
+
} else {
|
|
181
|
+
account.periodId = command.periodId;
|
|
182
|
+
account.periodEnd = command.periodEnd;
|
|
183
|
+
account.periodAllowance = command.allowance;
|
|
184
|
+
account.periodRemaining = 0;
|
|
185
|
+
account.consumed = 0;
|
|
186
|
+
grant(account, "periodRemaining", command.allowance);
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
case "debit": {
|
|
190
|
+
if (!command.allowDebt && availableCredits(account) < command.credits)
|
|
191
|
+
throw new Error("Insufficient credits");
|
|
192
|
+
const used = allocationTotal(take(account, command.credits));
|
|
193
|
+
account.debt += command.credits - used;
|
|
194
|
+
account.consumed += command.credits;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case "reserve":
|
|
198
|
+
if (command.credits === 0)
|
|
199
|
+
throw new Error("Reservation must be positive");
|
|
200
|
+
if (await tx.reservation(command.reservationId))
|
|
201
|
+
throw new Error("Credit reservation already exists");
|
|
202
|
+
if (availableCredits(account) < command.credits)
|
|
203
|
+
throw new Error("Insufficient credits");
|
|
204
|
+
reservation = {
|
|
205
|
+
id: command.reservationId,
|
|
206
|
+
periodId: account.periodId,
|
|
207
|
+
allocation: take(account, command.credits),
|
|
208
|
+
status: "reserved",
|
|
209
|
+
charged: null
|
|
210
|
+
};
|
|
211
|
+
account.reserved += command.credits;
|
|
212
|
+
break;
|
|
213
|
+
case "settle":
|
|
214
|
+
case "release": {
|
|
215
|
+
const held = await tx.reservation(command.reservationId);
|
|
216
|
+
if (!held || held.status !== "reserved")
|
|
217
|
+
throw new Error("Credit reservation is not active");
|
|
218
|
+
const total = allocationTotal(held.allocation);
|
|
219
|
+
const charged = command.kind === "settle" ? command.credits : 0;
|
|
220
|
+
if (charged > total)
|
|
221
|
+
throw new Error("Charge exceeds reserved credits");
|
|
222
|
+
let refund = total - charged;
|
|
223
|
+
for (const [bucket, key] of [
|
|
224
|
+
["purchased", "purchasedRemaining"],
|
|
225
|
+
["promotional", "promotionalRemaining"],
|
|
226
|
+
["period", "periodRemaining"]
|
|
227
|
+
]) {
|
|
228
|
+
const restored = Math.min(held.allocation[bucket], refund);
|
|
229
|
+
refund -= restored;
|
|
230
|
+
if (bucket !== "period" || held.periodId === account.periodId)
|
|
231
|
+
grant(account, key, restored);
|
|
232
|
+
}
|
|
233
|
+
account.reserved -= total;
|
|
234
|
+
if (held.periodId === account.periodId)
|
|
235
|
+
account.consumed += charged;
|
|
236
|
+
reservation = {
|
|
237
|
+
...held,
|
|
238
|
+
status: command.kind === "settle" ? "settled" : "released",
|
|
239
|
+
charged
|
|
240
|
+
};
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
validateCreditAccount(account);
|
|
245
|
+
const receipt = {
|
|
246
|
+
command,
|
|
247
|
+
account,
|
|
248
|
+
...reservation ? { reservation } : {}
|
|
249
|
+
};
|
|
250
|
+
await tx.save(operationId, receipt);
|
|
251
|
+
return receipt;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
var creditEntitlements = (input) => {
|
|
256
|
+
const funded = input.prepaidEnabled && input.account !== null && input.account.purchasedRemaining + input.account.promotionalRemaining > 0 && availableCredits(input.account) > 0;
|
|
257
|
+
return {
|
|
258
|
+
portal: input.subscribed,
|
|
259
|
+
mcp: input.subscribed ? "member" : funded ? "prepaid" : "free"
|
|
260
|
+
};
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// src/prepaidPostgres.ts
|
|
264
|
+
var namespace = (value) => {
|
|
265
|
+
if (!/^[a-z][a-z0-9_]*$/.test(value))
|
|
266
|
+
throw new Error("Invalid credit schema name");
|
|
267
|
+
return value;
|
|
268
|
+
};
|
|
269
|
+
var creditAccountPostgresSchemaSql = (schema = "billing_credits") => {
|
|
270
|
+
const n = namespace(schema);
|
|
271
|
+
return `CREATE SCHEMA IF NOT EXISTS ${n};
|
|
272
|
+
CREATE TABLE IF NOT EXISTS ${n}.accounts (
|
|
273
|
+
account_id text PRIMARY KEY, state jsonb NOT NULL, initial_state jsonb NOT NULL,
|
|
274
|
+
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
|
|
275
|
+
);
|
|
276
|
+
CREATE TABLE IF NOT EXISTS ${n}.operations (
|
|
277
|
+
account_id text NOT NULL REFERENCES ${n}.accounts(account_id), operation_id text NOT NULL,
|
|
278
|
+
receipt jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (account_id, operation_id)
|
|
279
|
+
);
|
|
280
|
+
CREATE TABLE IF NOT EXISTS ${n}.reservations (
|
|
281
|
+
account_id text NOT NULL REFERENCES ${n}.accounts(account_id), reservation_id text NOT NULL,
|
|
282
|
+
state jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (account_id, reservation_id)
|
|
283
|
+
);`;
|
|
284
|
+
};
|
|
285
|
+
var createPostgresCreditAccountStore = (client, schema = "billing_credits") => {
|
|
286
|
+
const n = namespace(schema);
|
|
287
|
+
const read = async (sql, accountId, lock = false) => {
|
|
288
|
+
const { rows } = await sql.query(`SELECT state FROM ${n}.accounts WHERE account_id = $1${lock ? " FOR UPDATE" : ""}`, [accountId]);
|
|
289
|
+
if (!rows[0])
|
|
290
|
+
return null;
|
|
291
|
+
const state = rows[0].state;
|
|
292
|
+
validateCreditAccount(state);
|
|
293
|
+
return state;
|
|
294
|
+
};
|
|
295
|
+
return {
|
|
296
|
+
initialize: async (accountId, seed) => {
|
|
297
|
+
validateCreditAccount(seed);
|
|
298
|
+
await client.query(`INSERT INTO ${n}.accounts (account_id, state, initial_state) VALUES ($1, $2::jsonb, $2::jsonb) ON CONFLICT DO NOTHING`, [accountId, JSON.stringify(seed)]);
|
|
299
|
+
},
|
|
300
|
+
read: (accountId) => read(client, accountId),
|
|
301
|
+
transaction: (accountId, run) => client.transaction(async (sql) => {
|
|
302
|
+
const account = await read(sql, accountId, true);
|
|
303
|
+
if (!account)
|
|
304
|
+
throw new Error("Credit account is not initialized");
|
|
305
|
+
return run({
|
|
306
|
+
account,
|
|
307
|
+
receipt: async (operationId) => {
|
|
308
|
+
const { rows } = await sql.query(`SELECT receipt FROM ${n}.operations WHERE account_id = $1 AND operation_id = $2`, [accountId, operationId]);
|
|
309
|
+
return rows[0]?.receipt ?? null;
|
|
310
|
+
},
|
|
311
|
+
reservation: async (id) => {
|
|
312
|
+
const { rows } = await sql.query(`SELECT state FROM ${n}.reservations WHERE account_id = $1 AND reservation_id = $2`, [accountId, id]);
|
|
313
|
+
return rows[0]?.state ?? null;
|
|
314
|
+
},
|
|
315
|
+
save: async (operationId, receipt) => {
|
|
316
|
+
await sql.query(`UPDATE ${n}.accounts SET state = $2::jsonb, updated_at = now() WHERE account_id = $1`, [accountId, JSON.stringify(receipt.account)]);
|
|
317
|
+
if (receipt.reservation)
|
|
318
|
+
await sql.query(`INSERT INTO ${n}.reservations (account_id, reservation_id, state) VALUES ($1, $2, $3::jsonb) ON CONFLICT (account_id, reservation_id) DO UPDATE SET state = EXCLUDED.state, updated_at = now()`, [
|
|
319
|
+
accountId,
|
|
320
|
+
receipt.reservation.id,
|
|
321
|
+
JSON.stringify(receipt.reservation)
|
|
322
|
+
]);
|
|
323
|
+
await sql.query(`INSERT INTO ${n}.operations (account_id, operation_id, receipt) VALUES ($1, $2, $3::jsonb)`, [accountId, operationId, JSON.stringify(receipt)]);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
})
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
export {
|
|
330
|
+
createPostgresCreditAccountStore,
|
|
331
|
+
creditAccountPostgresSchemaSql
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
//# debugId=D56D4E1DB5A095C464756E2164756E21
|
|
335
|
+
//# sourceMappingURL=prepaidPostgres.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/prepaid.ts", "../src/prepaidPostgres.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/** Service-credit accounting. Host authorization and payment verification happen\n * before these commands. Every command and its receipt commit in one transaction. */\nexport type CreditAccount = {\n periodId: string;\n periodEnd: string | null;\n periodAllowance: number;\n periodRemaining: number;\n purchasedRemaining: number;\n promotionalRemaining: number;\n debt: number;\n reserved: number;\n consumed: number;\n};\nexport type CreditAllocation = {\n period: number;\n purchased: number;\n promotional: number;\n};\nexport type CreditReservation = {\n id: string;\n periodId: string;\n allocation: CreditAllocation;\n status: \"reserved\" | \"settled\" | \"released\";\n charged: number | null;\n};\nexport type CreditCommand =\n | { kind: \"grant\"; bucket: \"purchased\" | \"promotional\"; credits: number }\n | { kind: \"reverse\"; bucket: \"purchased\" | \"promotional\"; credits: number }\n | {\n kind: \"period\";\n periodId: string;\n periodEnd: string | null;\n allowance: number;\n }\n | { kind: \"reserve\"; reservationId: string; credits: number }\n | { kind: \"settle\"; reservationId: string; credits: number }\n | { kind: \"release\"; reservationId: string }\n | { kind: \"debit\"; credits: number; allowDebt?: boolean; reference?: string };\nexport type CreditReceipt = {\n command: CreditCommand;\n account: CreditAccount;\n reservation?: CreditReservation;\n};\nexport type CreditTransaction = {\n account: CreditAccount;\n receipt: (operationId: string) => Promise<CreditReceipt | null>;\n reservation: (id: string) => Promise<CreditReservation | null>;\n save: (operationId: string, receipt: CreditReceipt) => Promise<void>;\n};\nexport type CreditAccountStore = {\n /** Persist seed once. Never overwrite an existing account. */\n initialize: (accountId: string, seed: CreditAccount) => Promise<void>;\n read: (accountId: string) => Promise<CreditAccount | null>;\n /** Lock one account, serialize its commands across processes, and roll back\n * account, reservation and receipt together if the callback fails. */\n transaction: <T>(\n accountId: string,\n run: (tx: CreditTransaction) => Promise<T>,\n ) => Promise<T>;\n};\nconst integer = (value: number) => {\n if (!Number.isSafeInteger(value) || value < 0)\n throw new Error(\"Credits must be nonnegative safe integers\");\n return value;\n};\nconst periodIdentity = (value: string) => {\n if (\n !Number.isFinite(Date.parse(value)) ||\n new Date(value).toISOString() !== value\n )\n throw new Error(\"Credit period ID must be an ISO UTC start timestamp\");\n return value;\n};\nconst identity = (value: string) => {\n if (!value || value.length > 256)\n throw new Error(\"Credit identity is invalid\");\n return value;\n};\nexport const validateCreditAccount = (account: CreditAccount) => {\n periodIdentity(account.periodId);\n if (\n account.periodEnd !== null &&\n periodIdentity(account.periodEnd) <= account.periodId\n )\n throw new Error(\"Credit period end must follow its start\");\n for (const key of [\n \"periodAllowance\",\n \"periodRemaining\",\n \"purchasedRemaining\",\n \"promotionalRemaining\",\n \"debt\",\n \"reserved\",\n \"consumed\",\n ] as const)\n integer(account[key]);\n integer(\n account.periodRemaining +\n account.purchasedRemaining +\n account.promotionalRemaining,\n );\n if (account.periodRemaining > account.periodAllowance)\n throw new Error(\"Period balance exceeds its allowance\");\n};\nexport const availableCredits = (account: CreditAccount) =>\n Math.max(\n 0,\n account.periodRemaining +\n account.purchasedRemaining +\n account.promotionalRemaining -\n account.debt,\n );\n\n/** Preserve the currently observable legacy balance without inventing purchase\n * provenance. Historical mixed bonus grants remain promotional carryover. */\nexport const migrateLegacyCreditAccount = (input: {\n periodId: string;\n periodEnd?: string | null;\n periodAllowance: number;\n bonusCredits: number;\n consumed: number;\n}): CreditAccount => {\n integer(input.periodAllowance);\n integer(input.consumed);\n if (!Number.isSafeInteger(input.bonusCredits))\n throw new Error(\"Invalid legacy bonus credits\");\n const account: CreditAccount = {\n periodId: input.periodId,\n periodEnd: input.periodEnd ?? null,\n periodAllowance: input.periodAllowance,\n periodRemaining: Math.max(0, input.periodAllowance - input.consumed),\n purchasedRemaining: 0,\n promotionalRemaining: Math.max(\n 0,\n input.bonusCredits - Math.max(0, input.consumed - input.periodAllowance),\n ),\n debt:\n Math.max(0, -input.bonusCredits) +\n Math.max(\n 0,\n input.consumed -\n input.periodAllowance -\n Math.max(0, input.bonusCredits),\n ),\n reserved: 0,\n consumed: input.consumed,\n };\n validateCreditAccount(account);\n return account;\n};\nconst allocationTotal = (value: CreditAllocation) =>\n value.period + value.promotional + value.purchased;\nconst take = (account: CreditAccount, credits: number): CreditAllocation => {\n // A reversal liability consumes existing available credits exactly once.\n let owed = account.debt;\n for (const key of [\n \"periodRemaining\",\n \"promotionalRemaining\",\n \"purchasedRemaining\",\n ] as const) {\n const covered = Math.min(account[key], owed);\n account[key] -= covered;\n owed -= covered;\n }\n account.debt = owed;\n const period = Math.min(account.periodRemaining, credits);\n const promotional = Math.min(account.promotionalRemaining, credits - period);\n const purchased = Math.min(\n account.purchasedRemaining,\n credits - period - promotional,\n );\n account.periodRemaining -= period;\n account.promotionalRemaining -= promotional;\n account.purchasedRemaining -= purchased;\n return { period, promotional, purchased };\n};\nconst grant = (\n account: CreditAccount,\n key: \"periodRemaining\" | \"purchasedRemaining\" | \"promotionalRemaining\",\n credits: number,\n) => {\n const covered = Math.min(account.debt, credits);\n account.debt -= covered;\n account[key] = integer(account[key] + credits - covered);\n};\nconst normalize = (command: CreditCommand): CreditCommand => {\n switch (command.kind) {\n case \"grant\":\n case \"reverse\":\n if (command.bucket !== \"purchased\" && command.bucket !== \"promotional\")\n throw new Error(\"Invalid credit bucket\");\n return {\n kind: command.kind,\n bucket: command.bucket,\n credits: integer(command.credits),\n };\n case \"period\":\n return {\n kind: command.kind,\n periodId: periodIdentity(command.periodId),\n periodEnd:\n command.periodEnd === null ? null : periodIdentity(command.periodEnd),\n allowance: integer(command.allowance),\n };\n case \"reserve\":\n case \"settle\":\n return {\n kind: command.kind,\n reservationId: identity(command.reservationId),\n credits: integer(command.credits),\n };\n case \"release\":\n return {\n kind: command.kind,\n reservationId: identity(command.reservationId),\n };\n case \"debit\":\n return {\n kind: command.kind,\n credits: integer(command.credits),\n allowDebt: command.allowDebt === true,\n ...(command.reference === undefined\n ? {}\n : { reference: identity(command.reference) }),\n };\n default:\n throw new Error(\"Invalid credit command\");\n }\n};\nexport const createCreditAccountLedger = (store: CreditAccountStore) => ({\n initialize: async (accountId: string, seed: CreditAccount) => {\n identity(accountId);\n validateCreditAccount(seed);\n await store.initialize(accountId, seed);\n },\n receipt: (accountId: string, operationId: string) =>\n store.transaction(identity(accountId), (tx) =>\n tx.receipt(identity(operationId)),\n ),\n balance: (accountId: string) => store.read(identity(accountId)),\n execute: async (\n accountId: string,\n operationId: string,\n input: CreditCommand,\n ): Promise<CreditReceipt> => {\n identity(accountId);\n identity(operationId);\n const command = normalize(input);\n return store.transaction(accountId, async (tx) => {\n const previous = await tx.receipt(operationId);\n if (previous) {\n if (\n JSON.stringify(normalize(previous.command)) !==\n JSON.stringify(command)\n )\n throw new Error(\"Credit operation ID reused with different input\");\n return previous;\n }\n const account = { ...tx.account };\n validateCreditAccount(account);\n let reservation: CreditReservation | undefined;\n switch (command.kind) {\n case \"grant\":\n grant(\n account,\n command.bucket === \"purchased\"\n ? \"purchasedRemaining\"\n : \"promotionalRemaining\",\n command.credits,\n );\n break;\n case \"reverse\": {\n const key =\n command.bucket === \"purchased\"\n ? \"purchasedRemaining\"\n : \"promotionalRemaining\";\n const covered = Math.min(account[key], command.credits);\n account[key] -= covered;\n account.debt += command.credits - covered;\n break;\n }\n case \"period\":\n if (command.periodId < account.periodId)\n throw new Error(\"Credit period cannot move backwards\");\n if (command.periodId === account.periodId) {\n // Upgrades add only the increase. Downgrades take effect next period.\n const increase = Math.max(\n 0,\n command.allowance - account.periodAllowance,\n );\n if (account.periodEnd === null)\n account.periodEnd = command.periodEnd;\n account.periodAllowance += increase;\n grant(account, \"periodRemaining\", increase);\n } else {\n account.periodId = command.periodId;\n account.periodEnd = command.periodEnd;\n account.periodAllowance = command.allowance;\n account.periodRemaining = 0;\n account.consumed = 0;\n grant(account, \"periodRemaining\", command.allowance);\n }\n break;\n case \"debit\": {\n if (!command.allowDebt && availableCredits(account) < command.credits)\n throw new Error(\"Insufficient credits\");\n const used = allocationTotal(take(account, command.credits));\n account.debt += command.credits - used;\n account.consumed += command.credits;\n break;\n }\n case \"reserve\":\n if (command.credits === 0)\n throw new Error(\"Reservation must be positive\");\n if (await tx.reservation(command.reservationId))\n throw new Error(\"Credit reservation already exists\");\n if (availableCredits(account) < command.credits)\n throw new Error(\"Insufficient credits\");\n reservation = {\n id: command.reservationId,\n periodId: account.periodId,\n allocation: take(account, command.credits),\n status: \"reserved\",\n charged: null,\n };\n account.reserved += command.credits;\n break;\n case \"settle\":\n case \"release\": {\n const held = await tx.reservation(command.reservationId);\n if (!held || held.status !== \"reserved\")\n throw new Error(\"Credit reservation is not active\");\n const total = allocationTotal(held.allocation);\n const charged = command.kind === \"settle\" ? command.credits : 0;\n if (charged > total)\n throw new Error(\"Charge exceeds reserved credits\");\n let refund = total - charged;\n for (const [bucket, key] of [\n [\"purchased\", \"purchasedRemaining\"],\n [\"promotional\", \"promotionalRemaining\"],\n [\"period\", \"periodRemaining\"],\n ] as const) {\n const restored = Math.min(held.allocation[bucket], refund);\n refund -= restored;\n if (bucket !== \"period\" || held.periodId === account.periodId)\n grant(account, key, restored);\n }\n account.reserved -= total;\n if (held.periodId === account.periodId) account.consumed += charged;\n reservation = {\n ...held,\n status: command.kind === \"settle\" ? \"settled\" : \"released\",\n charged,\n };\n break;\n }\n }\n validateCreditAccount(account);\n const receipt: CreditReceipt = {\n command,\n account,\n ...(reservation ? { reservation } : {}),\n };\n await tx.save(operationId, receipt);\n return receipt;\n });\n },\n});\n\n/** Portal access and funded MCP access are independent. Period-only free\n * allowances do not silently unlock prepaid work; carryover grants may. */\nexport const creditEntitlements = (input: {\n subscribed: boolean;\n prepaidEnabled: boolean;\n account: CreditAccount | null;\n}) => {\n const funded =\n input.prepaidEnabled &&\n input.account !== null &&\n input.account.purchasedRemaining + input.account.promotionalRemaining > 0 &&\n availableCredits(input.account) > 0;\n return {\n portal: input.subscribed,\n mcp: input.subscribed\n ? (\"member\" as const)\n : funded\n ? (\"prepaid\" as const)\n : (\"free\" as const),\n };\n};\n",
|
|
6
|
+
"import {\n validateCreditAccount,\n type CreditAccount,\n type CreditAccountStore,\n type CreditReceipt,\n type CreditReservation,\n} from \"./prepaid\";\nexport type CreditSql = {\n query: (\n query: string,\n parameters: readonly unknown[],\n ) => Promise<{ rows: Record<string, unknown>[] }>;\n};\nexport type CreditSqlClient = CreditSql & {\n transaction: <T>(run: (sql: CreditSql) => Promise<T>) => Promise<T>;\n};\nconst namespace = (value: string) => {\n if (!/^[a-z][a-z0-9_]*$/.test(value))\n throw new Error(\"Invalid credit schema name\");\n return value;\n};\nexport const creditAccountPostgresSchemaSql = (schema = \"billing_credits\") => {\n const n = namespace(schema);\n return `CREATE SCHEMA IF NOT EXISTS ${n};\nCREATE TABLE IF NOT EXISTS ${n}.accounts (\n account_id text PRIMARY KEY, state jsonb NOT NULL, initial_state jsonb NOT NULL,\n created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()\n);\nCREATE TABLE IF NOT EXISTS ${n}.operations (\n account_id text NOT NULL REFERENCES ${n}.accounts(account_id), operation_id text NOT NULL,\n receipt jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (account_id, operation_id)\n);\nCREATE TABLE IF NOT EXISTS ${n}.reservations (\n account_id text NOT NULL REFERENCES ${n}.accounts(account_id), reservation_id text NOT NULL,\n state jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (account_id, reservation_id)\n);`;\n};\nexport const createPostgresCreditAccountStore = (\n client: CreditSqlClient,\n schema = \"billing_credits\",\n): CreditAccountStore => {\n const n = namespace(schema);\n const read = async (sql: CreditSql, accountId: string, lock = false) => {\n const { rows } = await sql.query(\n `SELECT state FROM ${n}.accounts WHERE account_id = $1${lock ? \" FOR UPDATE\" : \"\"}`,\n [accountId],\n );\n if (!rows[0]) return null;\n const state = rows[0].state as CreditAccount;\n validateCreditAccount(state);\n return state;\n };\n return {\n initialize: async (accountId, seed) => {\n validateCreditAccount(seed);\n await client.query(\n `INSERT INTO ${n}.accounts (account_id, state, initial_state) VALUES ($1, $2::jsonb, $2::jsonb) ON CONFLICT DO NOTHING`,\n [accountId, JSON.stringify(seed)],\n );\n },\n read: (accountId) => read(client, accountId),\n transaction: (accountId, run) =>\n client.transaction(async (sql) => {\n const account = await read(sql, accountId, true);\n if (!account) throw new Error(\"Credit account is not initialized\");\n return run({\n account,\n receipt: async (operationId) => {\n const { rows } = await sql.query(\n `SELECT receipt FROM ${n}.operations WHERE account_id = $1 AND operation_id = $2`,\n [accountId, operationId],\n );\n return (rows[0]?.receipt as CreditReceipt | undefined) ?? null;\n },\n reservation: async (id) => {\n const { rows } = await sql.query(\n `SELECT state FROM ${n}.reservations WHERE account_id = $1 AND reservation_id = $2`,\n [accountId, id],\n );\n return (rows[0]?.state as CreditReservation | undefined) ?? null;\n },\n save: async (operationId, receipt) => {\n await sql.query(\n `UPDATE ${n}.accounts SET state = $2::jsonb, updated_at = now() WHERE account_id = $1`,\n [accountId, JSON.stringify(receipt.account)],\n );\n if (receipt.reservation)\n await sql.query(\n `INSERT INTO ${n}.reservations (account_id, reservation_id, state) VALUES ($1, $2, $3::jsonb) ON CONFLICT (account_id, reservation_id) DO UPDATE SET state = EXCLUDED.state, updated_at = now()`,\n [\n accountId,\n receipt.reservation.id,\n JSON.stringify(receipt.reservation),\n ],\n );\n await sql.query(\n `INSERT INTO ${n}.operations (account_id, operation_id, receipt) VALUES ($1, $2, $3::jsonb)`,\n [accountId, operationId, JSON.stringify(receipt)],\n );\n },\n });\n }),\n };\n};\n"
|
|
7
|
+
],
|
|
8
|
+
"mappings": ";;;;;;;;;;;;;;;;;AA4DA,IAAM,UAAU,CAAC,UAAkB;AAAA,EACjC,IAAI,CAAC,OAAO,cAAc,KAAK,KAAK,QAAQ;AAAA,IAC1C,MAAM,IAAI,MAAM,2CAA2C;AAAA,EAC7D,OAAO;AAAA;AAET,IAAM,iBAAiB,CAAC,UAAkB;AAAA,EACxC,IACE,CAAC,OAAO,SAAS,KAAK,MAAM,KAAK,CAAC,KAClC,IAAI,KAAK,KAAK,EAAE,YAAY,MAAM;AAAA,IAElC,MAAM,IAAI,MAAM,qDAAqD;AAAA,EACvE,OAAO;AAAA;AAET,IAAM,WAAW,CAAC,UAAkB;AAAA,EAClC,IAAI,CAAC,SAAS,MAAM,SAAS;AAAA,IAC3B,MAAM,IAAI,MAAM,4BAA4B;AAAA,EAC9C,OAAO;AAAA;AAEF,IAAM,wBAAwB,CAAC,YAA2B;AAAA,EAC/D,eAAe,QAAQ,QAAQ;AAAA,EAC/B,IACE,QAAQ,cAAc,QACtB,eAAe,QAAQ,SAAS,KAAK,QAAQ;AAAA,IAE7C,MAAM,IAAI,MAAM,yCAAyC;AAAA,EAC3D,WAAW,OAAO;AAAA,IAChB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAAA,IACE,QAAQ,QAAQ,IAAI;AAAA,EACtB,QACE,QAAQ,kBACN,QAAQ,qBACR,QAAQ,oBACZ;AAAA,EACA,IAAI,QAAQ,kBAAkB,QAAQ;AAAA,IACpC,MAAM,IAAI,MAAM,sCAAsC;AAAA;AAEnD,IAAM,mBAAmB,CAAC,YAC/B,KAAK,IACH,GACA,QAAQ,kBACN,QAAQ,qBACR,QAAQ,uBACR,QAAQ,IACZ;AAIK,IAAM,6BAA6B,CAAC,UAMtB;AAAA,EACnB,QAAQ,MAAM,eAAe;AAAA,EAC7B,QAAQ,MAAM,QAAQ;AAAA,EACtB,IAAI,CAAC,OAAO,cAAc,MAAM,YAAY;AAAA,IAC1C,MAAM,IAAI,MAAM,8BAA8B;AAAA,EAChD,MAAM,UAAyB;AAAA,IAC7B,UAAU,MAAM;AAAA,IAChB,WAAW,MAAM,aAAa;AAAA,IAC9B,iBAAiB,MAAM;AAAA,IACvB,iBAAiB,KAAK,IAAI,GAAG,MAAM,kBAAkB,MAAM,QAAQ;AAAA,IACnE,oBAAoB;AAAA,IACpB,sBAAsB,KAAK,IACzB,GACA,MAAM,eAAe,KAAK,IAAI,GAAG,MAAM,WAAW,MAAM,eAAe,CACzE;AAAA,IACA,MACE,KAAK,IAAI,GAAG,CAAC,MAAM,YAAY,IAC/B,KAAK,IACH,GACA,MAAM,WACJ,MAAM,kBACN,KAAK,IAAI,GAAG,MAAM,YAAY,CAClC;AAAA,IACF,UAAU;AAAA,IACV,UAAU,MAAM;AAAA,EAClB;AAAA,EACA,sBAAsB,OAAO;AAAA,EAC7B,OAAO;AAAA;AAET,IAAM,kBAAkB,CAAC,UACvB,MAAM,SAAS,MAAM,cAAc,MAAM;AAC3C,IAAM,OAAO,CAAC,SAAwB,YAAsC;AAAA,EAE1E,IAAI,OAAO,QAAQ;AAAA,EACnB,WAAW,OAAO;AAAA,IAChB;AAAA,IACA;AAAA,IACA;AAAA,EACF,GAAY;AAAA,IACV,MAAM,UAAU,KAAK,IAAI,QAAQ,MAAM,IAAI;AAAA,IAC3C,QAAQ,QAAQ;AAAA,IAChB,QAAQ;AAAA,EACV;AAAA,EACA,QAAQ,OAAO;AAAA,EACf,MAAM,SAAS,KAAK,IAAI,QAAQ,iBAAiB,OAAO;AAAA,EACxD,MAAM,cAAc,KAAK,IAAI,QAAQ,sBAAsB,UAAU,MAAM;AAAA,EAC3E,MAAM,YAAY,KAAK,IACrB,QAAQ,oBACR,UAAU,SAAS,WACrB;AAAA,EACA,QAAQ,mBAAmB;AAAA,EAC3B,QAAQ,wBAAwB;AAAA,EAChC,QAAQ,sBAAsB;AAAA,EAC9B,OAAO,EAAE,QAAQ,aAAa,UAAU;AAAA;AAE1C,IAAM,QAAQ,CACZ,SACA,KACA,YACG;AAAA,EACH,MAAM,UAAU,KAAK,IAAI,QAAQ,MAAM,OAAO;AAAA,EAC9C,QAAQ,QAAQ;AAAA,EAChB,QAAQ,OAAO,QAAQ,QAAQ,OAAO,UAAU,OAAO;AAAA;AAEzD,IAAM,YAAY,CAAC,YAA0C;AAAA,EAC3D,QAAQ,QAAQ;AAAA,SACT;AAAA,SACA;AAAA,MACH,IAAI,QAAQ,WAAW,eAAe,QAAQ,WAAW;AAAA,QACvD,MAAM,IAAI,MAAM,uBAAuB;AAAA,MACzC,OAAO;AAAA,QACL,MAAM,QAAQ;AAAA,QACd,QAAQ,QAAQ;AAAA,QAChB,SAAS,QAAQ,QAAQ,OAAO;AAAA,MAClC;AAAA,SACG;AAAA,MACH,OAAO;AAAA,QACL,MAAM,QAAQ;AAAA,QACd,UAAU,eAAe,QAAQ,QAAQ;AAAA,QACzC,WACE,QAAQ,cAAc,OAAO,OAAO,eAAe,QAAQ,SAAS;AAAA,QACtE,WAAW,QAAQ,QAAQ,SAAS;AAAA,MACtC;AAAA,SACG;AAAA,SACA;AAAA,MACH,OAAO;AAAA,QACL,MAAM,QAAQ;AAAA,QACd,eAAe,SAAS,QAAQ,aAAa;AAAA,QAC7C,SAAS,QAAQ,QAAQ,OAAO;AAAA,MAClC;AAAA,SACG;AAAA,MACH,OAAO;AAAA,QACL,MAAM,QAAQ;AAAA,QACd,eAAe,SAAS,QAAQ,aAAa;AAAA,MAC/C;AAAA,SACG;AAAA,MACH,OAAO;AAAA,QACL,MAAM,QAAQ;AAAA,QACd,SAAS,QAAQ,QAAQ,OAAO;AAAA,QAChC,WAAW,QAAQ,cAAc;AAAA,WAC7B,QAAQ,cAAc,YACtB,CAAC,IACD,EAAE,WAAW,SAAS,QAAQ,SAAS,EAAE;AAAA,MAC/C;AAAA;AAAA,MAEA,MAAM,IAAI,MAAM,wBAAwB;AAAA;AAAA;AAGvC,IAAM,4BAA4B,CAAC,WAA+B;AAAA,EACvE,YAAY,OAAO,WAAmB,SAAwB;AAAA,IAC5D,SAAS,SAAS;AAAA,IAClB,sBAAsB,IAAI;AAAA,IAC1B,MAAM,MAAM,WAAW,WAAW,IAAI;AAAA;AAAA,EAExC,SAAS,CAAC,WAAmB,gBAC3B,MAAM,YAAY,SAAS,SAAS,GAAG,CAAC,OACtC,GAAG,QAAQ,SAAS,WAAW,CAAC,CAClC;AAAA,EACF,SAAS,CAAC,cAAsB,MAAM,KAAK,SAAS,SAAS,CAAC;AAAA,EAC9D,SAAS,OACP,WACA,aACA,UAC2B;AAAA,IAC3B,SAAS,SAAS;AAAA,IAClB,SAAS,WAAW;AAAA,IACpB,MAAM,UAAU,UAAU,KAAK;AAAA,IAC/B,OAAO,MAAM,YAAY,WAAW,OAAO,OAAO;AAAA,MAChD,MAAM,WAAW,MAAM,GAAG,QAAQ,WAAW;AAAA,MAC7C,IAAI,UAAU;AAAA,QACZ,IACE,KAAK,UAAU,UAAU,SAAS,OAAO,CAAC,MAC1C,KAAK,UAAU,OAAO;AAAA,UAEtB,MAAM,IAAI,MAAM,iDAAiD;AAAA,QACnE,OAAO;AAAA,MACT;AAAA,MACA,MAAM,UAAU,KAAK,GAAG,QAAQ;AAAA,MAChC,sBAAsB,OAAO;AAAA,MAC7B,IAAI;AAAA,MACJ,QAAQ,QAAQ;AAAA,aACT;AAAA,UACH,MACE,SACA,QAAQ,WAAW,cACf,uBACA,wBACJ,QAAQ,OACV;AAAA,UACA;AAAA,aACG,WAAW;AAAA,UACd,MAAM,MACJ,QAAQ,WAAW,cACf,uBACA;AAAA,UACN,MAAM,UAAU,KAAK,IAAI,QAAQ,MAAM,QAAQ,OAAO;AAAA,UACtD,QAAQ,QAAQ;AAAA,UAChB,QAAQ,QAAQ,QAAQ,UAAU;AAAA,UAClC;AAAA,QACF;AAAA,aACK;AAAA,UACH,IAAI,QAAQ,WAAW,QAAQ;AAAA,YAC7B,MAAM,IAAI,MAAM,qCAAqC;AAAA,UACvD,IAAI,QAAQ,aAAa,QAAQ,UAAU;AAAA,YAEzC,MAAM,WAAW,KAAK,IACpB,GACA,QAAQ,YAAY,QAAQ,eAC9B;AAAA,YACA,IAAI,QAAQ,cAAc;AAAA,cACxB,QAAQ,YAAY,QAAQ;AAAA,YAC9B,QAAQ,mBAAmB;AAAA,YAC3B,MAAM,SAAS,mBAAmB,QAAQ;AAAA,UAC5C,EAAO;AAAA,YACL,QAAQ,WAAW,QAAQ;AAAA,YAC3B,QAAQ,YAAY,QAAQ;AAAA,YAC5B,QAAQ,kBAAkB,QAAQ;AAAA,YAClC,QAAQ,kBAAkB;AAAA,YAC1B,QAAQ,WAAW;AAAA,YACnB,MAAM,SAAS,mBAAmB,QAAQ,SAAS;AAAA;AAAA,UAErD;AAAA,aACG,SAAS;AAAA,UACZ,IAAI,CAAC,QAAQ,aAAa,iBAAiB,OAAO,IAAI,QAAQ;AAAA,YAC5D,MAAM,IAAI,MAAM,sBAAsB;AAAA,UACxC,MAAM,OAAO,gBAAgB,KAAK,SAAS,QAAQ,OAAO,CAAC;AAAA,UAC3D,QAAQ,QAAQ,QAAQ,UAAU;AAAA,UAClC,QAAQ,YAAY,QAAQ;AAAA,UAC5B;AAAA,QACF;AAAA,aACK;AAAA,UACH,IAAI,QAAQ,YAAY;AAAA,YACtB,MAAM,IAAI,MAAM,8BAA8B;AAAA,UAChD,IAAI,MAAM,GAAG,YAAY,QAAQ,aAAa;AAAA,YAC5C,MAAM,IAAI,MAAM,mCAAmC;AAAA,UACrD,IAAI,iBAAiB,OAAO,IAAI,QAAQ;AAAA,YACtC,MAAM,IAAI,MAAM,sBAAsB;AAAA,UACxC,cAAc;AAAA,YACZ,IAAI,QAAQ;AAAA,YACZ,UAAU,QAAQ;AAAA,YAClB,YAAY,KAAK,SAAS,QAAQ,OAAO;AAAA,YACzC,QAAQ;AAAA,YACR,SAAS;AAAA,UACX;AAAA,UACA,QAAQ,YAAY,QAAQ;AAAA,UAC5B;AAAA,aACG;AAAA,aACA,WAAW;AAAA,UACd,MAAM,OAAO,MAAM,GAAG,YAAY,QAAQ,aAAa;AAAA,UACvD,IAAI,CAAC,QAAQ,KAAK,WAAW;AAAA,YAC3B,MAAM,IAAI,MAAM,kCAAkC;AAAA,UACpD,MAAM,QAAQ,gBAAgB,KAAK,UAAU;AAAA,UAC7C,MAAM,UAAU,QAAQ,SAAS,WAAW,QAAQ,UAAU;AAAA,UAC9D,IAAI,UAAU;AAAA,YACZ,MAAM,IAAI,MAAM,iCAAiC;AAAA,UACnD,IAAI,SAAS,QAAQ;AAAA,UACrB,YAAY,QAAQ,QAAQ;AAAA,YAC1B,CAAC,aAAa,oBAAoB;AAAA,YAClC,CAAC,eAAe,sBAAsB;AAAA,YACtC,CAAC,UAAU,iBAAiB;AAAA,UAC9B,GAAY;AAAA,YACV,MAAM,WAAW,KAAK,IAAI,KAAK,WAAW,SAAS,MAAM;AAAA,YACzD,UAAU;AAAA,YACV,IAAI,WAAW,YAAY,KAAK,aAAa,QAAQ;AAAA,cACnD,MAAM,SAAS,KAAK,QAAQ;AAAA,UAChC;AAAA,UACA,QAAQ,YAAY;AAAA,UACpB,IAAI,KAAK,aAAa,QAAQ;AAAA,YAAU,QAAQ,YAAY;AAAA,UAC5D,cAAc;AAAA,eACT;AAAA,YACH,QAAQ,QAAQ,SAAS,WAAW,YAAY;AAAA,YAChD;AAAA,UACF;AAAA,UACA;AAAA,QACF;AAAA;AAAA,MAEF,sBAAsB,OAAO;AAAA,MAC7B,MAAM,UAAyB;AAAA,QAC7B;AAAA,QACA;AAAA,WACI,cAAc,EAAE,YAAY,IAAI,CAAC;AAAA,MACvC;AAAA,MACA,MAAM,GAAG,KAAK,aAAa,OAAO;AAAA,MAClC,OAAO;AAAA,KACR;AAAA;AAEL;AAIO,IAAM,qBAAqB,CAAC,UAI7B;AAAA,EACJ,MAAM,SACJ,MAAM,kBACN,MAAM,YAAY,QAClB,MAAM,QAAQ,qBAAqB,MAAM,QAAQ,uBAAuB,KACxE,iBAAiB,MAAM,OAAO,IAAI;AAAA,EACpC,OAAO;AAAA,IACL,QAAQ,MAAM;AAAA,IACd,KAAK,MAAM,aACN,WACD,SACG,YACA;AAAA,EACT;AAAA;;;ACnXF,IAAM,YAAY,CAAC,UAAkB;AAAA,EACnC,IAAI,CAAC,oBAAoB,KAAK,KAAK;AAAA,IACjC,MAAM,IAAI,MAAM,4BAA4B;AAAA,EAC9C,OAAO;AAAA;AAEF,IAAM,iCAAiC,CAAC,SAAS,sBAAsB;AAAA,EAC5E,MAAM,IAAI,UAAU,MAAM;AAAA,EAC1B,OAAO,+BAA+B;AAAA,6BACX;AAAA;AAAA;AAAA;AAAA,6BAIA;AAAA,wCACW;AAAA;AAAA;AAAA,6BAGX;AAAA,wCACW;AAAA;AAAA;AAAA;AAIjC,IAAM,mCAAmC,CAC9C,QACA,SAAS,sBACc;AAAA,EACvB,MAAM,IAAI,UAAU,MAAM;AAAA,EAC1B,MAAM,OAAO,OAAO,KAAgB,WAAmB,OAAO,UAAU;AAAA,IACtE,QAAQ,SAAS,MAAM,IAAI,MACzB,qBAAqB,mCAAmC,OAAO,gBAAgB,MAC/E,CAAC,SAAS,CACZ;AAAA,IACA,IAAI,CAAC,KAAK;AAAA,MAAI,OAAO;AAAA,IACrB,MAAM,QAAQ,KAAK,GAAG;AAAA,IACtB,sBAAsB,KAAK;AAAA,IAC3B,OAAO;AAAA;AAAA,EAET,OAAO;AAAA,IACL,YAAY,OAAO,WAAW,SAAS;AAAA,MACrC,sBAAsB,IAAI;AAAA,MAC1B,MAAM,OAAO,MACX,eAAe,0GACf,CAAC,WAAW,KAAK,UAAU,IAAI,CAAC,CAClC;AAAA;AAAA,IAEF,MAAM,CAAC,cAAc,KAAK,QAAQ,SAAS;AAAA,IAC3C,aAAa,CAAC,WAAW,QACvB,OAAO,YAAY,OAAO,QAAQ;AAAA,MAChC,MAAM,UAAU,MAAM,KAAK,KAAK,WAAW,IAAI;AAAA,MAC/C,IAAI,CAAC;AAAA,QAAS,MAAM,IAAI,MAAM,mCAAmC;AAAA,MACjE,OAAO,IAAI;AAAA,QACT;AAAA,QACA,SAAS,OAAO,gBAAgB;AAAA,UAC9B,QAAQ,SAAS,MAAM,IAAI,MACzB,uBAAuB,4DACvB,CAAC,WAAW,WAAW,CACzB;AAAA,UACA,OAAQ,KAAK,IAAI,WAAyC;AAAA;AAAA,QAE5D,aAAa,OAAO,OAAO;AAAA,UACzB,QAAQ,SAAS,MAAM,IAAI,MACzB,qBAAqB,gEACrB,CAAC,WAAW,EAAE,CAChB;AAAA,UACA,OAAQ,KAAK,IAAI,SAA2C;AAAA;AAAA,QAE9D,MAAM,OAAO,aAAa,YAAY;AAAA,UACpC,MAAM,IAAI,MACR,UAAU,8EACV,CAAC,WAAW,KAAK,UAAU,QAAQ,OAAO,CAAC,CAC7C;AAAA,UACA,IAAI,QAAQ;AAAA,YACV,MAAM,IAAI,MACR,eAAe,mLACf;AAAA,cACE;AAAA,cACA,QAAQ,YAAY;AAAA,cACpB,KAAK,UAAU,QAAQ,WAAW;AAAA,YACpC,CACF;AAAA,UACF,MAAM,IAAI,MACR,eAAe,+EACf,CAAC,WAAW,aAAa,KAAK,UAAU,OAAO,CAAC,CAClD;AAAA;AAAA,MAEJ,CAAC;AAAA,KACF;AAAA,EACL;AAAA;",
|
|
9
|
+
"debugId": "D56D4E1DB5A095C464756E2164756E21",
|
|
10
|
+
"names": []
|
|
11
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Prepaid service credits
|
|
2
|
+
|
|
3
|
+
Use `@absolutejs/billing/prepaid` for service-credit balances and `@absolutejs/billing/prepaid-postgres` for durable storage. These units are not currency and are not the assistant provider's model tokens. Host commerce permission, authentication, approval, and payment verification remain separate requirements.
|
|
4
|
+
|
|
5
|
+
`createCreditAccountLedger(store)` exposes `initialize`, `balance`, `receipt`, and `execute`. PostgreSQL stores initial state, current state, immutable operation receipts, and reservation state. Every command locks its account and commits its receipt and balance together. Operation IDs are scoped to the account; reuse with different normalized input fails. Identical retries return the historical receipt, so use `balance` for the current view.
|
|
6
|
+
|
|
7
|
+
## Accounting rules
|
|
8
|
+
|
|
9
|
+
- Purchased and promotional balances persist across renewals and subscription cancellation. Period IDs are canonical ISO UTC start timestamps and cannot move backwards. A same-period upgrade grants only the increase; downgrades take effect at the next period.
|
|
10
|
+
- Spending uses period credits, then promotional credits, then purchased credits. Reservations remove their allocation from availability immediately. Settlement charges at most the reservation and restores unused funds in reverse order. Unused expired-period credits are not restored into a later period.
|
|
11
|
+
- Reversals remove credits from their originating bucket and record any shortfall as debt. Future grants or available funds cover that debt exactly once. A reservation release also pays outstanding reversal debt before restoring spendable funds.
|
|
12
|
+
- `debit` is for already-metered usage. `allowDebt: true` is an explicit legacy/postpaid option; do not use it to authorize prepaid execution. Authorize prepaid work with reservations before an external effect.
|
|
13
|
+
- All amounts must be nonnegative safe integers. The database adapter supports PostgreSQL-compatible drivers through a parameterized SQL port and real transactions. Never implement its transaction method as independent pooled queries.
|
|
14
|
+
|
|
15
|
+
## Durable work with a maximum customer charge
|
|
16
|
+
|
|
17
|
+
Apply `creditAccountPostgresSchemaSql()` followed by `creditWorkPostgresSchemaSql()`. `createPostgresCreditWork(client)` offers:
|
|
18
|
+
|
|
19
|
+
1. `begin(accountId, workId, requestDigest, budget)` reserves the budget and atomically claims execution. Run the effect only when `fresh` is true. A retry of running work must report pending; a finished retry returns its stored result. Bind the digest to the exact tool and arguments and bind account identity outside model input.
|
|
20
|
+
2. `record(accountId, workId, eventId, credits, eventDigest)` records measured usage once. Commit the application's usage row in the same outer transaction. It returns the customer charge; any provider usage beyond the agreed budget is tracked as `absorbed` rather than customer debt. Hosts should also bound provider calls, concurrency, time, and output, and stop starting additional work when the budget is exhausted.
|
|
21
|
+
3. After all metering writes complete, `finish(accountId, workId, result, failed)` settles measured charges and releases unused credit. A failed task still charges its measured usage. Persisted result retrieval must check the authenticated account.
|
|
22
|
+
|
|
23
|
+
A process crash or unknown effect outcome deliberately leaves work running and its reservation held. Do not automatically replay it or release the hold merely because time passed. Reconcile the effect and metering before finishing it. This is an at-most-once execution claim with an explicit uncertain state, not a guarantee that an external provider executes exactly once.
|
|
24
|
+
|
|
25
|
+
## Migrating a mixed legacy balance
|
|
26
|
+
|
|
27
|
+
`migrateLegacyCreditAccount` preserves `max(0, periodAllowance + bonusCredits - consumed)` without inventing historical purchase provenance. It first allocates known current-period consumption against period allowance, carries only the residual bonus balance, and records any deficit. Legacy carryover is classified as promotional because the old mixed column cannot prove which credits were purchased. Keep the original row and its payment/usage history as immutable migration evidence. Future verified purchases go to the purchased bucket.
|
|
28
|
+
|
|
29
|
+
Dry-run all accounts, compare current availability before and after, and separately review ambiguous historical purchase allocation before changing customer balances or claiming a reconstructed purchase balance. Run under a writer drain or account-level locks; block legacy writers after cutover. Never roll back by re-enabling the old balance formula, which would restore spent permanent credits. An operational rollback should disable new paid work while keeping the new ledger and recovery reads available.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@absolutejs/billing",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Provider-neutral pricing and invoice computation used by the hosted AbsoluteJS.ai platform. Converts metered usage into exact integer-micro line items with tiers and allowances.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/absolutejs/billing.git"
|
|
@@ -32,22 +32,41 @@
|
|
|
32
32
|
"default": "./dist/ledger.js",
|
|
33
33
|
"import": "./dist/ledger.js",
|
|
34
34
|
"types": "./dist/ledger.d.ts"
|
|
35
|
+
},
|
|
36
|
+
"./prepaid": {
|
|
37
|
+
"types": "./dist/prepaid.d.ts",
|
|
38
|
+
"import": "./dist/prepaid.js",
|
|
39
|
+
"default": "./dist/prepaid.js"
|
|
40
|
+
},
|
|
41
|
+
"./prepaid-postgres": {
|
|
42
|
+
"types": "./dist/prepaidPostgres.d.ts",
|
|
43
|
+
"import": "./dist/prepaidPostgres.js",
|
|
44
|
+
"default": "./dist/prepaidPostgres.js"
|
|
45
|
+
},
|
|
46
|
+
"./credit-work": {
|
|
47
|
+
"types": "./dist/creditWork.d.ts",
|
|
48
|
+
"import": "./dist/creditWork.js",
|
|
49
|
+
"default": "./dist/creditWork.js"
|
|
35
50
|
}
|
|
36
51
|
},
|
|
37
52
|
"publishConfig": {
|
|
38
53
|
"access": "public"
|
|
39
54
|
},
|
|
40
55
|
"files": [
|
|
56
|
+
"CHANGELOG.md",
|
|
57
|
+
"README.md",
|
|
58
|
+
"changelog.json",
|
|
41
59
|
"dist",
|
|
42
|
-
"
|
|
60
|
+
"docs/prepaid-credits.md"
|
|
43
61
|
],
|
|
44
62
|
"scripts": {
|
|
45
|
-
"build": "rm -rf dist && bun build src/index.ts src/ledger.ts src/manifest.ts --root ./src --outdir dist --sourcemap --target=bun && tsc --project tsconfig.build.json && absolute-manifest emit",
|
|
63
|
+
"build": "rm -rf dist && bun build src/index.ts src/ledger.ts src/prepaid.ts src/prepaidPostgres.ts src/creditWork.ts src/manifest.ts --root ./src --outdir dist --sourcemap --target=bun && tsc --project tsconfig.build.json && absolute-manifest emit",
|
|
46
64
|
"test": "bun test tests/",
|
|
47
65
|
"typecheck": "tsc --noEmit",
|
|
48
66
|
"format": "prettier --write \"./**/*.{ts,json,md}\"",
|
|
49
|
-
"check:package": "bun run typecheck && bun run build && bun run test",
|
|
50
|
-
"release": "bun run format && bun run check:package && bun publish"
|
|
67
|
+
"check:package": "bun run typecheck && bun run build && bun run test && absolute-changelog check",
|
|
68
|
+
"release": "bun run format && bun run check:package && bun publish",
|
|
69
|
+
"prepublishOnly": "bun run check:package"
|
|
51
70
|
},
|
|
52
71
|
"keywords": [
|
|
53
72
|
"absolutejs",
|
|
@@ -59,12 +78,14 @@
|
|
|
59
78
|
"pricing"
|
|
60
79
|
],
|
|
61
80
|
"devDependencies": {
|
|
81
|
+
"@absolutejs/changelog": "^0.6.0",
|
|
62
82
|
"@types/bun": "^1.3.14",
|
|
63
83
|
"prettier": "^3.8.3",
|
|
64
|
-
"typescript": "^5.9.0"
|
|
84
|
+
"typescript": "^5.9.0",
|
|
85
|
+
"@electric-sql/pglite": "^0.3.14"
|
|
65
86
|
},
|
|
66
87
|
"dependencies": {
|
|
67
|
-
"@absolutejs/manifest": "^0.
|
|
88
|
+
"@absolutejs/manifest": "^0.9.0",
|
|
68
89
|
"@sinclair/typebox": "^0.34.0"
|
|
69
90
|
},
|
|
70
91
|
"absolutejs": {
|