@krizaka/credits 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-34Y2HXOB.js → chunk-FPQSMNV2.js} +33 -13
- package/dist/chunk-FPQSMNV2.js.map +1 -0
- package/dist/{chunk-WDHQ7QHY.js → chunk-NKDQNDSD.js} +9 -2
- package/dist/chunk-NKDQNDSD.js.map +1 -0
- package/dist/index.js +270 -40
- package/dist/index.js.map +1 -1
- package/dist/settings.js +11 -4
- package/dist/settings.js.map +1 -1
- package/dist/testkit.js +116 -21
- package/dist/testkit.js.map +1 -1
- package/package.json +4 -4
- package/dist/chunk-34Y2HXOB.js.map +0 -1
- package/dist/chunk-WDHQ7QHY.js.map +0 -1
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
CREDITS_CODE,
|
|
4
4
|
CreditsError,
|
|
5
5
|
ON_EXHAUSTED
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-NKDQNDSD.js";
|
|
7
7
|
|
|
8
8
|
// src/rules.ts
|
|
9
9
|
import { isAfter } from "@krizaka/time";
|
|
@@ -20,7 +20,8 @@ function drawGranted(granted, need) {
|
|
|
20
20
|
const dayLeft = s.grant.capPerDay === null ? Infinity : Math.max(0, s.grant.capPerDay - s.usedToday);
|
|
21
21
|
const monthLeft = s.grant.capPerMonth === null ? Infinity : Math.max(0, s.grant.capPerMonth - s.usedThisMonth);
|
|
22
22
|
const take = Math.min(left, s.remaining, dayLeft, monthLeft);
|
|
23
|
-
if (take < Math.min(left, s.remaining))
|
|
23
|
+
if (take < Math.min(left, s.remaining))
|
|
24
|
+
capped ??= dayLeft <= monthLeft ? CREDITS_CODE.capDay : CREDITS_CODE.capMonth;
|
|
24
25
|
if (take > 0) {
|
|
25
26
|
draws.push({ grantId: s.grant.id, amount: take });
|
|
26
27
|
left -= take;
|
|
@@ -31,7 +32,9 @@ function drawGranted(granted, need) {
|
|
|
31
32
|
function drawPurchased(purchased, need) {
|
|
32
33
|
const draws = [];
|
|
33
34
|
let left = need;
|
|
34
|
-
const oldestFirst = [...purchased].sort(
|
|
35
|
+
const oldestFirst = [...purchased].sort(
|
|
36
|
+
(a, b) => a.grant.createdAt.valueOf() - b.grant.createdAt.valueOf() || a.grant.id.localeCompare(b.grant.id)
|
|
37
|
+
);
|
|
35
38
|
for (const s of oldestFirst) {
|
|
36
39
|
if (left === 0) break;
|
|
37
40
|
const take = Math.min(left, s.remaining);
|
|
@@ -47,9 +50,14 @@ function planDraws(states, need, now) {
|
|
|
47
50
|
if (first.left === 0) return { ok: true, draws: first.draws };
|
|
48
51
|
if (first.capped) return { ok: false, code: first.capped };
|
|
49
52
|
const taken = new Map(first.draws.map((d) => [d.grantId, d.amount]));
|
|
50
|
-
const stopped = live.some(
|
|
53
|
+
const stopped = live.some(
|
|
54
|
+
(s) => s.grant.bucket === BUCKET.granted && s.grant.onExhausted === ON_EXHAUSTED.stop && s.remaining - (taken.get(s.grant.id) ?? 0) <= 0
|
|
55
|
+
);
|
|
51
56
|
if (stopped) return { ok: false, code: CREDITS_CODE.exhausted };
|
|
52
|
-
const rest = drawPurchased(
|
|
57
|
+
const rest = drawPurchased(
|
|
58
|
+
live.filter((s) => s.grant.bucket === BUCKET.purchased && s.remaining > 0),
|
|
59
|
+
first.left
|
|
60
|
+
);
|
|
53
61
|
return rest.left === 0 ? { ok: true, draws: [...first.draws, ...rest.draws] } : { ok: false, code: CREDITS_CODE.paywall };
|
|
54
62
|
}
|
|
55
63
|
function skipFirst(draws, skip) {
|
|
@@ -63,12 +71,17 @@ function skipFirst(draws, skip) {
|
|
|
63
71
|
return out;
|
|
64
72
|
}
|
|
65
73
|
function checkAmount(n, what = "an amount") {
|
|
66
|
-
if (!Number.isSafeInteger(n) || n <= 0)
|
|
74
|
+
if (!Number.isSafeInteger(n) || n <= 0)
|
|
75
|
+
throw new CreditsError(CREDITS_CODE.badAmount, `${what} of credits is a positive integer, got ${n}`);
|
|
67
76
|
return n;
|
|
68
77
|
}
|
|
69
78
|
var KEY = /^[\x21-\x7e]{1,150}$/;
|
|
70
79
|
function checkKey(key) {
|
|
71
|
-
if (!KEY.test(key) || key.includes(":"))
|
|
80
|
+
if (!KEY.test(key) || key.includes(":"))
|
|
81
|
+
throw new CreditsError(
|
|
82
|
+
CREDITS_CODE.badKey,
|
|
83
|
+
"An idempotency key is 1 to 150 printable characters, without spaces or a colon"
|
|
84
|
+
);
|
|
72
85
|
return key;
|
|
73
86
|
}
|
|
74
87
|
function checkSettings(s) {
|
|
@@ -78,21 +91,28 @@ function checkSettings(s) {
|
|
|
78
91
|
throw new CreditsError(CREDITS_CODE.badSettings, `Not an IANA time zone: ${s.timeZone}`);
|
|
79
92
|
}
|
|
80
93
|
if (!Number.isSafeInteger(s.holdTtlSeconds) || s.holdTtlSeconds < HOLD_TTL_BOUNDS[0] || s.holdTtlSeconds > HOLD_TTL_BOUNDS[1]) {
|
|
81
|
-
throw new CreditsError(
|
|
94
|
+
throw new CreditsError(
|
|
95
|
+
CREDITS_CODE.badSettings,
|
|
96
|
+
`holdTtlSeconds is ${HOLD_TTL_BOUNDS[0]} to ${HOLD_TTL_BOUNDS[1]}`
|
|
97
|
+
);
|
|
82
98
|
}
|
|
83
99
|
return s;
|
|
84
100
|
}
|
|
85
101
|
var MAX_SOURCE = 200;
|
|
86
102
|
function checkGrantedTerms(input, now) {
|
|
87
|
-
if (!(input.expiresAt instanceof Date) || Number.isNaN(input.expiresAt.valueOf()) || input.expiresAt <= now)
|
|
88
|
-
|
|
89
|
-
|
|
103
|
+
if (!(input.expiresAt instanceof Date) || Number.isNaN(input.expiresAt.valueOf()) || input.expiresAt <= now)
|
|
104
|
+
throw new CreditsError(CREDITS_CODE.badGrant, "A granted pot lapses in the future");
|
|
105
|
+
for (const cap of [input.capPerDay, input.capPerMonth])
|
|
106
|
+
if (cap !== void 0 && cap !== null) checkAmount(cap, "a cap");
|
|
107
|
+
if (input.onExhausted !== ON_EXHAUSTED.stop && input.onExhausted !== ON_EXHAUSTED.fallbackPaid)
|
|
108
|
+
throw new CreditsError(CREDITS_CODE.badGrant, "onExhausted is stop or fallback_paid");
|
|
90
109
|
}
|
|
91
110
|
function checkNewGrant(kind, input, now) {
|
|
92
111
|
checkAmount(input.amount, "a grant");
|
|
93
112
|
checkKey(input.idempotencyKey);
|
|
94
113
|
if (!input.accountId) throw new CreditsError(CREDITS_CODE.badGrant, "A grant has an account");
|
|
95
|
-
if (!input.source || input.source.length > MAX_SOURCE)
|
|
114
|
+
if (!input.source || input.source.length > MAX_SOURCE)
|
|
115
|
+
throw new CreditsError(CREDITS_CODE.badGrant, `A source is 1 to ${MAX_SOURCE} characters`);
|
|
96
116
|
if (kind === "grant") checkGrantedTerms(input, now);
|
|
97
117
|
}
|
|
98
118
|
|
|
@@ -106,4 +126,4 @@ export {
|
|
|
106
126
|
checkSettings,
|
|
107
127
|
checkNewGrant
|
|
108
128
|
};
|
|
109
|
-
//# sourceMappingURL=chunk-
|
|
129
|
+
//# sourceMappingURL=chunk-FPQSMNV2.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/rules.ts"],"sourcesContent":["import { isAfter } from \"@krizaka/time\";\n\nimport {\n BUCKET,\n CREDITS_CODE,\n type CreditsCode,\n CreditsError,\n type CreditsSettings,\n type GrantState,\n ON_EXHAUSTED,\n} from \"./port\";\n\n/** How much of one grant a consumption takes. */\nexport interface Draw {\n grantId: string;\n amount: number;\n}\n\nexport type Plan = { ok: true; draws: Draw[] } | { ok: false; code: CreditsCode };\n\n/** The defaults and bounds of the settings (the admin changes them without a redeploy). */\nexport const CREDITS_DEFAULTS: CreditsSettings = { timeZone: \"America/Toronto\", holdTtlSeconds: 900 };\n// @fixed: bounds on the lifetime of a hold, in seconds: 30 seconds to a day (a sanity range, not a business parameter)\nexport const HOLD_TTL_BOUNDS = [30, 86_400] as const;\n\nconst alive = (s: GrantState, now: Date) =>\n !s.closed && (s.grant.expiresAt === null || isAfter(s.grant.expiresAt, now));\nconst byExpiry = (a: GrantState, b: GrantState) =>\n (a.grant.expiresAt?.valueOf() ?? Infinity) - (b.grant.expiresAt?.valueOf() ?? Infinity) ||\n a.grant.createdAt.valueOf() - b.grant.createdAt.valueOf() ||\n a.grant.id.localeCompare(b.grant.id);\n\n/** What the granted pots can give, each within its caps, and the first cap that held one back. */\nfunction drawGranted(\n granted: readonly GrantState[],\n need: number,\n): { draws: Draw[]; left: number; capped: CreditsCode | null } {\n const draws: Draw[] = [];\n let left = need;\n let capped: CreditsCode | null = null;\n for (const s of granted) {\n if (left === 0) break;\n const dayLeft = s.grant.capPerDay === null ? Infinity : Math.max(0, s.grant.capPerDay - s.usedToday);\n const monthLeft = s.grant.capPerMonth === null ? Infinity : Math.max(0, s.grant.capPerMonth - s.usedThisMonth);\n const take = Math.min(left, s.remaining, dayLeft, monthLeft);\n if (take < Math.min(left, s.remaining))\n capped ??= dayLeft <= monthLeft ? CREDITS_CODE.capDay : CREDITS_CODE.capMonth;\n if (take > 0) {\n draws.push({ grantId: s.grant.id, amount: take });\n left -= take;\n }\n }\n return { draws, left, capped };\n}\n\n/** What the purchased pots can give, the oldest first. */\nfunction drawPurchased(purchased: readonly GrantState[], need: number): { draws: Draw[]; left: number } {\n const draws: Draw[] = [];\n let left = need;\n const oldestFirst = [...purchased].sort(\n (a, b) => a.grant.createdAt.valueOf() - b.grant.createdAt.valueOf() || a.grant.id.localeCompare(b.grant.id),\n );\n for (const s of oldestFirst) {\n if (left === 0) break;\n const take = Math.min(left, s.remaining);\n draws.push({ grantId: s.grant.id, amount: take });\n left -= take;\n }\n return { draws, left };\n}\n\n/**\n * Plans where `need` credits come from, or says why they cannot. Pure: the same states give the same plan.\n *\n * 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\n * 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\n * credits do not bypass it); else a `stop` pot that is empty answers `CREDITS_EXHAUSTED`; else the purchased credits give the rest, and\n * when they cannot, `PAYWALL`. Nothing is partial: the plan covers the whole amount or refuses.\n * @param states every grant of the account with what is left (`CreditStore.grantStates`)\n * @param need credits wanted, a positive integer\n * @param now the injected clock's instant\n */\nexport function planDraws(states: readonly GrantState[], need: number, now: Date): Plan {\n const live = states.filter((s) => alive(s, now));\n const granted = live.filter((s) => s.grant.bucket === BUCKET.granted && s.remaining > 0).sort(byExpiry);\n const first = drawGranted(granted, need);\n if (first.left === 0) return { ok: true, draws: first.draws };\n if (first.capped) return { ok: false, code: first.capped };\n const taken = new Map(first.draws.map((d) => [d.grantId, d.amount]));\n const stopped = live.some(\n (s) =>\n s.grant.bucket === BUCKET.granted &&\n s.grant.onExhausted === ON_EXHAUSTED.stop &&\n s.remaining - (taken.get(s.grant.id) ?? 0) <= 0,\n );\n if (stopped) return { ok: false, code: CREDITS_CODE.exhausted };\n const rest = drawPurchased(\n live.filter((s) => s.grant.bucket === BUCKET.purchased && s.remaining > 0),\n first.left,\n );\n return rest.left === 0\n ? { ok: true, draws: [...first.draws, ...rest.draws] }\n : { ok: false, code: CREDITS_CODE.paywall };\n}\n\n/** Drops the first `skip` credits of a plan (the ones already held back by active holds), keeping the order. */\nexport function skipFirst(draws: readonly Draw[], skip: number): Draw[] {\n let toSkip = skip;\n const out: Draw[] = [];\n for (const d of draws) {\n const dropped = Math.min(toSkip, d.amount);\n toSkip -= dropped;\n if (d.amount > dropped) out.push({ grantId: d.grantId, amount: d.amount - dropped });\n }\n return out;\n}\n\n/** Whether `n` is an amount of credits: a positive safe integer. @throws CreditsError `CREDITS_BAD_AMOUNT` */\nexport function checkAmount(n: number, what = \"an amount\"): number {\n if (!Number.isSafeInteger(n) || n <= 0)\n throw new CreditsError(CREDITS_CODE.badAmount, `${what} of credits is a positive integer, got ${n}`);\n return n;\n}\n\nconst KEY = /^[\\x21-\\x7e]{1,150}$/;\n/** An idempotency key: printable ASCII without spaces, 1 to 150 characters. @throws CreditsError `CREDITS_BAD_KEY` */\nexport function checkKey(key: string): string {\n if (!KEY.test(key) || key.includes(\":\"))\n throw new CreditsError(\n CREDITS_CODE.badKey,\n \"An idempotency key is 1 to 150 printable characters, without spaces or a colon\",\n );\n return key;\n}\n\n/** Checks the settings the service was given. @throws CreditsError `CREDITS_BAD_SETTINGS` */\nexport function checkSettings(s: CreditsSettings): CreditsSettings {\n try {\n new Intl.DateTimeFormat(\"en\", { timeZone: s.timeZone });\n } catch {\n throw new CreditsError(CREDITS_CODE.badSettings, `Not an IANA time zone: ${s.timeZone}`);\n }\n if (\n !Number.isSafeInteger(s.holdTtlSeconds) ||\n s.holdTtlSeconds < HOLD_TTL_BOUNDS[0] ||\n s.holdTtlSeconds > HOLD_TTL_BOUNDS[1]\n ) {\n throw new CreditsError(\n CREDITS_CODE.badSettings,\n `holdTtlSeconds is ${HOLD_TTL_BOUNDS[0]} to ${HOLD_TTL_BOUNDS[1]}`,\n );\n }\n return s;\n}\n\n// @fixed: the longest source a line of the ledger carries (its CHECK)\nconst MAX_SOURCE = 200;\n\n/** The shape of what `grant` and `purchase` take, for the checks. */\ninterface NewGrantInput {\n accountId: string;\n amount: number;\n source: string;\n idempotencyKey: string;\n expiresAt?: Date;\n capPerDay?: number | null;\n capPerMonth?: number | null;\n onExhausted?: string;\n}\n\n/** The checks of a granted pot's own terms: it lapses in the future, its caps are whole numbers, it says what happens when it runs out. */\nfunction checkGrantedTerms(input: NewGrantInput, now: Date): void {\n if (!(input.expiresAt instanceof Date) || Number.isNaN(input.expiresAt.valueOf()) || input.expiresAt <= now)\n throw new CreditsError(CREDITS_CODE.badGrant, \"A granted pot lapses in the future\");\n for (const cap of [input.capPerDay, input.capPerMonth])\n if (cap !== undefined && cap !== null) checkAmount(cap, \"a cap\");\n if (input.onExhausted !== ON_EXHAUSTED.stop && input.onExhausted !== ON_EXHAUSTED.fallbackPaid)\n throw new CreditsError(CREDITS_CODE.badGrant, \"onExhausted is stop or fallback_paid\");\n}\n\n/**\n * Checks what `grant` or `purchase` was given, before any store call.\n * @throws CreditsError `CREDITS_BAD_AMOUNT`, `CREDITS_BAD_KEY` or `CREDITS_BAD_GRANT`\n */\nexport function checkNewGrant(kind: \"grant\" | \"purchase\", input: NewGrantInput, now: Date): void {\n checkAmount(input.amount, \"a grant\");\n checkKey(input.idempotencyKey);\n if (!input.accountId) throw new CreditsError(CREDITS_CODE.badGrant, \"A grant has an account\");\n if (!input.source || input.source.length > MAX_SOURCE)\n throw new CreditsError(CREDITS_CODE.badGrant, `A source is 1 to ${MAX_SOURCE} characters`);\n if (kind === \"grant\") checkGrantedTerms(input, now);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// src/port.ts
|
|
2
2
|
var BUCKET = { granted: "granted", purchased: "purchased" };
|
|
3
3
|
var ON_EXHAUSTED = { stop: "stop", fallbackPaid: "fallback_paid" };
|
|
4
|
-
var ENTRY_TYPE = {
|
|
4
|
+
var ENTRY_TYPE = {
|
|
5
|
+
grant: "GRANT",
|
|
6
|
+
purchase: "PURCHASE",
|
|
7
|
+
debit: "DEBIT",
|
|
8
|
+
refund: "REFUND",
|
|
9
|
+
expiry: "EXPIRY",
|
|
10
|
+
adjustment: "ADJUSTMENT"
|
|
11
|
+
};
|
|
5
12
|
var HOLD_STATUS = { active: "active", settled: "settled", released: "released" };
|
|
6
13
|
var CREDITS_CODE = {
|
|
7
14
|
capDay: "CREDITS_CAP_DAY",
|
|
@@ -53,4 +60,4 @@ export {
|
|
|
53
60
|
CREDITS_STATUS,
|
|
54
61
|
CreditsError
|
|
55
62
|
};
|
|
56
|
-
//# sourceMappingURL=chunk-
|
|
63
|
+
//# sourceMappingURL=chunk-NKDQNDSD.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/port.ts"],"sourcesContent":["/**\n * The ports of « credits »: ONE ledger of usage credits per account. Generic: no product import, no database client (a `Query`\n * comes in), no wall clock (a `Clock` comes in). Credits are integers (never floats). The ledger is append-only and the balance is\n * DERIVED from it: nothing stores a balance that could drift.\n *\n * Two kinds of credit live in it. A GRANT is free usage (a coupon, a monthly allowance, a demo): it expires, may be capped per day or\n * month, and says what happens when it runs out (`stop`, or `fallback_paid`: go on with what the person bought). A PURCHASE is paid\n * usage: it does not expire and is never capped.\n */\nexport type { Clock, Query } from \"@krizaka/db\";\n\nexport const BUCKET = { granted: \"granted\", purchased: \"purchased\" } as const;\nexport type Bucket = (typeof BUCKET)[keyof typeof BUCKET];\n\n/** What happens when a grant has run out: refuse (`stop`), or move on to purchased credits (`fallback_paid`). */\nexport const ON_EXHAUSTED = { stop: \"stop\", fallbackPaid: \"fallback_paid\" } as const;\nexport type OnExhausted = (typeof ON_EXHAUSTED)[keyof typeof ON_EXHAUSTED];\n\n/** The kinds of line of the ledger. Amounts are signed: credits in are positive, credits out negative. */\nexport const ENTRY_TYPE = {\n grant: \"GRANT\",\n purchase: \"PURCHASE\",\n debit: \"DEBIT\",\n refund: \"REFUND\",\n expiry: \"EXPIRY\",\n adjustment: \"ADJUSTMENT\",\n} as const;\nexport type EntryType = (typeof ENTRY_TYPE)[keyof typeof ENTRY_TYPE];\n\nexport const HOLD_STATUS = { active: \"active\", settled: \"settled\", released: \"released\" } as const;\nexport type HoldStatus = (typeof HOLD_STATUS)[keyof typeof HOLD_STATUS];\n\n/** A pot of credits. Immutable: what is left of it is derived from the ledger. */\nexport interface Grant {\n id: string;\n accountId: string;\n bucket: Bucket;\n /** What it started with. */\n amount: number;\n /** The most it gives in a calendar day (in the settings' time zone), or none. Granted only. */\n capPerDay: number | null;\n /** The most it gives in a calendar month, or none. Granted only. */\n capPerMonth: number | null;\n /** When it lapses. Required for a granted pot, none for a purchased one. */\n expiresAt: Date | null;\n onExhausted: OnExhausted;\n /** Where it comes from (`coupon:<redemptionId>`, `plan:monthly`, `purchase:<paymentRef>`). */\n source: string;\n createdAt: Date;\n}\n\n/** One line of the ledger. */\nexport interface Entry {\n id: string;\n accountId: string;\n grantId: string | null;\n type: EntryType;\n /** Signed. */\n amount: number;\n reason: string;\n /** The caller's key for the whole operation: a replay finds its lines. */\n operation: string;\n /** Unique: `<operation>:<n>`. */\n idempotencyKey: string;\n /** For a REFUND: the operation it gives back. */\n reverses: string | null;\n holdId: string | null;\n at: Date;\n}\n\nexport interface Hold {\n id: string;\n accountId: string;\n amount: number;\n expiresAt: Date;\n status: HoldStatus;\n /** What it settled for, when settled. */\n settledAmount: number | null;\n createdAt: Date;\n closedAt: Date | null;\n operation: string;\n}\n\n/** A grant as the planner sees it: what is left, what it gave today and this month, and whether it was closed by an expiry line. */\nexport interface GrantState {\n grant: Grant;\n remaining: number;\n usedToday: number;\n usedThisMonth: number;\n /** An EXPIRY line closed it (revoked, or swept after its date). */\n closed: boolean;\n}\n\n/** The calendar the caps are counted in: the zone, and the local day and month of « now » in it (`2026-10-26`, `2026-10`). */\nexport interface Calendar {\n timeZone: string;\n day: string;\n month: string;\n}\n\n/** Where the ledger lives. Every adapter runs `creditStoreContract` (`@krizaka/credits/testkit`). Times come from the caller. */\nexport interface CreditStore {\n /** Serialises the writers of one account until the transaction ends (an advisory lock). Call it before reading to decide. */\n lock(accountId: string): Promise<void>;\n insertGrant(grant: Grant): Promise<void>;\n grantById(grantId: string): Promise<Grant | null>;\n /** The account's grants, with what is left and what each gave in the calendar's day and month. Includes lapsed and closed ones. */\n grantStates(accountId: string, calendar: Calendar): Promise<GrantState[]>;\n /** Writes lines. A duplicate `idempotencyKey` is a bug of the caller (it checked `entriesOf` first, under the lock) and throws. */\n insertEntries(entries: Entry[]): Promise<void>;\n /** The lines of one operation, in order. */\n entriesOf(operation: string): Promise<Entry[]>;\n /** The lines of the account, newest first. */\n history(accountId: string, options: { limit: number; before?: Date }): Promise<Entry[]>;\n insertHold(hold: Hold): Promise<void>;\n holdById(holdId: string): Promise<Hold | null>;\n holdByOperation(operation: string): Promise<Hold | null>;\n /** What the account's active holds that have not expired at `now` keep back, except `exceptHoldId`. */\n heldAmount(accountId: string, now: Date, exceptHoldId?: string): Promise<number>;\n closeHold(\n holdId: string,\n status: typeof HOLD_STATUS.settled | typeof HOLD_STATUS.released,\n settledAmount: number | null,\n at: Date,\n ): Promise<void>;\n /** Granted pots past their date that still hold credits and no EXPIRY line, oldest first. */\n lapsedWithCredits(now: Date, limit: number): Promise<{ grantId: string; accountId: string }[]>;\n}\n\n/** The knobs of the ledger, read from the admin's settings (`@krizaka/credits/settings`). */\nexport interface CreditsSettings {\n /** The IANA zone that days and months are counted in. */\n timeZone: string;\n /** Seconds a hold keeps credits back when the caller names no lifetime. */\n holdTtlSeconds: number;\n}\n\n/** Stable codes of the refusals of this package. */\nexport const CREDITS_CODE = {\n capDay: \"CREDITS_CAP_DAY\",\n capMonth: \"CREDITS_CAP_MONTH\",\n exhausted: \"CREDITS_EXHAUSTED\",\n paywall: \"PAYWALL\",\n badAmount: \"CREDITS_BAD_AMOUNT\",\n badGrant: \"CREDITS_BAD_GRANT\",\n badKey: \"CREDITS_BAD_KEY\",\n badSettings: \"CREDITS_BAD_SETTINGS\",\n unknownGrant: \"CREDITS_UNKNOWN_GRANT\",\n unknownHold: \"CREDITS_UNKNOWN_HOLD\",\n holdClosed: \"CREDITS_HOLD_CLOSED\",\n keyReused: \"CREDITS_KEY_REUSED\",\n nothingToRefund: \"CREDITS_NOTHING_TO_REFUND\",\n} as const;\nexport type CreditsCode = (typeof CREDITS_CODE)[keyof typeof CREDITS_CODE];\n\n/** The HTTP status a route may answer for each refusal. */\nexport const CREDITS_STATUS: Record<CreditsCode, number> = {\n CREDITS_CAP_DAY: 429,\n CREDITS_CAP_MONTH: 429,\n CREDITS_EXHAUSTED: 402,\n PAYWALL: 402,\n CREDITS_BAD_AMOUNT: 400,\n CREDITS_BAD_GRANT: 400,\n CREDITS_BAD_KEY: 400,\n CREDITS_BAD_SETTINGS: 500,\n CREDITS_UNKNOWN_GRANT: 404,\n CREDITS_UNKNOWN_HOLD: 404,\n CREDITS_HOLD_CLOSED: 409,\n CREDITS_KEY_REUSED: 409,\n CREDITS_NOTHING_TO_REFUND: 404,\n};\n\n/** A refusal: a stable `code`, the HTTP `status` a route may answer, and a message. */\nexport class CreditsError extends Error {\n readonly status: number;\n constructor(\n readonly code: CreditsCode,\n message: string,\n ) {\n super(message);\n this.name = \"CreditsError\";\n this.status = CREDITS_STATUS[code];\n }\n}\n"],"mappings":";AAWO,IAAM,SAAS,EAAE,SAAS,WAAW,WAAW,YAAY;AAI5D,IAAM,eAAe,EAAE,MAAM,QAAQ,cAAc,gBAAgB;AAInE,IAAM,aAAa;AAAA,EACxB,OAAO;AAAA,EACP,UAAU;AAAA,EACV,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,YAAY;AACd;AAGO,IAAM,cAAc,EAAE,QAAQ,UAAU,SAAS,WAAW,UAAU,WAAW;AA6GjF,IAAM,eAAe;AAAA,EAC1B,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,WAAW;AAAA,EACX,SAAS;AAAA,EACT,WAAW;AAAA,EACX,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,cAAc;AAAA,EACd,aAAa;AAAA,EACb,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,iBAAiB;AACnB;AAIO,IAAM,iBAA8C;AAAA,EACzD,iBAAiB;AAAA,EACjB,mBAAmB;AAAA,EACnB,mBAAmB;AAAA,EACnB,SAAS;AAAA,EACT,oBAAoB;AAAA,EACpB,mBAAmB;AAAA,EACnB,iBAAiB;AAAA,EACjB,sBAAsB;AAAA,EACtB,uBAAuB;AAAA,EACvB,sBAAsB;AAAA,EACtB,qBAAqB;AAAA,EACrB,oBAAoB;AAAA,EACpB,2BAA2B;AAC7B;AAGO,IAAM,eAAN,cAA2B,MAAM;AAAA,EAEtC,YACW,MACT,SACA;AACA,UAAM,OAAO;AAHJ;AAIT,SAAK,OAAO;AACZ,SAAK,SAAS,eAAe,IAAI;AAAA,EACnC;AAAA,EANW;AAAA,EAFF;AASX;","names":[]}
|