@nehorai/credits 1.7.0 → 2.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/README.md +384 -153
- package/dist/adapters/index.cjs +308 -46
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.d.cts +3 -2
- package/dist/adapters/index.d.ts +3 -2
- package/dist/adapters/index.js +308 -46
- package/dist/adapters/index.js.map +1 -1
- package/dist/auth/index.cjs.map +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.cjs +700 -76
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +583 -5
- package/dist/core/index.d.ts +583 -5
- package/dist/core/index.js +652 -76
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +1772 -371
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -7
- package/dist/index.d.ts +44 -7
- package/dist/index.js +1716 -371
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +1403 -185
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +141 -15
- package/dist/repository/index.d.ts +141 -15
- package/dist/repository/index.js +1396 -185
- package/dist/repository/index.js.map +1 -1
- package/dist/sdk/index.cjs +23 -1
- package/dist/sdk/index.cjs.map +1 -1
- package/dist/sdk/index.d.cts +2 -2
- package/dist/sdk/index.d.ts +2 -2
- package/dist/sdk/index.js +23 -1
- package/dist/sdk/index.js.map +1 -1
- package/dist/service/index.cjs +484 -106
- package/dist/service/index.cjs.map +1 -1
- package/dist/service/index.d.cts +68 -10
- package/dist/service/index.d.ts +68 -10
- package/dist/service/index.js +484 -106
- package/dist/service/index.js.map +1 -1
- package/dist/types-BhCdep_t.d.ts +646 -0
- package/dist/{types-CjLj3GW5.d.cts → types-Dzc443vy.d.cts} +44 -2
- package/dist/{types-CjLj3GW5.d.ts → types-Dzc443vy.d.ts} +44 -2
- package/dist/types-wtksMa4n.d.cts +646 -0
- package/package.json +71 -70
- package/LICENSE +0 -21
- package/dist/types-DFe5BoJY.d.cts +0 -329
- package/dist/types-_bSybQnb.d.ts +0 -329
package/dist/core/index.d.cts
CHANGED
|
@@ -1,7 +1,127 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export { A as AIProviderType, B as BuiltinTier, g as CreditCheckResult,
|
|
1
|
+
import { R as ReservationStatus, a as PortableReservation, P as PortableTransaction, b as CreditSource, J as JournalReferenceType } from '../types-Dzc443vy.cjs';
|
|
2
|
+
export { A as AIProviderType, B as BuiltinTier, g as CreditCheckResult, C as CreditOperationType, D as DeductCreditsResult, M as MonthlyResetResult, f as PortableJournalEntry, d as PortableUsageLog, c as PortableUserCredits, h as ResourceType, e as SubscriptionExpiryResult, S as SubscriptionTier, T as TierConfig, i as TransactionType, U as UsageHistoryEntry, j as UsageHistoryResponse, W as WithCreditsOptions, k as calculateAvailableCredits, t as toDate, l as toPortableTimestamp } from '../types-Dzc443vy.cjs';
|
|
3
|
+
import { T as TerminalReservationStatus, I as ICreditRepository } from '../types-wtksMa4n.cjs';
|
|
4
|
+
export { a as AddCreditsOutcome, C as CommitOutcome, E as ExpireOutcome, R as ReleaseOutcome, n as ReserveOutcome, p as isCreditedOutcome, q as isReservedOutcome, r as isWinningOutcome } from '../types-wtksMa4n.cjs';
|
|
3
5
|
export { D as DeferredExecutor, c as createDeferredExecutor, g as genericDeferred, n as noopDeferred, s as synchronousDeferred } from '../deferred-C7rrRfkm.cjs';
|
|
4
|
-
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Trust checks for a *persisted* reservation row.
|
|
9
|
+
*
|
|
10
|
+
* A reservation is written once and read back on every transition, and nothing
|
|
11
|
+
* between those two moments belongs to this library: a direct `UPDATE`, a
|
|
12
|
+
* half-finished migration, a legacy adapter or a future version can all leave a
|
|
13
|
+
* row this code never wrote. Two of the fields it reads back are load-bearing.
|
|
14
|
+
*
|
|
15
|
+
* **`status`** decides whether a transition runs at all. It used to be cast —
|
|
16
|
+
* `reservation.status as TerminalReservationStatus` — so a row holding
|
|
17
|
+
* `'gremlin'` was reported as `already_terminal` with `terminalStatus:
|
|
18
|
+
* 'gremlin'`: a *success* outcome, telling the caller the reservation had been
|
|
19
|
+
* resolved, over a row nothing in this library can resolve. The cast is a lie
|
|
20
|
+
* the type system cannot catch, so the value is checked against the closed set
|
|
21
|
+
* instead and anything outside it quarantines the row.
|
|
22
|
+
*
|
|
23
|
+
* **`holdPlacedAt`** decides whether the hold behind the row exists. See
|
|
24
|
+
* {@link assertHoldPlaced}.
|
|
25
|
+
*
|
|
26
|
+
* Both refusals happen before any state changes, and neither one repairs
|
|
27
|
+
* anything: a corrupt row stops moving and waits for an operator. Silently
|
|
28
|
+
* reconciling it would need an audit trail and a recovery procedure, and
|
|
29
|
+
* guessing is how a bad row becomes a wrong balance.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Every status this library knows how to reason about. */
|
|
33
|
+
declare const RESERVATION_STATUSES: readonly ReservationStatus[];
|
|
34
|
+
/** The statuses a reservation can no longer transition out of. */
|
|
35
|
+
declare const TERMINAL_RESERVATION_STATUSES: readonly TerminalReservationStatus[];
|
|
36
|
+
declare function isReservationStatus(value: unknown): value is ReservationStatus;
|
|
37
|
+
declare function isTerminalReservationStatus(value: unknown): value is TerminalReservationStatus;
|
|
38
|
+
/** The subset of a reservation these checks read, so callers can pass a row. */
|
|
39
|
+
interface ReservationIntegrityView {
|
|
40
|
+
id: string;
|
|
41
|
+
userId: string;
|
|
42
|
+
status: unknown;
|
|
43
|
+
/** Set by the atomic reserve, in the same transaction as the hold. */
|
|
44
|
+
holdPlacedAt?: string | null;
|
|
45
|
+
idempotencyKey?: string | null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Refuse a transition over a status this library does not define.
|
|
49
|
+
*
|
|
50
|
+
* Runs ahead of every early exit, including the `already_terminal` one, for the
|
|
51
|
+
* reason in the module header: `already_terminal` is a success outcome, and a
|
|
52
|
+
* success outcome over an unknown status is the failure mode being prevented.
|
|
53
|
+
*/
|
|
54
|
+
declare function assertKnownReservationStatus(reservation: ReservationIntegrityView, transition: string): asserts reservation is ReservationIntegrityView & {
|
|
55
|
+
status: ReservationStatus;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Narrow a non-`reserved` status for the `already_terminal` outcomes.
|
|
59
|
+
*
|
|
60
|
+
* Validates rather than casts. `assertKnownReservationStatus` has usually run
|
|
61
|
+
* already; this repeats the check because the cast it replaces was the defect,
|
|
62
|
+
* and a second closed-set test costs nothing next to a database round trip.
|
|
63
|
+
*/
|
|
64
|
+
declare function terminalStatusOf(reservation: ReservationIntegrityView, transition: string): TerminalReservationStatus;
|
|
65
|
+
/**
|
|
66
|
+
* Refuse a transition over a hold that was never atomically placed.
|
|
67
|
+
*
|
|
68
|
+
* `holdPlacedAt` is the single invariant behind the V2 boundary: **a row is
|
|
69
|
+
* only a reservation if the same atomic operation that wrote it also increased
|
|
70
|
+
* `reserved` by its amount.** The atomic reserve sets the field inside that one
|
|
71
|
+
* transaction, so the fact and the hold commit together or not at all.
|
|
72
|
+
*
|
|
73
|
+
* Without it, `createReservation` — a plain row writer that does not touch
|
|
74
|
+
* `reserved` — could mint a keyed row, and a later `reserveCreditsV2` with that
|
|
75
|
+
* key would adopt it as a `replayed` hold that no credits back. The caller then
|
|
76
|
+
* commits it, and the commit passes its `reserved >= amount` guard by consuming
|
|
77
|
+
* coverage that belongs to a *different*, real hold: two holds, one payment.
|
|
78
|
+
*
|
|
79
|
+
* So this is checked before adopting a replay and before every transition, and
|
|
80
|
+
* `createReservation` refuses a key outright — the two halves of the same rule.
|
|
81
|
+
*/
|
|
82
|
+
declare function assertHoldPlaced(reservation: ReservationIntegrityView, transition: string): void;
|
|
83
|
+
/**
|
|
84
|
+
* Refuse an idempotency key on the direct, non-atomic reservation writer.
|
|
85
|
+
*
|
|
86
|
+
* The other half of {@link assertHoldPlaced}. `createReservation` inserts a row
|
|
87
|
+
* and does not touch `reserved`, so a key registered here would name a hold
|
|
88
|
+
* that does not exist. Rejecting the key at the boundary means the unbacked
|
|
89
|
+
* keyed row cannot be created in the first place, rather than only being
|
|
90
|
+
* refused later by every transition that tries to move it.
|
|
91
|
+
*
|
|
92
|
+
* The amount check that runs alongside this is about a value the ledger cannot
|
|
93
|
+
* honour; this is about a *claim* the writer cannot honour.
|
|
94
|
+
*/
|
|
95
|
+
declare function assertUnkeyedDirectReservation(input: {
|
|
96
|
+
userId: string;
|
|
97
|
+
idempotencyKey?: string;
|
|
98
|
+
}): void;
|
|
99
|
+
/** True when the row records an atomically placed hold. */
|
|
100
|
+
declare function hasPlacedHold(reservation: Pick<PortableReservation, "holdPlacedAt">): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Refuse a direct status write that would bypass the V2 state machine.
|
|
103
|
+
*
|
|
104
|
+
* `updateReservationStatus` assigns a status and nothing else: it does not
|
|
105
|
+
* check the current status, move the balance, adjust `reserved`, or journal.
|
|
106
|
+
* On a row whose hold was atomically placed, that is not an update — it is a
|
|
107
|
+
* bypass. Writing `committed` onto a live hold strands `reserved` forever (the
|
|
108
|
+
* real commit then reports `already_terminal` and never debits), and writing
|
|
109
|
+
* `reserved` onto a terminal row re-arms a settled reservation so it can be
|
|
110
|
+
* settled again.
|
|
111
|
+
*
|
|
112
|
+
* Two refusals, mirroring the createReservation/reserveCredits split:
|
|
113
|
+
*
|
|
114
|
+
* - A row bearing `holdPlacedAt` belongs to the V2 transitions. Every status it
|
|
115
|
+
* will ever hold is assigned by commit/release/expire, atomically with the
|
|
116
|
+
* ledger movement that status implies.
|
|
117
|
+
* - No row, V2 or legacy, may be *reopened* — status can never be assigned back
|
|
118
|
+
* to `reserved`, because nothing here re-places the hold that status claims.
|
|
119
|
+
*
|
|
120
|
+
* Rows written by `createReservation` (no hold, no key) remain freely
|
|
121
|
+
* annotatable with terminal statuses: their statuses never carried ledger
|
|
122
|
+
* meaning, and the V2 transitions refuse them anyway.
|
|
123
|
+
*/
|
|
124
|
+
declare function assertDirectStatusWriteAllowed(reservation: ReservationIntegrityView, status: ReservationStatus): void;
|
|
5
125
|
|
|
6
126
|
/**
|
|
7
127
|
* Credit system error classes - framework agnostic
|
|
@@ -21,6 +141,31 @@ declare const CreditErrorCode: {
|
|
|
21
141
|
readonly INVALID_OPERATION_TYPE: "INVALID_OPERATION_TYPE";
|
|
22
142
|
readonly CONFIGURATION_ERROR: "CONFIGURATION_ERROR";
|
|
23
143
|
readonly DATABASE_ERROR: "DATABASE_ERROR";
|
|
144
|
+
/** Caller reused an idempotency key with a different immutable payload. */
|
|
145
|
+
readonly IDEMPOTENCY_CONFLICT: "IDEMPOTENCY_CONFLICT";
|
|
146
|
+
/**
|
|
147
|
+
* Retryable infrastructure failure — serialization/deadlock, lock timeout,
|
|
148
|
+
* connection loss, pool exhaustion. Distinct from `DATABASE_ERROR`, which is
|
|
149
|
+
* a deterministic failure that will fail again identically on retry.
|
|
150
|
+
*/
|
|
151
|
+
readonly TRANSIENT_ERROR: "TRANSIENT_ERROR";
|
|
152
|
+
/** The configured repository adapter does not implement the requested V2 method. */
|
|
153
|
+
readonly UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION";
|
|
154
|
+
/** Amount was not a finite positive number. */
|
|
155
|
+
readonly INVALID_AMOUNT: "INVALID_AMOUNT";
|
|
156
|
+
/** Idempotency key was supplied but empty or whitespace-only. */
|
|
157
|
+
readonly INVALID_IDEMPOTENCY_KEY: "INVALID_IDEMPOTENCY_KEY";
|
|
158
|
+
/**
|
|
159
|
+
* A persisted reservation holds a status this library does not define, so no
|
|
160
|
+
* transition can reason about it. Quarantined rather than cast; nothing moved.
|
|
161
|
+
*/
|
|
162
|
+
readonly CORRUPT_RESERVATION_STATUS: "CORRUPT_RESERVATION_STATUS";
|
|
163
|
+
/**
|
|
164
|
+
* A persisted reservation does not record that its hold was placed
|
|
165
|
+
* atomically, so the credits it claims are not known to be reserved. Moving
|
|
166
|
+
* it would spend another hold's coverage; nothing moved.
|
|
167
|
+
*/
|
|
168
|
+
readonly UNBACKED_RESERVATION: "UNBACKED_RESERVATION";
|
|
24
169
|
};
|
|
25
170
|
type CreditErrorCodeType = (typeof CreditErrorCode)[keyof typeof CreditErrorCode];
|
|
26
171
|
/**
|
|
@@ -80,6 +225,432 @@ declare function createUserNotFoundError(userId: string): CreditError;
|
|
|
80
225
|
* Create an invalid operation type error
|
|
81
226
|
*/
|
|
82
227
|
declare function createInvalidOperationTypeError(operationType: string, validTypes: string[]): CreditError;
|
|
228
|
+
/**
|
|
229
|
+
* Create an idempotency conflict error
|
|
230
|
+
*
|
|
231
|
+
* Raised when a caller reuses an idempotency key with a different immutable
|
|
232
|
+
* payload (different amount or operation type). This is a caller bug, never a
|
|
233
|
+
* retryable condition — the details carry both payloads so it can be diffed.
|
|
234
|
+
*/
|
|
235
|
+
declare function createIdempotencyConflictError(idempotencyKey: string, details?: Record<string, unknown>): CreditError;
|
|
236
|
+
/**
|
|
237
|
+
* Create a transient (retryable) error
|
|
238
|
+
*/
|
|
239
|
+
declare function createTransientError(message: string, details?: Record<string, unknown>): CreditError;
|
|
240
|
+
/**
|
|
241
|
+
* Create an error for a V2 method the configured adapter does not implement
|
|
242
|
+
*/
|
|
243
|
+
declare function createUnsupportedOperationError(operation: string, hint?: string): CreditError;
|
|
244
|
+
/**
|
|
245
|
+
* Create an invalid amount error
|
|
246
|
+
*/
|
|
247
|
+
declare function createInvalidAmountError(amount: unknown): CreditError;
|
|
248
|
+
/**
|
|
249
|
+
* Check if an error is an idempotency conflict
|
|
250
|
+
*/
|
|
251
|
+
declare function isIdempotencyConflictError(error: unknown): boolean;
|
|
252
|
+
/**
|
|
253
|
+
* Check if an error is safe to retry
|
|
254
|
+
*
|
|
255
|
+
* Only `TRANSIENT_ERROR` qualifies. Use {@link classifyDatabaseError} first to
|
|
256
|
+
* turn a raw driver error into a classified `CreditError`.
|
|
257
|
+
*/
|
|
258
|
+
declare function isTransientError(error: unknown): boolean;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* `paymentRef` as a global, semantic idempotency boundary.
|
|
262
|
+
*
|
|
263
|
+
* A payment reference is the identity of a *credit event* — one webhook, one
|
|
264
|
+
* invoice, one charge. It is deliberately not scoped to a user: a processor's
|
|
265
|
+
* reference is unique across the processor's whole account, and the SQL adapter
|
|
266
|
+
* has always enforced it with a unique index over `payment_ref` alone. Two
|
|
267
|
+
* problems followed from treating it as anything less.
|
|
268
|
+
*
|
|
269
|
+
* **It was scoped per user in memory and globally in SQL.** The in-memory
|
|
270
|
+
* adapter searched only the crediting user's own transactions, so replaying a
|
|
271
|
+
* reference against a *different* user credited both accounts, while the SQL
|
|
272
|
+
* adapter silently no-opped. Same call, two ledgers, opposite results — and the
|
|
273
|
+
* memory one is the direction that invents money.
|
|
274
|
+
*
|
|
275
|
+
* **It was compared on presence, never on payload.** A reference that arrived a
|
|
276
|
+
* second time carrying a different amount was accepted as a replay of the
|
|
277
|
+
* first, so a corrected or spoofed webhook credited the original amount and
|
|
278
|
+
* reported success. Presence is only sound when the payload is known to match;
|
|
279
|
+
* otherwise the honest answer is a conflict.
|
|
280
|
+
*
|
|
281
|
+
* So a reference now resolves to exactly one of three outcomes — `created`,
|
|
282
|
+
* `replayed`, `conflict` — in both adapters, and `conflict` mutates nothing.
|
|
283
|
+
*/
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The fields that define the credit event a reference names.
|
|
287
|
+
*
|
|
288
|
+
* `description` is excluded on purpose: it is human-facing copy that a retry
|
|
289
|
+
* may legitimately regenerate ("Purchase 100 credits" versus "Purchase of 100
|
|
290
|
+
* credits") without the event being different. Everything that decides *how
|
|
291
|
+
* much lands where* is in.
|
|
292
|
+
*/
|
|
293
|
+
interface PaymentEventPayload {
|
|
294
|
+
userId: string;
|
|
295
|
+
amount: number;
|
|
296
|
+
/** The transaction row's `type` — the operation the reference names. */
|
|
297
|
+
type: PortableTransaction["type"];
|
|
298
|
+
/** The journal entry's `source` — where the credits came from. */
|
|
299
|
+
source: CreditSource;
|
|
300
|
+
/** The journal entry's `referenceType`. */
|
|
301
|
+
referenceType: JournalReferenceType;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The stored side of the comparison, as either adapter can produce it.
|
|
305
|
+
*
|
|
306
|
+
* `amount` is `unknown` because SQL returns `numeric` as a string, and
|
|
307
|
+
* comparing the mapped `Number` is exactly the rounding hole that let
|
|
308
|
+
* `9999999999.9900001` match a request for `9999999999.99`.
|
|
309
|
+
*/
|
|
310
|
+
interface StoredPaymentEvent {
|
|
311
|
+
userId: string;
|
|
312
|
+
amount: unknown;
|
|
313
|
+
type: string;
|
|
314
|
+
/** Absent when no journal entry could be located for the stored transaction. */
|
|
315
|
+
source?: string;
|
|
316
|
+
referenceType?: string;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Normalise a payment reference to "a reference" or "no reference".
|
|
320
|
+
*
|
|
321
|
+
* Empty and whitespace-only strings are *not* references: they cannot identify
|
|
322
|
+
* a payment, and treating them as one produced the divergence where SQL skipped
|
|
323
|
+
* the duplicate check (falsy) but still stored the value, so the replay hit the
|
|
324
|
+
* unique index and threw while memory quietly credited twice.
|
|
325
|
+
*
|
|
326
|
+
* Unlike an idempotency key, a blank reference is normalised rather than
|
|
327
|
+
* rejected. A key is the caller's explicit request for exactly-once and a blank
|
|
328
|
+
* one means they got it wrong; `paymentRef` is an optional annotation on a
|
|
329
|
+
* credit, and an absent one is a normal, supported call.
|
|
330
|
+
*/
|
|
331
|
+
declare function normalizePaymentRef(ref?: string | null): string | undefined;
|
|
332
|
+
/**
|
|
333
|
+
* Name the first field on which a stored credit event disagrees, or `null`.
|
|
334
|
+
*
|
|
335
|
+
* `null` means the delivery is a genuine replay and must change nothing.
|
|
336
|
+
* Anything else is a conflict: the same reference was used for a different
|
|
337
|
+
* event, and crediting on it would either double-credit or credit the wrong
|
|
338
|
+
* amount to the wrong account.
|
|
339
|
+
*
|
|
340
|
+
* `source` and `referenceType` are compared only when the stored side has them.
|
|
341
|
+
* They live on the journal entry, and a transaction written before this library
|
|
342
|
+
* journalled its purchases has none to compare against — treating that absence
|
|
343
|
+
* as a mismatch would turn every pre-existing reference into a permanent
|
|
344
|
+
* conflict, which is a refusal to credit rather than a protection against one.
|
|
345
|
+
*/
|
|
346
|
+
declare function describePaymentMismatch(stored: StoredPaymentEvent, expected: PaymentEventPayload): string | null;
|
|
347
|
+
/**
|
|
348
|
+
* Refuse a payment reference on the direct, record-only transaction writer.
|
|
349
|
+
*
|
|
350
|
+
* `createTransaction` writes a ledger *record*: no balance moves and no journal
|
|
351
|
+
* entry is created. But a `paymentRef` on that record still occupies the global
|
|
352
|
+
* unique boundary, and `addCreditsV2` treats the row that holds a reference as
|
|
353
|
+
* the authoritative credit event — a later delivery of the real payment with
|
|
354
|
+
* the same reference then matches the record on user/amount/type, reports
|
|
355
|
+
* `replayed`, and credits nothing, forever. The record would have consumed the
|
|
356
|
+
* payment.
|
|
357
|
+
*
|
|
358
|
+
* Same shape as `assertUnkeyedDirectReservation`: the claim is refused at the
|
|
359
|
+
* boundary, so the row that could impersonate a credit event cannot be written
|
|
360
|
+
* in the first place. Credits that carry a reference go through
|
|
361
|
+
* `addCreditsAtomic` / `addCreditsV2`, which claim the reference and move the
|
|
362
|
+
* balance in one atomic step.
|
|
363
|
+
*/
|
|
364
|
+
declare function assertUnreferencedDirectTransaction(input: {
|
|
365
|
+
userId: string;
|
|
366
|
+
paymentRef?: string;
|
|
367
|
+
}): void;
|
|
368
|
+
/**
|
|
369
|
+
* The conflict, as an error, for the legacy `addCreditsAtomic` signature.
|
|
370
|
+
*
|
|
371
|
+
* That method returns `void`, so it has nowhere to put a `conflict` outcome.
|
|
372
|
+
* Swallowing one would be the original bug in a new place — the SQL adapter
|
|
373
|
+
* used to no-op on a cross-user reference reuse, which reads to the caller as
|
|
374
|
+
* "credited" — so the legacy path throws instead. Callers that want to *handle*
|
|
375
|
+
* a conflict rather than catch it should use `addCreditsV2`.
|
|
376
|
+
*/
|
|
377
|
+
declare function createPaymentRefConflictError(paymentRef: string, details: {
|
|
378
|
+
userId: string;
|
|
379
|
+
mismatch: string;
|
|
380
|
+
existingUserId?: string;
|
|
381
|
+
}): CreditError;
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Validation and exact comparison for credit amounts.
|
|
385
|
+
*
|
|
386
|
+
* Every amount ends up in a `numeric(12, 2)` column, so the ledger can only
|
|
387
|
+
* represent values on the cent grid, with at most ten integer digits. A value
|
|
388
|
+
* outside that grid is not "close enough" — PostgreSQL silently rounds it on
|
|
389
|
+
* write, and the row that comes back no longer equals what the caller asked
|
|
390
|
+
* for. Rejecting it before any row is touched is the only way the ledger and
|
|
391
|
+
* the caller can agree on what happened.
|
|
392
|
+
*
|
|
393
|
+
* These live in core rather than in an adapter so every repository — shipped
|
|
394
|
+
* or third-party — validates identically.
|
|
395
|
+
*/
|
|
396
|
+
/** `numeric(12, 2)`: 12 significant digits, 2 of them after the point. */
|
|
397
|
+
declare const CREDIT_AMOUNT_SCALE = 2;
|
|
398
|
+
/** Largest representable amount: ten integer digits plus two decimals. */
|
|
399
|
+
declare const CREDIT_AMOUNT_MAX = 9999999999.99;
|
|
400
|
+
/**
|
|
401
|
+
* Convert an amount to whole cents, or `null` if it is not exactly on the grid.
|
|
402
|
+
*
|
|
403
|
+
* `Math.round(value * 100) === value * 100` is the tempting check and it is
|
|
404
|
+
* wrong: `1.005 * 100` is `100.49999999999999` in binary float, so a genuinely
|
|
405
|
+
* invalid value can pass and a valid one can fail. Rounding first and then
|
|
406
|
+
* confirming the value round-trips (`value === cents / 100`) is exact for
|
|
407
|
+
* everything in range, because a cent count below 2^53 divided by 100 is the
|
|
408
|
+
* nearest double to that decimal, which is what the literal parsed to.
|
|
409
|
+
*/
|
|
410
|
+
declare function toCents(value: number): number | null;
|
|
411
|
+
/**
|
|
412
|
+
* Add amounts on the cent grid, exactly.
|
|
413
|
+
*
|
|
414
|
+
* Deriving a total with `+` is not safe in a ledger that validates against the
|
|
415
|
+
* grid: `0.10 + 0.20` is `0.30000000000000004` and `0.30 - 0.10` is
|
|
416
|
+
* `0.19999999999999998`, neither of which is a value `numeric(12, 2)` can hold.
|
|
417
|
+
* Two perfectly legal columns would therefore produce a total the validator
|
|
418
|
+
* rejects with `INVALID_AMOUNT`, blaming the caller for a rounding artefact.
|
|
419
|
+
* Summing whole cents is exact for every value in range, and `cents / 100` is
|
|
420
|
+
* the nearest double to that decimal — the same one the literal would parse to.
|
|
421
|
+
*
|
|
422
|
+
* Subtract by negating: `sumAmounts(total, -amount)`. Negation is exact.
|
|
423
|
+
*
|
|
424
|
+
* This is arithmetic, not validation. If any input is itself off the grid the
|
|
425
|
+
* float sum is returned unchanged — deliberately, so a corrupt value is never
|
|
426
|
+
* rounded into legitimacy here — but that is not a guarantee the result will be
|
|
427
|
+
* rejected downstream: two off-grid inputs can cancel, and `sumAmounts(0.005,
|
|
428
|
+
* -0.005)` is a perfectly representable `0`. Inputs read from storage must be
|
|
429
|
+
* validated as inputs, with `assertValidStoredAmount` or
|
|
430
|
+
* `assertValidStoredAmountRaw`, before they are summed. Checking only the
|
|
431
|
+
* derived result is not enough and never was.
|
|
432
|
+
*/
|
|
433
|
+
declare function sumAmounts(...values: number[]): number;
|
|
434
|
+
/** Amounts that may be *moved*: strictly positive and on the cent grid. */
|
|
435
|
+
declare function isValidCreditAmount(value: unknown): value is number;
|
|
436
|
+
/**
|
|
437
|
+
* Reject an unusable amount before any row is written.
|
|
438
|
+
*
|
|
439
|
+
* @param context - merged into the error details, e.g. `{ userId }`.
|
|
440
|
+
*/
|
|
441
|
+
declare function assertValidCreditAmount(value: unknown, context?: Record<string, unknown>): asserts value is number;
|
|
442
|
+
/**
|
|
443
|
+
* Amounts the ledger can *record*: on the cent grid and in range, but free to
|
|
444
|
+
* be zero or negative.
|
|
445
|
+
*
|
|
446
|
+
* Not every numeric column holds something spendable. A release journal entry
|
|
447
|
+
* records `amount: 0` because no credits changed hands, and `balanceAfter` goes
|
|
448
|
+
* negative for an account that was corrected or allowed to overdraw. Forcing
|
|
449
|
+
* those through {@link isValidCreditAmount} would reject legitimate ledger
|
|
450
|
+
* rows; skipping validation entirely would let `Infinity` or a third decimal
|
|
451
|
+
* reach the column and be silently rounded or rejected by the driver.
|
|
452
|
+
*/
|
|
453
|
+
declare function isRepresentableAmount(value: unknown): value is number;
|
|
454
|
+
/**
|
|
455
|
+
* Reject a ledger value the `numeric(12, 2)` column cannot hold exactly.
|
|
456
|
+
*
|
|
457
|
+
* @param field - the column being written, so the error names the culprit.
|
|
458
|
+
*/
|
|
459
|
+
declare function assertRepresentableAmount(value: unknown, field: string, context?: Record<string, unknown>): asserts value is number;
|
|
460
|
+
/**
|
|
461
|
+
* Check a whole set of optional ledger fields at once.
|
|
462
|
+
*
|
|
463
|
+
* The balance writers each take a handful of independently optional numbers,
|
|
464
|
+
* and validating them one `if` at a time is where one quietly gets missed.
|
|
465
|
+
* Undefined entries are skipped — they mean "leave this column alone".
|
|
466
|
+
*/
|
|
467
|
+
declare function assertRepresentableFields(fields: Record<string, unknown>, context?: Record<string, unknown>): void;
|
|
468
|
+
/**
|
|
469
|
+
* The lowest `balance` that keeps every outstanding hold committable.
|
|
470
|
+
*
|
|
471
|
+
* Commit's funding guard is `balance + bonusCredits >= amount`, and every live
|
|
472
|
+
* hold is counted in `reserved`, so any write that lowers `balance` must keep
|
|
473
|
+
* `balance + bonusCredits >= reserved` — otherwise a hold that was fully backed
|
|
474
|
+
* when it was placed throws `INSUFFICIENT_CREDITS` at commit time and is
|
|
475
|
+
* stranded. Monthly resets, tier downgrades and explicit tier writes all lower
|
|
476
|
+
* `balance` to a configured target; this is the floor they may not cut through.
|
|
477
|
+
*
|
|
478
|
+
* The floor is `reserved - bonusCredits`, never below zero: bonus credits also
|
|
479
|
+
* fund commits, so they count toward the backing.
|
|
480
|
+
*/
|
|
481
|
+
declare function backedBalanceFloor(reserved: number, bonusCredits: number): number;
|
|
482
|
+
/**
|
|
483
|
+
* Validate a limit derived from tier configuration.
|
|
484
|
+
*
|
|
485
|
+
* `Infinity` is this codebase's sentinel for an unlimited tier, so it is
|
|
486
|
+
* accepted here; {@link storedMonthlyLimit} decides what actually reaches the
|
|
487
|
+
* column. Every other value ends up in `numeric(12, 2)` and has to fit, so a
|
|
488
|
+
* tier configured with `monthlyCredits: 1.005` is caught here rather than
|
|
489
|
+
* silently rounded to `1.01` by PostgreSQL.
|
|
490
|
+
*/
|
|
491
|
+
declare function assertRepresentableTierAmount(value: unknown, field: string, context?: Record<string, unknown>): void;
|
|
492
|
+
/**
|
|
493
|
+
* Validate a stored amount in the representation storage actually returned.
|
|
494
|
+
*
|
|
495
|
+
* Checking the mapped float is not enough, because the mapping is lossy in both
|
|
496
|
+
* directions. A `numeric` column with no scale constraint can hold
|
|
497
|
+
* `9999999999.9900001`; `Number()` rounds that to `9999999999.99`, which is a
|
|
498
|
+
* perfectly valid amount, so a validator looking at the mapped value sees
|
|
499
|
+
* nothing wrong and the transition proceeds — while PostgreSQL goes on doing
|
|
500
|
+
* exact arithmetic with the digits JavaScript threw away. `NaN` and values past
|
|
501
|
+
* double range are worse: the mapper turns them into `0`, which misreports what
|
|
502
|
+
* the row holds.
|
|
503
|
+
*
|
|
504
|
+
* So this runs on the driver's own output — a string, for `numeric` — and uses
|
|
505
|
+
* exact integer arithmetic. It returns the amount as a float only after
|
|
506
|
+
* establishing that the float is a faithful representation of it.
|
|
507
|
+
*
|
|
508
|
+
* @returns the stored amount, exactly representable as a JS number.
|
|
509
|
+
*/
|
|
510
|
+
/**
|
|
511
|
+
* How an unlimited tier's monthly limit is stored.
|
|
512
|
+
*
|
|
513
|
+
* `getConfigMonthlyLimit` returns `Infinity` for an unlimited tier, and no
|
|
514
|
+
* numeric column can hold that. Both adapters therefore store the ceiling of
|
|
515
|
+
* the representable range instead: any real usage compares below it, so
|
|
516
|
+
* "unlimited" keeps behaving like unlimited, and a reader that knows nothing
|
|
517
|
+
* about the sentinel sees a very large allowance rather than a wrong one.
|
|
518
|
+
*
|
|
519
|
+
* This exists so the two adapters cannot drift. The SQL adapter previously
|
|
520
|
+
* stored `0` here, which reads as *no* allowance — the exact opposite — while
|
|
521
|
+
* the in-memory adapter kept `Infinity`.
|
|
522
|
+
*/
|
|
523
|
+
declare function storedMonthlyLimit(limit: number): number;
|
|
524
|
+
declare function assertValidStoredAmountRaw(raw: unknown, context: Record<string, unknown>): number;
|
|
525
|
+
/**
|
|
526
|
+
* Reject a *persisted* amount that the ledger cannot honour.
|
|
527
|
+
*
|
|
528
|
+
* A reservation row is written once and read back on every transition, and
|
|
529
|
+
* nothing stops a direct SQL write, a botched migration, or an older version of
|
|
530
|
+
* this library from leaving an amount the invariants cannot reason about. A
|
|
531
|
+
* negative one is the dangerous case: `reserved >= amount` is trivially true
|
|
532
|
+
* for it, and `balance - (-10)` *adds* credits, so a transition that trusts the
|
|
533
|
+
* stored value mints money. Every transition therefore re-validates what it
|
|
534
|
+
* locked before it changes anything.
|
|
535
|
+
*
|
|
536
|
+
* Deliberately fails release and expire as well as commit. Returning a hold of
|
|
537
|
+
* an unknown size is not obviously safer than spending one — it hands back
|
|
538
|
+
* coverage that may never have been taken — so a corrupt row stops moving
|
|
539
|
+
* entirely and waits for an operator, rather than being partially honoured.
|
|
540
|
+
*
|
|
541
|
+
* This is the float-typed form, used by the in-memory adapter where the stored
|
|
542
|
+
* value *is* a JS number and no conversion has happened. A repository whose
|
|
543
|
+
* storage returns some other representation must use
|
|
544
|
+
* {@link assertValidStoredAmountRaw} instead, or the conversion will hide the
|
|
545
|
+
* corruption from the check.
|
|
546
|
+
*/
|
|
547
|
+
declare function assertValidStoredAmount(value: unknown, context: Record<string, unknown>): asserts value is number;
|
|
548
|
+
/**
|
|
549
|
+
* Parse a value read back from a `numeric` column into exact cents.
|
|
550
|
+
*
|
|
551
|
+
* Drivers return `numeric` as a string precisely so no precision is lost in
|
|
552
|
+
* transit; comparing the parsed floats would throw that away again. Returns
|
|
553
|
+
* `null` for anything unparseable so callers can treat it as "not equal"
|
|
554
|
+
* rather than crashing on malformed ledger data.
|
|
555
|
+
*/
|
|
556
|
+
declare function numericToCents(value: unknown): bigint | null;
|
|
557
|
+
/**
|
|
558
|
+
* Exact equality for two amounts that may be numbers or `numeric` strings.
|
|
559
|
+
*
|
|
560
|
+
* Unparseable input is *not* equal to anything, including itself — a
|
|
561
|
+
* comparison against corrupt data must never come back "matches".
|
|
562
|
+
*/
|
|
563
|
+
declare function sameAmount(a: unknown, b: unknown): boolean;
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Validation for caller-supplied idempotency keys.
|
|
567
|
+
*
|
|
568
|
+
* The keys are enforced by a *partial* unique index (`WHERE idempotency_key IS
|
|
569
|
+
* NOT NULL`), so "no key" and "a key" are genuinely different states and the
|
|
570
|
+
* boundary between them has to be unambiguous. An empty string sat in between:
|
|
571
|
+
* the SQL adapter stored it — occupying a row in the unique index — while every
|
|
572
|
+
* `if (key)` check read it as absent, so the key was written but never
|
|
573
|
+
* deduplicated anything, and the in-memory adapter ignored it outright. Two
|
|
574
|
+
* adapters, two behaviours, neither of them the one the caller asked for.
|
|
575
|
+
*
|
|
576
|
+
* Rejecting is preferred over trimming. Normalising `" job-1 "` to `"job-1"`
|
|
577
|
+
* would mean two callers who sent different strings silently share a hold, and
|
|
578
|
+
* a caller whose key is accidentally whitespace would rather hear about it than
|
|
579
|
+
* have it quietly repaired.
|
|
580
|
+
*/
|
|
581
|
+
/** A usable key: a string with at least one non-whitespace character. */
|
|
582
|
+
declare function isValidIdempotencyKey(value: unknown): value is string;
|
|
583
|
+
/**
|
|
584
|
+
* Reject an unusable idempotency key before anything is written.
|
|
585
|
+
*
|
|
586
|
+
* `undefined` is allowed and means "no key" — that is the legacy, at-least-once
|
|
587
|
+
* behaviour, and it is a valid choice. Only a key that was *supplied* and
|
|
588
|
+
* cannot work is an error.
|
|
589
|
+
*/
|
|
590
|
+
declare function assertValidIdempotencyKey(value: unknown, context?: Record<string, unknown>): asserts value is string | undefined;
|
|
591
|
+
/**
|
|
592
|
+
* Namespace the V2 reservation transitions own. Public writes may not use it.
|
|
593
|
+
*
|
|
594
|
+
* A transition that finds its deterministic key already present treats an
|
|
595
|
+
* exactly-matching row as its own retry and reuses it. That is correct against
|
|
596
|
+
* a row the transition itself wrote — and a hole if any caller can write one.
|
|
597
|
+
* A caller who pre-seeds `reservation:<id>:commit` with a plausible-looking
|
|
598
|
+
* entry would have the commit adopt it: the reservation flips to committed and
|
|
599
|
+
* the balance moves, while the only journal row is the caller's, never written
|
|
600
|
+
* by the transition and not necessarily describing what it did.
|
|
601
|
+
*
|
|
602
|
+
* Reserving the prefix removes the premise. Nothing outside the transitions can
|
|
603
|
+
* create a key in it, so an existing key is either the transition's own retry
|
|
604
|
+
* or corruption — and corruption is refused.
|
|
605
|
+
*/
|
|
606
|
+
declare const RESERVED_JOURNAL_KEY_PREFIX = "reservation:";
|
|
607
|
+
/** True when a key belongs to the namespace the V2 transitions own. */
|
|
608
|
+
declare function isReservedJournalKey(key: unknown): boolean;
|
|
609
|
+
/**
|
|
610
|
+
* Refuse a public journal write that claims a key in the reserved namespace.
|
|
611
|
+
*
|
|
612
|
+
* Called from the adapters' public `createJournalEntry`, never from the
|
|
613
|
+
* transitions themselves — they are the ones the namespace belongs to.
|
|
614
|
+
*/
|
|
615
|
+
declare function assertPublicJournalKey(key: unknown, userId: string): void;
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Driver-error classification.
|
|
619
|
+
*
|
|
620
|
+
* Consuming apps need to react differently to "the user is out of credits"
|
|
621
|
+
* (show a paywall), "the database blinked" (retry), and "our SQL is wrong"
|
|
622
|
+
* (page someone). Drivers signal that distinction through a SQLSTATE on the
|
|
623
|
+
* thrown object, so this maps SQLSTATE classes onto {@link CreditErrorCode}
|
|
624
|
+
* without taking a dependency on any particular driver.
|
|
625
|
+
*/
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Read a SQLSTATE-ish `code` off an unknown thrown value.
|
|
629
|
+
*
|
|
630
|
+
* The chain matters. Wrapping drivers — and Drizzle itself, in places — rethrow
|
|
631
|
+
* with the original error on `cause` and no `code` of their own, so looking only
|
|
632
|
+
* at the outermost object reports "no SQLSTATE" for an error that plainly has
|
|
633
|
+
* one. The walk is bounded and tracks what it has seen, since a `cause` graph
|
|
634
|
+
* can be circular.
|
|
635
|
+
*/
|
|
636
|
+
declare function getSqlState(error: unknown): string | undefined;
|
|
637
|
+
/**
|
|
638
|
+
* True when the SQLSTATE is a unique-violation (`23505`).
|
|
639
|
+
*
|
|
640
|
+
* Callers that race on a partial unique index use this to tell "someone else
|
|
641
|
+
* inserted the same idempotency key" from a genuine schema error.
|
|
642
|
+
*/
|
|
643
|
+
declare function isUniqueViolation(error: unknown): boolean;
|
|
644
|
+
/** True when the error is worth retrying. */
|
|
645
|
+
declare function isTransientDatabaseError(error: unknown): boolean;
|
|
646
|
+
/**
|
|
647
|
+
* Wrap an unknown thrown value in a classified {@link CreditError}.
|
|
648
|
+
*
|
|
649
|
+
* Already-classified `CreditError`s pass through untouched so a domain outcome
|
|
650
|
+
* (insufficient credits, idempotency conflict) is never downgraded to
|
|
651
|
+
* `DATABASE_ERROR` by a generic catch further up the stack.
|
|
652
|
+
*/
|
|
653
|
+
declare function classifyDatabaseError(error: unknown, context?: Record<string, unknown>): CreditError;
|
|
83
654
|
|
|
84
655
|
/**
|
|
85
656
|
* Core credit operations - framework agnostic
|
|
@@ -91,6 +662,11 @@ declare function createInvalidOperationTypeError(operationType: string, validTyp
|
|
|
91
662
|
/**
|
|
92
663
|
* Commit a reservation with journal entry
|
|
93
664
|
*
|
|
665
|
+
* On a V2 repository the balance move and the journal entry happen in one
|
|
666
|
+
* transaction and this function adds nothing of its own — the duplicate entry
|
|
667
|
+
* the previous implementation wrote is gone. Legacy adapters keep the old
|
|
668
|
+
* read-then-write path, journal included.
|
|
669
|
+
*
|
|
94
670
|
* @param repository - The credit repository
|
|
95
671
|
* @param userId - User ID
|
|
96
672
|
* @param reservationId - Reservation to commit
|
|
@@ -112,8 +688,10 @@ declare function releaseReservationWithJournal(repository: ICreditRepository, us
|
|
|
112
688
|
* @param amount - Credits to reserve
|
|
113
689
|
* @param operationType - Type of operation
|
|
114
690
|
* @param expiryMs - Reservation expiry time in milliseconds
|
|
691
|
+
* @param idempotencyKey - Optional caller key; a replay with the same
|
|
692
|
+
* amount/operation returns the original reservation instead of a second hold
|
|
115
693
|
* @returns The reservation
|
|
116
694
|
*/
|
|
117
|
-
declare function reserveCreditsForOperation(repository: ICreditRepository, userId: string, amount: number, operationType: string, expiryMs?: number): Promise<PortableReservation>;
|
|
695
|
+
declare function reserveCreditsForOperation(repository: ICreditRepository, userId: string, amount: number, operationType: string, expiryMs?: number, idempotencyKey?: string): Promise<PortableReservation>;
|
|
118
696
|
|
|
119
|
-
export { CreditError, CreditErrorCode, type CreditErrorCodeType, PortableReservation, commitReservationWithJournal, createInsufficientCreditsError, createInvalidOperationTypeError, createReservationAlreadyProcessedError, createReservationExpiredError, createReservationNotFoundError, createUserNotFoundError, isCreditError, isInsufficientCreditsError, releaseReservationWithJournal, reserveCreditsForOperation };
|
|
697
|
+
export { CREDIT_AMOUNT_MAX, CREDIT_AMOUNT_SCALE, CreditError, CreditErrorCode, type CreditErrorCodeType, CreditSource, JournalReferenceType, type PaymentEventPayload, PortableReservation, PortableTransaction, RESERVATION_STATUSES, RESERVED_JOURNAL_KEY_PREFIX, type ReservationIntegrityView, ReservationStatus, type StoredPaymentEvent, TERMINAL_RESERVATION_STATUSES, TerminalReservationStatus, assertDirectStatusWriteAllowed, assertHoldPlaced, assertKnownReservationStatus, assertPublicJournalKey, assertRepresentableAmount, assertRepresentableFields, assertRepresentableTierAmount, assertUnkeyedDirectReservation, assertUnreferencedDirectTransaction, assertValidCreditAmount, assertValidIdempotencyKey, assertValidStoredAmount, assertValidStoredAmountRaw, backedBalanceFloor, classifyDatabaseError, commitReservationWithJournal, createIdempotencyConflictError, createInsufficientCreditsError, createInvalidAmountError, createInvalidOperationTypeError, createPaymentRefConflictError, createReservationAlreadyProcessedError, createReservationExpiredError, createReservationNotFoundError, createTransientError, createUnsupportedOperationError, createUserNotFoundError, describePaymentMismatch, getSqlState, hasPlacedHold, isCreditError, isIdempotencyConflictError, isInsufficientCreditsError, isRepresentableAmount, isReservationStatus, isReservedJournalKey, isTerminalReservationStatus, isTransientDatabaseError, isTransientError, isUniqueViolation, isValidCreditAmount, isValidIdempotencyKey, normalizePaymentRef, numericToCents, releaseReservationWithJournal, reserveCreditsForOperation, sameAmount, storedMonthlyLimit, sumAmounts, terminalStatusOf, toCents };
|