@krizaka/credits 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/README.md +49 -2
- package/dist/chunk-34Y2HXOB.js +109 -0
- package/dist/chunk-34Y2HXOB.js.map +1 -0
- package/dist/chunk-WDHQ7QHY.js +56 -0
- package/dist/chunk-WDHQ7QHY.js.map +1 -0
- package/dist/credits-Do0guD2C.d.ts +258 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +524 -0
- package/dist/index.js.map +1 -0
- package/dist/port-C0Qw77Bp.d.ts +163 -0
- package/dist/settings.d.ts +18 -0
- package/dist/settings.js +23 -0
- package/dist/settings.js.map +1 -0
- package/dist/testkit.d.ts +30 -0
- package/dist/testkit.js +284 -0
- package/dist/testkit.js.map +1 -0
- package/package.json +83 -3
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { Query, Clock } from '@krizaka/db';
|
|
2
|
+
import { g as CreditsSettings, e as CreditsCode, j as GrantState, G as Grant, m as OnExhausted, E as Entry, C as CreditStore } from './port-C0Qw77Bp.js';
|
|
3
|
+
|
|
4
|
+
/** The topic the ledger's events are appended to on the outbox. */
|
|
5
|
+
declare const CREDITS_TOPIC = "credits";
|
|
6
|
+
/** Event types: the names of `krizaka-billing-api`, so a TypeScript product and a Java service speak alike. */
|
|
7
|
+
declare const CREDIT_EVENT: {
|
|
8
|
+
readonly granted: "evt.credit.granted.v1";
|
|
9
|
+
readonly exhausted: "evt.credit.exhausted.v1";
|
|
10
|
+
};
|
|
11
|
+
/** The part of `@krizaka/outbox`'s `Outbox` this package needs: structural, so `credits` does not depend on `outbox`. */
|
|
12
|
+
interface OutboxPort {
|
|
13
|
+
append(tx: Query, message: {
|
|
14
|
+
topic: string;
|
|
15
|
+
type: string;
|
|
16
|
+
payload: unknown;
|
|
17
|
+
id?: string;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
id: string;
|
|
20
|
+
appended: boolean;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
/** A stable UUID (version 5 shaped) from a seed: announcing the same fact twice is one outbox message. */
|
|
24
|
+
declare function uuidOf(seed: string): string;
|
|
25
|
+
/** The JSON Schema of each event's payload (a consumer validates against it; the Java twin publishes the same names). */
|
|
26
|
+
declare const CREDIT_EVENT_SCHEMAS: {
|
|
27
|
+
readonly "evt.credit.granted.v1": {
|
|
28
|
+
readonly required: readonly ["accountId", "grantId", "bucket", "amount", "source", "onExhausted"];
|
|
29
|
+
readonly properties: {
|
|
30
|
+
readonly accountId: {
|
|
31
|
+
readonly type: "string";
|
|
32
|
+
};
|
|
33
|
+
readonly grantId: {
|
|
34
|
+
readonly type: "string";
|
|
35
|
+
};
|
|
36
|
+
readonly bucket: {
|
|
37
|
+
readonly enum: readonly ["granted", "purchased"];
|
|
38
|
+
};
|
|
39
|
+
readonly amount: {
|
|
40
|
+
readonly type: "integer";
|
|
41
|
+
readonly minimum: 1;
|
|
42
|
+
};
|
|
43
|
+
readonly expiresAt: {
|
|
44
|
+
readonly type: readonly ["string", "null"];
|
|
45
|
+
readonly format: "date-time";
|
|
46
|
+
};
|
|
47
|
+
readonly capPerDay: {
|
|
48
|
+
readonly type: readonly ["integer", "null"];
|
|
49
|
+
readonly minimum: 1;
|
|
50
|
+
};
|
|
51
|
+
readonly capPerMonth: {
|
|
52
|
+
readonly type: readonly ["integer", "null"];
|
|
53
|
+
readonly minimum: 1;
|
|
54
|
+
};
|
|
55
|
+
readonly onExhausted: {
|
|
56
|
+
readonly enum: readonly ["stop", "fallback_paid"];
|
|
57
|
+
};
|
|
58
|
+
readonly source: {
|
|
59
|
+
readonly type: "string";
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
readonly type: "object";
|
|
63
|
+
readonly additionalProperties: false;
|
|
64
|
+
};
|
|
65
|
+
readonly "evt.credit.exhausted.v1": {
|
|
66
|
+
readonly required: readonly ["accountId", "grantId", "bucket", "source", "onExhausted"];
|
|
67
|
+
readonly properties: {
|
|
68
|
+
readonly accountId: {
|
|
69
|
+
readonly type: "string";
|
|
70
|
+
};
|
|
71
|
+
readonly grantId: {
|
|
72
|
+
readonly type: "string";
|
|
73
|
+
};
|
|
74
|
+
readonly bucket: {
|
|
75
|
+
readonly enum: readonly ["granted", "purchased"];
|
|
76
|
+
};
|
|
77
|
+
readonly onExhausted: {
|
|
78
|
+
readonly enum: readonly ["stop", "fallback_paid"];
|
|
79
|
+
};
|
|
80
|
+
readonly source: {
|
|
81
|
+
readonly type: "string";
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
readonly type: "object";
|
|
85
|
+
readonly additionalProperties: false;
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** How much of one grant a consumption takes. */
|
|
90
|
+
interface Draw {
|
|
91
|
+
grantId: string;
|
|
92
|
+
amount: number;
|
|
93
|
+
}
|
|
94
|
+
type Plan = {
|
|
95
|
+
ok: true;
|
|
96
|
+
draws: Draw[];
|
|
97
|
+
} | {
|
|
98
|
+
ok: false;
|
|
99
|
+
code: CreditsCode;
|
|
100
|
+
};
|
|
101
|
+
/** The defaults and bounds of the settings (the admin changes them without a redeploy). */
|
|
102
|
+
declare const CREDITS_DEFAULTS: CreditsSettings;
|
|
103
|
+
declare const HOLD_TTL_BOUNDS: readonly [30, 86400];
|
|
104
|
+
/**
|
|
105
|
+
* Plans where `need` credits come from, or says why they cannot. Pure: the same states give the same plan.
|
|
106
|
+
*
|
|
107
|
+
* Granted credits go first, the pot that lapses first first, each within its caps of the day and the month. If the granted pots cannot
|
|
108
|
+
* give it all: a pot held back by a cap answers `CREDITS_CAP_DAY` / `CREDITS_CAP_MONTH` (the cap is a fair-use limit: purchased
|
|
109
|
+
* credits do not bypass it); else a `stop` pot that is empty answers `CREDITS_EXHAUSTED`; else the purchased credits give the rest, and
|
|
110
|
+
* when they cannot, `PAYWALL`. Nothing is partial: the plan covers the whole amount or refuses.
|
|
111
|
+
* @param states every grant of the account with what is left (`CreditStore.grantStates`)
|
|
112
|
+
* @param need credits wanted, a positive integer
|
|
113
|
+
* @param now the injected clock's instant
|
|
114
|
+
*/
|
|
115
|
+
declare function planDraws(states: readonly GrantState[], need: number, now: Date): Plan;
|
|
116
|
+
/** Drops the first `skip` credits of a plan (the ones already held back by active holds), keeping the order. */
|
|
117
|
+
declare function skipFirst(draws: readonly Draw[], skip: number): Draw[];
|
|
118
|
+
|
|
119
|
+
interface CreditsDeps {
|
|
120
|
+
clock: Clock;
|
|
121
|
+
/** The settings, read at each call so the admin's change applies at once: an object, or a function over the settings service. */
|
|
122
|
+
settings: CreditsSettings | (() => CreditsSettings | Promise<CreditsSettings>);
|
|
123
|
+
/** The store over a query (a pool or a transaction). Default: the PostgreSQL store on schema `credits`. */
|
|
124
|
+
storeFor?: (query: Query) => CreditStore;
|
|
125
|
+
/** The Postgres schema, when the product renamed the module's (used by the default store). */
|
|
126
|
+
schema?: string;
|
|
127
|
+
/** Announces `evt.credit.granted.v1` and `evt.credit.exhausted.v1` in the caller's transaction (`createOutbox(...)` of `@krizaka/outbox`). */
|
|
128
|
+
outbox?: OutboxPort;
|
|
129
|
+
/** Fresh ids; default random UUIDs. */
|
|
130
|
+
newId?: () => string;
|
|
131
|
+
}
|
|
132
|
+
interface GrantInput {
|
|
133
|
+
accountId: string;
|
|
134
|
+
/** Credits, a positive integer. */
|
|
135
|
+
amount: number;
|
|
136
|
+
/** When the pot lapses. Required, in the future. */
|
|
137
|
+
expiresAt: Date;
|
|
138
|
+
capPerDay?: number | null;
|
|
139
|
+
capPerMonth?: number | null;
|
|
140
|
+
onExhausted: OnExhausted;
|
|
141
|
+
/** `coupon:<redemptionId>`, `plan:monthly`… */
|
|
142
|
+
source: string;
|
|
143
|
+
/** One key per grant: a replay answers the same grant and writes nothing. */
|
|
144
|
+
idempotencyKey: string;
|
|
145
|
+
}
|
|
146
|
+
interface PurchaseInput {
|
|
147
|
+
accountId: string;
|
|
148
|
+
amount: number;
|
|
149
|
+
/** `purchase:<paymentRef>`. */
|
|
150
|
+
source: string;
|
|
151
|
+
idempotencyKey: string;
|
|
152
|
+
}
|
|
153
|
+
interface ConsumeInput {
|
|
154
|
+
accountId: string;
|
|
155
|
+
amount: number;
|
|
156
|
+
/** What it was spent on (`audit:<id>`): kept on the line. */
|
|
157
|
+
reason: string;
|
|
158
|
+
idempotencyKey: string;
|
|
159
|
+
}
|
|
160
|
+
interface Consumed {
|
|
161
|
+
draws: Draw[];
|
|
162
|
+
/** True when the key was seen before: the first answer is repeated and nothing was written. */
|
|
163
|
+
replayed: boolean;
|
|
164
|
+
}
|
|
165
|
+
interface Balance {
|
|
166
|
+
/** What can be spent now (not lapsed, not closed), holds not subtracted. */
|
|
167
|
+
total: number;
|
|
168
|
+
granted: number;
|
|
169
|
+
purchased: number;
|
|
170
|
+
/** Kept back by active holds. */
|
|
171
|
+
held: number;
|
|
172
|
+
/** `total - held`, never negative. */
|
|
173
|
+
available: number;
|
|
174
|
+
grants: {
|
|
175
|
+
id: string;
|
|
176
|
+
bucket: Grant["bucket"];
|
|
177
|
+
remaining: number;
|
|
178
|
+
expiresAt: Date | null;
|
|
179
|
+
source: string;
|
|
180
|
+
capPerDay: number | null;
|
|
181
|
+
capPerMonth: number | null;
|
|
182
|
+
onExhausted: OnExhausted;
|
|
183
|
+
}[];
|
|
184
|
+
}
|
|
185
|
+
/** The ledger as a product uses it. Every function takes the `Query` it works through: pass `tx` to join a unit of work. */
|
|
186
|
+
interface Credits {
|
|
187
|
+
/** Free usage that lapses, with optional caps. Idempotent by key. */
|
|
188
|
+
grant(tx: Query, input: GrantInput): Promise<{
|
|
189
|
+
grantId: string;
|
|
190
|
+
replayed: boolean;
|
|
191
|
+
}>;
|
|
192
|
+
/** Paid usage: it does not lapse and has no cap. Idempotent by key. */
|
|
193
|
+
purchase(tx: Query, input: PurchaseInput): Promise<{
|
|
194
|
+
grantId: string;
|
|
195
|
+
replayed: boolean;
|
|
196
|
+
}>;
|
|
197
|
+
/** Spends credits from the pots that lapse first, within their caps; refuses with `CREDITS_CAP_DAY`, `CREDITS_CAP_MONTH`, `CREDITS_EXHAUSTED` or `PAYWALL`. Nothing is partial. */
|
|
198
|
+
consume(tx: Query, input: ConsumeInput): Promise<Consumed>;
|
|
199
|
+
/** Keeps credits back for work that has not finished; the reserve counts against caps and balance. */
|
|
200
|
+
hold(tx: Query, input: {
|
|
201
|
+
accountId: string;
|
|
202
|
+
amount: number;
|
|
203
|
+
ttlSeconds?: number;
|
|
204
|
+
idempotencyKey: string;
|
|
205
|
+
}): Promise<{
|
|
206
|
+
holdId: string;
|
|
207
|
+
expiresAt: Date;
|
|
208
|
+
replayed: boolean;
|
|
209
|
+
}>;
|
|
210
|
+
/** Spends what the work cost (at most the hold's amount; default all of it) and frees the rest. Once. */
|
|
211
|
+
settle(tx: Query, holdId: string, options?: {
|
|
212
|
+
amount?: number;
|
|
213
|
+
reason?: string;
|
|
214
|
+
}): Promise<Consumed>;
|
|
215
|
+
/** Frees a hold without spending. Idempotent. */
|
|
216
|
+
release(tx: Query, holdId: string): Promise<void>;
|
|
217
|
+
/** Gives back what an earlier consume or settle spent, to the same pots, once. */
|
|
218
|
+
refund(tx: Query, input: {
|
|
219
|
+
accountId: string;
|
|
220
|
+
consumed?: string;
|
|
221
|
+
settledHold?: string;
|
|
222
|
+
reason: string;
|
|
223
|
+
}): Promise<{
|
|
224
|
+
refunded: number;
|
|
225
|
+
replayed: boolean;
|
|
226
|
+
}>;
|
|
227
|
+
/** Closes a pot: what is left is written off by an EXPIRY line. Credits already spent are never taken back. Idempotent. */
|
|
228
|
+
expire(tx: Query, grantId: string): Promise<{
|
|
229
|
+
expired: number;
|
|
230
|
+
}>;
|
|
231
|
+
/** Closes the granted pots past their date that still hold credits (a job's sweep). */
|
|
232
|
+
expireDue(tx: Query, options?: {
|
|
233
|
+
limit?: number;
|
|
234
|
+
}): Promise<number>;
|
|
235
|
+
/** A signed correction by an administrator, never taking a pot below zero. */
|
|
236
|
+
adjust(tx: Query, input: {
|
|
237
|
+
accountId: string;
|
|
238
|
+
grantId: string;
|
|
239
|
+
amount: number;
|
|
240
|
+
reason: string;
|
|
241
|
+
idempotencyKey: string;
|
|
242
|
+
}): Promise<{
|
|
243
|
+
replayed: boolean;
|
|
244
|
+
}>;
|
|
245
|
+
balance(query: Query, accountId: string): Promise<Balance>;
|
|
246
|
+
history(query: Query, accountId: string, options?: {
|
|
247
|
+
limit?: number;
|
|
248
|
+
before?: Date;
|
|
249
|
+
}): Promise<Entry[]>;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The credits ledger of a product.
|
|
253
|
+
* @param deps the clock, the settings, and optionally the store, the schema, the outbox and the id source
|
|
254
|
+
* @returns the ledger's functions
|
|
255
|
+
*/
|
|
256
|
+
declare function createCredits(deps: CreditsDeps): Credits;
|
|
257
|
+
|
|
258
|
+
export { type Balance as B, CREDITS_DEFAULTS as C, type Draw as D, type GrantInput as G, HOLD_TTL_BOUNDS as H, type OutboxPort as O, type Plan as P, CREDITS_TOPIC as a, CREDIT_EVENT as b, CREDIT_EVENT_SCHEMAS as c, type ConsumeInput as d, type Consumed as e, type Credits as f, type CreditsDeps as g, type PurchaseInput as h, createCredits as i, planDraws as p, skipFirst as s, uuidOf as u };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export { B as Balance, C as CREDITS_DEFAULTS, a as CREDITS_TOPIC, b as CREDIT_EVENT, c as CREDIT_EVENT_SCHEMAS, d as ConsumeInput, e as Consumed, f as Credits, g as CreditsDeps, D as Draw, G as GrantInput, H as HOLD_TTL_BOUNDS, O as OutboxPort, P as Plan, h as PurchaseInput, i as createCredits, p as planDraws, s as skipFirst, u as uuidOf } from './credits-Do0guD2C.js';
|
|
2
|
+
import { C as CreditStore, E as Entry } from './port-C0Qw77Bp.js';
|
|
3
|
+
export { B as BUCKET, a as Bucket, b as CREDITS_CODE, c as CREDITS_STATUS, d as Calendar, e as CreditsCode, f as CreditsError, g as CreditsSettings, h as ENTRY_TYPE, i as EntryType, G as Grant, j as GrantState, H as HOLD_STATUS, k as Hold, l as HoldStatus, O as ON_EXHAUSTED, m as OnExhausted } from './port-C0Qw77Bp.js';
|
|
4
|
+
import * as _krizaka_db from '@krizaka/db';
|
|
5
|
+
import { Query } from '@krizaka/db';
|
|
6
|
+
export { Clock, Query } from '@krizaka/db';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An in-memory ledger, for unit tests and demos: the same behaviour as the database's (the contract kit runs on both) except that
|
|
10
|
+
* `lock` does nothing, because it has no transaction to hold it in. It is for one flow at a time; concurrency is proved on PostgreSQL.
|
|
11
|
+
*/
|
|
12
|
+
declare function memoryCreditStore(): CreditStore & {
|
|
13
|
+
entries(): readonly Readonly<Entry>[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The PostgreSQL store of the ledger, in schema `credits` (see `creditsSchema`), over the `Query` it is given: the caller's transaction
|
|
18
|
+
* for every writing call, so the lines commit or roll back with the product's work. `lock` is `pg_advisory_xact_lock` on the account:
|
|
19
|
+
* two requests spending the last credit take turns, and the second sees the first's lines. Days and months are cut in the calendar's
|
|
20
|
+
* IANA zone BY POSTGRES (`AT TIME ZONE`), so daylight-saving shifts are the database's own tz data. Times come from the caller.
|
|
21
|
+
* @param schema the Postgres schema of the module, default `credits`
|
|
22
|
+
*/
|
|
23
|
+
declare function dbCreditStore(query: Query, schema?: string): CreditStore;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The tables of « credits », in their own Postgres schema (ADR-001 §3), following `krizaka-billing`'s model and names: `grant` (a pot:
|
|
27
|
+
* `granted` with an expiry and optional caps, or `purchased`), `entry` (the ledger: GRANT, PURCHASE, DEBIT, REFUND, EXPIRY, ADJUSTMENT;
|
|
28
|
+
* APPEND-ONLY, a trigger refuses any UPDATE or DELETE) and `hold` (credits kept back for work in progress). Nothing stores a balance: what is
|
|
29
|
+
* left of a pot is the sum of its lines. The one product table referenced is the owner (`owner`: the account table), declared by the product;
|
|
30
|
+
* an account that has credits is anonymised, never deleted (it is an accounting record), exactly like an invoice.
|
|
31
|
+
* Version 1 is frozen once released: a change is version 2.
|
|
32
|
+
*/
|
|
33
|
+
declare const creditsSchema: _krizaka_db.ModuleSchema;
|
|
34
|
+
|
|
35
|
+
export { CreditStore, Entry, creditsSchema, dbCreditStore, memoryCreditStore };
|