@absolutejs/billing 0.5.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.d.ts +49 -0
- package/dist/ledger.js +33 -2
- package/dist/ledger.js.map +3 -3
- 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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@absolutejs/billing`.
|
|
4
|
+
|
|
5
|
+
This file is generated by `absolute-changelog` from the entries in
|
|
6
|
+
`changelog/`. Edit an entry, not this file — and add new ones under
|
|
7
|
+
`changelog/unreleased/`.
|
|
8
|
+
|
|
9
|
+
## 0.7.0 — 2026-09-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Add durable prepaid credit accounts, reservations, and capped idempotent work settlement**
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Earlier releases
|
|
18
|
+
|
|
19
|
+
# @absolutejs/billing changelog
|
|
20
|
+
|
|
21
|
+
## 0.1.0 — 2026-05-31
|
|
22
|
+
|
|
23
|
+
Initial release. Closes G13 from the second-pass PaaS audit — the
|
|
24
|
+
substrate now has a pure cost-model layer between
|
|
25
|
+
`@absolutejs/metering` and any invoicing backend.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **`createPlan({ name, basePriceMicros?, pricedDimensions,
|
|
30
|
+
currency?, rounding?, minimumChargeMicros?, metadata? })`** —
|
|
31
|
+
declarative pricing config. Validates tiered dimensions at
|
|
32
|
+
construction: non-empty, final tier must be `upTo: Infinity`,
|
|
33
|
+
bounds must be monotonically non-decreasing.
|
|
34
|
+
- **`computeInvoice({ plan, period, tenant, usage, currency? })`**
|
|
35
|
+
— pure function. Returns `{ tenant, plan, currency, period,
|
|
36
|
+
lineItems, totalMicros, totalUnits, metadata? }`.
|
|
37
|
+
- **Three pricing shapes** per dimension: flat `perUnitMicros`,
|
|
38
|
+
graduated `tiers`, or custom `price(quantity) => micros`.
|
|
39
|
+
- **`freeTier`** per dimension subtracted before pricing.
|
|
40
|
+
- **`unit`** divisor so `bytesEgress` priced as MB ↔ `unit:
|
|
41
|
+
1024*1024`.
|
|
42
|
+
- **Rounding modes**: `'truncate'` (default — sub-cent → $0.00 to
|
|
43
|
+
match operator intuition) and `'round-half-up'`.
|
|
44
|
+
- **`minimumChargeMicros`** floor with an explicit
|
|
45
|
+
`'minimum-charge-adjustment'` line item that captures the gap
|
|
46
|
+
(transparent, not magical).
|
|
47
|
+
- **`formatMicros(amount, currency, { minorUnits? })`** — pure
|
|
48
|
+
string formatter, honors zero-minor-unit currencies (JPY etc.).
|
|
49
|
+
- **All money in integer micros** (1/1,000,000 of a unit) — same
|
|
50
|
+
denomination Stripe uses internally. Float drift structurally
|
|
51
|
+
impossible.
|
|
52
|
+
|
|
53
|
+
### Design notes
|
|
54
|
+
|
|
55
|
+
- Pure functions throughout — no IO, no SDK peers, no side effects.
|
|
56
|
+
The control plane can preview invoices, re-price past periods
|
|
57
|
+
under a proposed plan, and dry-run plan changes without touching
|
|
58
|
+
any external system.
|
|
59
|
+
- Invoice push (Stripe, QuickBooks, mailed PDF) lives OUTSIDE this
|
|
60
|
+
package in `@absolutejs/billing-adapters/*`.
|
|
61
|
+
- Substrate is intentionally policy-free: it doesn't pick the plan
|
|
62
|
+
for a tenant, doesn't fetch usage, doesn't store invoices. Those
|
|
63
|
+
are control-plane concerns.
|
|
64
|
+
|
|
65
|
+
### Tests
|
|
66
|
+
|
|
67
|
+
34 covering: tier validation; flat per-unit; zero / missing /
|
|
68
|
+
negative / NaN usage; free tier (below / above / at boundary);
|
|
69
|
+
unit divisor + truncation + round-half-up; tiered (single band /
|
|
70
|
+
multi-band / unbounded final); tiered + free tier composition;
|
|
71
|
+
custom price fn; chargedQuantity passed to custom fn; base fee
|
|
72
|
+
emission + suppression-when-zero; minimum-charge top-up + no-op
|
|
73
|
+
when above floor; currency defaults + override; metadata
|
|
74
|
+
flow-through; realistic full-Usage shape; label override;
|
|
75
|
+
formatMicros (whole / sub-cent / negative / zero-minor-unit).
|
|
76
|
+
|
|
77
|
+
### License
|
|
78
|
+
|
|
79
|
+
BSL-1.1 with named carveout against hosted SaaS-billing platforms
|
|
80
|
+
(Metronome, Orb, Lago, Stripe Billing, m3ter, Chargebee). Change
|
|
81
|
+
date: 2030-05-31 (Apache 2.0).
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `@absolutejs/billing`
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Provider-neutral pricing and invoice computation used by the hosted AbsoluteJS.ai platform.
|
|
4
4
|
|
|
5
5
|
`@absolutejs/billing` is the pure-function layer between
|
|
6
6
|
`@absolutejs/metering` (which collects usage events) and an
|
|
@@ -94,3 +94,7 @@ mailed-PDF generator) lives outside this package, in
|
|
|
94
94
|
BSL-1.1 with named carveout against hosted SaaS billing platforms
|
|
95
95
|
(Metronome, Orb, Lago, Stripe Billing, m3ter, Chargebee). See
|
|
96
96
|
`LICENSE`. Change date: **2030-05-31** → Apache 2.0.
|
|
97
|
+
|
|
98
|
+
## Prepaid service credits
|
|
99
|
+
|
|
100
|
+
[Durable balances, reservations, capped work, and migration rules](docs/prepaid-credits.md) are available through the `prepaid`, `prepaid-postgres`, and `credit-work` subpaths.
|
package/changelog.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contract": 1,
|
|
3
|
+
"name": "@absolutejs/billing",
|
|
4
|
+
"releases": [
|
|
5
|
+
{
|
|
6
|
+
"changes": [
|
|
7
|
+
{
|
|
8
|
+
"kind": "added",
|
|
9
|
+
"summary": "Add durable prepaid credit accounts, reservations, and capped idempotent work settlement"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"date": "2026-09-11",
|
|
13
|
+
"version": "0.7.0"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type CreditSqlClient } from "./prepaidPostgres";
|
|
2
|
+
export type CreditWork = {
|
|
3
|
+
request: string;
|
|
4
|
+
budget: number;
|
|
5
|
+
charged: number;
|
|
6
|
+
absorbed: number;
|
|
7
|
+
status: "running" | "completed" | "failed";
|
|
8
|
+
result: string | null;
|
|
9
|
+
};
|
|
10
|
+
export declare const creditWorkPostgresSchemaSql: (schema?: string) => string;
|
|
11
|
+
/** Durable execution claim + capped customer charge. A crash leaves work running
|
|
12
|
+
* and its credits held: never automatically rerun an uncertain external effect.
|
|
13
|
+
* Provider cost over the agreed budget is recorded as absorbed, not customer debt. */
|
|
14
|
+
export declare const createPostgresCreditWork: (client: CreditSqlClient, schema?: string) => {
|
|
15
|
+
get: (accountId: string, workId: string) => Promise<CreditWork | null>;
|
|
16
|
+
begin: (accountId: string, workId: string, request: string, budget: number) => Promise<{
|
|
17
|
+
fresh: boolean;
|
|
18
|
+
work: CreditWork;
|
|
19
|
+
}>;
|
|
20
|
+
record: (accountId: string, workId: string, eventId: string, credits: number, reference: string) => Promise<{
|
|
21
|
+
charged: number;
|
|
22
|
+
fresh: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
finish: (accountId: string, workId: string, result: string, failed?: boolean) => Promise<CreditWork>;
|
|
25
|
+
};
|
|
@@ -0,0 +1,460 @@
|
|
|
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
|
+
|
|
330
|
+
// src/creditWork.ts
|
|
331
|
+
var namespace2 = (value) => {
|
|
332
|
+
if (!/^[a-z][a-z0-9_]*$/.test(value))
|
|
333
|
+
throw new Error("Invalid credit schema name");
|
|
334
|
+
return value;
|
|
335
|
+
};
|
|
336
|
+
var id = (value) => {
|
|
337
|
+
if (!value || value.length > 128)
|
|
338
|
+
throw new Error("Invalid credit work ID");
|
|
339
|
+
};
|
|
340
|
+
var integer2 = (value) => {
|
|
341
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
342
|
+
throw new Error("Invalid credit work amount");
|
|
343
|
+
};
|
|
344
|
+
var creditWorkPostgresSchemaSql = (schema = "billing_credits") => {
|
|
345
|
+
const n = namespace2(schema);
|
|
346
|
+
return `CREATE TABLE IF NOT EXISTS ${n}.work (
|
|
347
|
+
account_id text NOT NULL REFERENCES ${n}.accounts(account_id), work_id text NOT NULL,
|
|
348
|
+
state jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
|
349
|
+
PRIMARY KEY(account_id, work_id)
|
|
350
|
+
);
|
|
351
|
+
CREATE TABLE IF NOT EXISTS ${n}.work_usage (
|
|
352
|
+
account_id text NOT NULL, work_id text NOT NULL, event_id text NOT NULL,
|
|
353
|
+
credits bigint NOT NULL CHECK (credits >= 0), charged bigint NOT NULL CHECK (charged >= 0 AND charged <= credits), reference text NOT NULL,
|
|
354
|
+
PRIMARY KEY(account_id, work_id, event_id), FOREIGN KEY(account_id, work_id) REFERENCES ${n}.work(account_id, work_id)
|
|
355
|
+
);`;
|
|
356
|
+
};
|
|
357
|
+
var createPostgresCreditWork = (client, schema = "billing_credits") => {
|
|
358
|
+
const n = namespace2(schema);
|
|
359
|
+
const read = async (sql, accountId, workId) => {
|
|
360
|
+
const { rows } = await sql.query(`SELECT state FROM ${n}.work WHERE account_id = $1 AND work_id = $2 FOR UPDATE`, [accountId, workId]);
|
|
361
|
+
return rows[0]?.state;
|
|
362
|
+
};
|
|
363
|
+
const save = (sql, accountId, workId, state) => sql.query(`UPDATE ${n}.work SET state = $3::jsonb, updated_at = now() WHERE account_id = $1 AND work_id = $2`, [accountId, workId, JSON.stringify(state)]);
|
|
364
|
+
const ledger = (sql) => createCreditAccountLedger(createPostgresCreditAccountStore({ ...sql, transaction: (run) => run(sql) }, schema));
|
|
365
|
+
return {
|
|
366
|
+
get: (accountId, workId) => {
|
|
367
|
+
id(accountId);
|
|
368
|
+
id(workId);
|
|
369
|
+
return client.transaction(async (sql) => await read(sql, accountId, workId) ?? null);
|
|
370
|
+
},
|
|
371
|
+
begin: (accountId, workId, request, budget) => {
|
|
372
|
+
id(accountId);
|
|
373
|
+
id(workId);
|
|
374
|
+
integer2(budget);
|
|
375
|
+
if (!budget || !request || request.length > 256)
|
|
376
|
+
throw new Error("Invalid credit work request");
|
|
377
|
+
return client.transaction(async (sql) => {
|
|
378
|
+
await sql.query(`SELECT account_id FROM ${n}.accounts WHERE account_id = $1 FOR UPDATE`, [accountId]);
|
|
379
|
+
const existing = await read(sql, accountId, workId);
|
|
380
|
+
if (existing) {
|
|
381
|
+
if (existing.request !== request || existing.budget !== budget)
|
|
382
|
+
throw new Error("Credit work ID reused with different input");
|
|
383
|
+
return { fresh: false, work: existing };
|
|
384
|
+
}
|
|
385
|
+
await ledger(sql).execute(accountId, `work-reserve:${workId}`, {
|
|
386
|
+
kind: "reserve",
|
|
387
|
+
reservationId: `work:${workId}`,
|
|
388
|
+
credits: budget
|
|
389
|
+
});
|
|
390
|
+
const work = {
|
|
391
|
+
request,
|
|
392
|
+
budget,
|
|
393
|
+
charged: 0,
|
|
394
|
+
absorbed: 0,
|
|
395
|
+
status: "running",
|
|
396
|
+
result: null
|
|
397
|
+
};
|
|
398
|
+
await sql.query(`INSERT INTO ${n}.work (account_id, work_id, state) VALUES ($1, $2, $3::jsonb)`, [accountId, workId, JSON.stringify(work)]);
|
|
399
|
+
return { fresh: true, work };
|
|
400
|
+
});
|
|
401
|
+
},
|
|
402
|
+
record: (accountId, workId, eventId, credits, reference) => {
|
|
403
|
+
id(accountId);
|
|
404
|
+
id(workId);
|
|
405
|
+
id(eventId);
|
|
406
|
+
integer2(credits);
|
|
407
|
+
id(reference);
|
|
408
|
+
return client.transaction(async (sql) => {
|
|
409
|
+
const work = await read(sql, accountId, workId);
|
|
410
|
+
if (!work)
|
|
411
|
+
throw new Error("Credit work does not exist");
|
|
412
|
+
const { rows } = await sql.query(`SELECT credits, charged, reference FROM ${n}.work_usage WHERE account_id = $1 AND work_id = $2 AND event_id = $3`, [accountId, workId, eventId]);
|
|
413
|
+
const previous = rows[0];
|
|
414
|
+
if (previous) {
|
|
415
|
+
if (Number(previous.credits) !== credits || previous.reference !== reference)
|
|
416
|
+
throw new Error("Credit work event reused with different input");
|
|
417
|
+
return { charged: Number(previous.charged), fresh: false };
|
|
418
|
+
}
|
|
419
|
+
if (work.status !== "running")
|
|
420
|
+
throw new Error("Credit work already finished");
|
|
421
|
+
const charged = Math.min(credits, work.budget - work.charged);
|
|
422
|
+
work.charged += charged;
|
|
423
|
+
work.absorbed += credits - charged;
|
|
424
|
+
integer2(work.charged);
|
|
425
|
+
integer2(work.absorbed);
|
|
426
|
+
await sql.query(`INSERT INTO ${n}.work_usage (account_id, work_id, event_id, credits, charged, reference) VALUES ($1, $2, $3, $4, $5, $6)`, [accountId, workId, eventId, credits, charged, reference]);
|
|
427
|
+
await save(sql, accountId, workId, work);
|
|
428
|
+
return { charged, fresh: true };
|
|
429
|
+
});
|
|
430
|
+
},
|
|
431
|
+
finish: (accountId, workId, result, failed = false) => {
|
|
432
|
+
id(accountId);
|
|
433
|
+
id(workId);
|
|
434
|
+
return client.transaction(async (sql) => {
|
|
435
|
+
await sql.query(`SELECT account_id FROM ${n}.accounts WHERE account_id = $1 FOR UPDATE`, [accountId]);
|
|
436
|
+
const work = await read(sql, accountId, workId);
|
|
437
|
+
if (!work)
|
|
438
|
+
throw new Error("Credit work does not exist");
|
|
439
|
+
if (work.status !== "running")
|
|
440
|
+
return work;
|
|
441
|
+
await ledger(sql).execute(accountId, `work-settle:${workId}`, {
|
|
442
|
+
kind: "settle",
|
|
443
|
+
reservationId: `work:${workId}`,
|
|
444
|
+
credits: work.charged
|
|
445
|
+
});
|
|
446
|
+
work.status = failed ? "failed" : "completed";
|
|
447
|
+
work.result = result;
|
|
448
|
+
await save(sql, accountId, workId, work);
|
|
449
|
+
return work;
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
};
|
|
454
|
+
export {
|
|
455
|
+
createPostgresCreditWork,
|
|
456
|
+
creditWorkPostgresSchemaSql
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
//# debugId=AA6CCB44B9F64D8964756E2164756E21
|
|
460
|
+
//# sourceMappingURL=creditWork.js.map
|