@krizaka/credits 0.1.2 → 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/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  checkSettings,
8
8
  planDraws,
9
9
  skipFirst
10
- } from "./chunk-34Y2HXOB.js";
10
+ } from "./chunk-FPQSMNV2.js";
11
11
  import {
12
12
  BUCKET,
13
13
  CREDITS_CODE,
@@ -16,7 +16,7 @@ import {
16
16
  ENTRY_TYPE,
17
17
  HOLD_STATUS,
18
18
  ON_EXHAUSTED
19
- } from "./chunk-WDHQ7QHY.js";
19
+ } from "./chunk-NKDQNDSD.js";
20
20
 
21
21
  // src/credits.ts
22
22
  import { randomUUID } from "crypto";
@@ -70,9 +70,42 @@ var num = (v) => Number(v);
70
70
  var numOrNull = (v) => v === null ? null : Number(v);
71
71
  var date = (v) => new Date(v);
72
72
  var dateOrNull = (v) => v === null ? null : new Date(v);
73
- var toGrant = (r) => ({ id: r.id, accountId: r.account_id, bucket: r.bucket, amount: num(r.amount), capPerDay: numOrNull(r.cap_per_day), capPerMonth: numOrNull(r.cap_per_month), expiresAt: dateOrNull(r.expires_at), onExhausted: r.on_exhausted, source: r.source, createdAt: date(r.created_at) });
74
- var toEntry = (r) => ({ id: r.id, accountId: r.account_id, grantId: r.grant_id, type: r.type, amount: num(r.amount), reason: r.reason, operation: r.operation, idempotencyKey: r.idempotency_key, reverses: r.reverses, holdId: r.hold_id, at: date(r.at) });
75
- var toHold = (r) => ({ id: r.id, accountId: r.account_id, amount: num(r.amount), expiresAt: date(r.expires_at), status: r.status, settledAmount: numOrNull(r.settled_amount), createdAt: date(r.created_at), closedAt: dateOrNull(r.closed_at), operation: r.operation });
73
+ var toGrant = (r) => ({
74
+ id: r.id,
75
+ accountId: r.account_id,
76
+ bucket: r.bucket,
77
+ amount: num(r.amount),
78
+ capPerDay: numOrNull(r.cap_per_day),
79
+ capPerMonth: numOrNull(r.cap_per_month),
80
+ expiresAt: dateOrNull(r.expires_at),
81
+ onExhausted: r.on_exhausted,
82
+ source: r.source,
83
+ createdAt: date(r.created_at)
84
+ });
85
+ var toEntry = (r) => ({
86
+ id: r.id,
87
+ accountId: r.account_id,
88
+ grantId: r.grant_id,
89
+ type: r.type,
90
+ amount: num(r.amount),
91
+ reason: r.reason,
92
+ operation: r.operation,
93
+ idempotencyKey: r.idempotency_key,
94
+ reverses: r.reverses,
95
+ holdId: r.hold_id,
96
+ at: date(r.at)
97
+ });
98
+ var toHold = (r) => ({
99
+ id: r.id,
100
+ accountId: r.account_id,
101
+ amount: num(r.amount),
102
+ expiresAt: date(r.expires_at),
103
+ status: r.status,
104
+ settledAmount: numOrNull(r.settled_amount),
105
+ createdAt: date(r.created_at),
106
+ closedAt: dateOrNull(r.closed_at),
107
+ operation: r.operation
108
+ });
76
109
  function dbCreditStore(query, schema = "credits") {
77
110
  const s = ident(schema);
78
111
  const grant = `${s}."grant"`;
@@ -83,7 +116,21 @@ function dbCreditStore(query, schema = "credits") {
83
116
  await query("SELECT pg_advisory_xact_lock(hashtextextended('krizaka.credits:' || $1::text, 0))", [accountId]);
84
117
  },
85
118
  async insertGrant(g) {
86
- await query(`INSERT INTO ${grant} (id, account_id, bucket, amount, cap_per_day, cap_per_month, expires_at, on_exhausted, source, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [g.id, g.accountId, g.bucket, g.amount, g.capPerDay, g.capPerMonth, g.expiresAt, g.onExhausted, g.source, g.createdAt]);
119
+ await query(
120
+ `INSERT INTO ${grant} (id, account_id, bucket, amount, cap_per_day, cap_per_month, expires_at, on_exhausted, source, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
121
+ [
122
+ g.id,
123
+ g.accountId,
124
+ g.bucket,
125
+ g.amount,
126
+ g.capPerDay,
127
+ g.capPerMonth,
128
+ g.expiresAt,
129
+ g.onExhausted,
130
+ g.source,
131
+ g.createdAt
132
+ ]
133
+ );
87
134
  },
88
135
  async grantById(id) {
89
136
  const [row] = await query(`SELECT * FROM ${grant} WHERE id = $1`, [id]);
@@ -101,21 +148,50 @@ function dbCreditStore(query, schema = "credits") {
101
148
  GROUP BY g.id`,
102
149
  [accountId, calendar.timeZone, calendar.day, calendar.month]
103
150
  );
104
- return rows.map((r) => ({ grant: toGrant(r), remaining: num(r.remaining), usedToday: num(r.used_today), usedThisMonth: num(r.used_month), closed: r.closed }));
151
+ return rows.map((r) => ({
152
+ grant: toGrant(r),
153
+ remaining: num(r.remaining),
154
+ usedToday: num(r.used_today),
155
+ usedThisMonth: num(r.used_month),
156
+ closed: r.closed
157
+ }));
105
158
  },
106
159
  async insertEntries(entries) {
107
160
  for (const e of entries) {
108
- await query(`INSERT INTO ${entry} (id, account_id, grant_id, type, amount, reason, operation, idempotency_key, reverses, hold_id, at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [e.id, e.accountId, e.grantId, e.type, e.amount, e.reason, e.operation, e.idempotencyKey, e.reverses, e.holdId, e.at]);
161
+ await query(
162
+ `INSERT INTO ${entry} (id, account_id, grant_id, type, amount, reason, operation, idempotency_key, reverses, hold_id, at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
163
+ [
164
+ e.id,
165
+ e.accountId,
166
+ e.grantId,
167
+ e.type,
168
+ e.amount,
169
+ e.reason,
170
+ e.operation,
171
+ e.idempotencyKey,
172
+ e.reverses,
173
+ e.holdId,
174
+ e.at
175
+ ]
176
+ );
109
177
  }
110
178
  },
111
179
  async entriesOf(operation) {
112
- return (await query(`SELECT * FROM ${entry} WHERE operation = $1 ORDER BY seq`, [operation])).map(toEntry);
180
+ return (await query(`SELECT * FROM ${entry} WHERE operation = $1 ORDER BY seq`, [operation])).map(
181
+ toEntry
182
+ );
113
183
  },
114
184
  async history(accountId, { limit, before }) {
115
- return (await query(`SELECT * FROM ${entry} WHERE account_id = $1 AND ($2::timestamptz IS NULL OR at < $2) ORDER BY seq DESC LIMIT $3`, [accountId, before ?? null, limit])).map(toEntry);
185
+ return (await query(
186
+ `SELECT * FROM ${entry} WHERE account_id = $1 AND ($2::timestamptz IS NULL OR at < $2) ORDER BY seq DESC LIMIT $3`,
187
+ [accountId, before ?? null, limit]
188
+ )).map(toEntry);
116
189
  },
117
190
  async insertHold(h) {
118
- await query(`INSERT INTO ${hold} (id, account_id, amount, expires_at, status, settled_amount, created_at, closed_at, operation) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [h.id, h.accountId, h.amount, h.expiresAt, h.status, h.settledAmount, h.createdAt, h.closedAt, h.operation]);
191
+ await query(
192
+ `INSERT INTO ${hold} (id, account_id, amount, expires_at, status, settled_amount, created_at, closed_at, operation) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
193
+ [h.id, h.accountId, h.amount, h.expiresAt, h.status, h.settledAmount, h.createdAt, h.closedAt, h.operation]
194
+ );
119
195
  },
120
196
  async holdById(id) {
121
197
  const [row] = await query(`SELECT * FROM ${hold} WHERE id = $1`, [id]);
@@ -126,11 +202,19 @@ function dbCreditStore(query, schema = "credits") {
126
202
  return row ? toHold(row) : null;
127
203
  },
128
204
  async heldAmount(accountId, now, exceptHoldId) {
129
- const [row] = await query(`SELECT COALESCE(SUM(amount), 0) AS held FROM ${hold} WHERE account_id = $1 AND status = 'active' AND expires_at > $2 AND ($3::uuid IS NULL OR id <> $3)`, [accountId, now, exceptHoldId ?? null]);
205
+ const [row] = await query(
206
+ `SELECT COALESCE(SUM(amount), 0) AS held FROM ${hold} WHERE account_id = $1 AND status = 'active' AND expires_at > $2 AND ($3::uuid IS NULL OR id <> $3)`,
207
+ [accountId, now, exceptHoldId ?? null]
208
+ );
130
209
  return num(row?.held ?? 0);
131
210
  },
132
211
  async closeHold(id, status, settledAmount, at) {
133
- await query(`UPDATE ${hold} SET status = $2, settled_amount = $3, closed_at = $4 WHERE id = $1`, [id, status, settledAmount, at]);
212
+ await query(`UPDATE ${hold} SET status = $2, settled_amount = $3, closed_at = $4 WHERE id = $1`, [
213
+ id,
214
+ status,
215
+ settledAmount,
216
+ at
217
+ ]);
134
218
  },
135
219
  async lapsedWithCredits(now, limit) {
136
220
  const rows = await query(
@@ -171,19 +255,38 @@ function createCredits(deps) {
171
255
  });
172
256
  const replayOf = async (store, operation, accountId) => {
173
257
  const lines = await store.entriesOf(operation);
174
- if (lines.some((l) => l.accountId !== accountId)) throw new CreditsError(CREDITS_CODE.keyReused, "This idempotency key was used for another account");
258
+ if (lines.some((l) => l.accountId !== accountId))
259
+ throw new CreditsError(CREDITS_CODE.keyReused, "This idempotency key was used for another account");
175
260
  return lines;
176
261
  };
177
262
  const announce = async (tx, type, seed, payload) => {
178
263
  await deps.outbox?.append(tx, { topic: CREDITS_TOPIC, type, payload, id: uuidOf(seed) });
179
264
  };
180
- const grantPayload = (g) => ({ accountId: g.accountId, grantId: g.id, bucket: g.bucket, amount: g.amount, expiresAt: g.expiresAt?.toISOString() ?? null, capPerDay: g.capPerDay, capPerMonth: g.capPerMonth, onExhausted: g.onExhausted, source: g.source });
265
+ const grantPayload = (g) => ({
266
+ accountId: g.accountId,
267
+ grantId: g.id,
268
+ bucket: g.bucket,
269
+ amount: g.amount,
270
+ expiresAt: g.expiresAt?.toISOString() ?? null,
271
+ capPerDay: g.capPerDay,
272
+ capPerMonth: g.capPerMonth,
273
+ onExhausted: g.onExhausted,
274
+ source: g.source
275
+ });
181
276
  const announceExhausted = (tx, states, draws) => Promise.all(
182
277
  draws.flatMap((d) => {
183
278
  const s = states.find((x) => x.grant.id === d.grantId);
184
279
  if (!s || s.remaining - d.amount > 0) return [];
185
280
  const g = s.grant;
186
- return [announce(tx, CREDIT_EVENT.exhausted, `exhausted:${g.id}`, { accountId: g.accountId, grantId: g.id, bucket: g.bucket, onExhausted: g.onExhausted, source: g.source })];
281
+ return [
282
+ announce(tx, CREDIT_EVENT.exhausted, `exhausted:${g.id}`, {
283
+ accountId: g.accountId,
284
+ grantId: g.id,
285
+ bucket: g.bucket,
286
+ onExhausted: g.onExhausted,
287
+ source: g.source
288
+ })
289
+ ];
187
290
  })
188
291
  );
189
292
  async function create(tx, kind, input) {
@@ -209,7 +312,20 @@ function createCredits(deps) {
209
312
  createdAt: now
210
313
  };
211
314
  await store.insertGrant(grant);
212
- await store.insertEntries([entry({ accountId: grant.accountId, grantId: grant.id, type: granted ? ENTRY_TYPE.grant : ENTRY_TYPE.purchase, amount: input.amount, reason: input.source, operation }, 0, now)]);
315
+ await store.insertEntries([
316
+ entry(
317
+ {
318
+ accountId: grant.accountId,
319
+ grantId: grant.id,
320
+ type: granted ? ENTRY_TYPE.grant : ENTRY_TYPE.purchase,
321
+ amount: input.amount,
322
+ reason: input.source,
323
+ operation
324
+ },
325
+ 0,
326
+ now
327
+ )
328
+ ]);
213
329
  await announce(tx, CREDIT_EVENT.granted, `granted:${grant.id}`, grantPayload(grant));
214
330
  return { grantId: grant.id, replayed: false };
215
331
  }
@@ -222,7 +338,23 @@ function createCredits(deps) {
222
338
  const plan = planDraws(states, held + args.amount, now);
223
339
  if (!plan.ok) throw new CreditsError(plan.code, REFUSAL_TEXT[plan.code] ?? plan.code);
224
340
  const draws = skipFirst(plan.draws, held);
225
- await store.insertEntries(draws.map((d, i) => entry({ accountId: args.accountId, grantId: d.grantId, type: ENTRY_TYPE.debit, amount: -d.amount, reason: args.reason, operation: args.operation, ...args.holdId ? { holdId: args.holdId } : {} }, i, now)));
341
+ await store.insertEntries(
342
+ draws.map(
343
+ (d, i) => entry(
344
+ {
345
+ accountId: args.accountId,
346
+ grantId: d.grantId,
347
+ type: ENTRY_TYPE.debit,
348
+ amount: -d.amount,
349
+ reason: args.reason,
350
+ operation: args.operation,
351
+ ...args.holdId ? { holdId: args.holdId } : {}
352
+ },
353
+ i,
354
+ now
355
+ )
356
+ )
357
+ );
226
358
  await announceExhausted(tx, states, draws);
227
359
  return draws;
228
360
  }
@@ -235,7 +367,20 @@ function createCredits(deps) {
235
367
  const now = deps.clock.now();
236
368
  const state = (await statesOf(store, found.accountId, now, timeZone)).find((s) => s.grant.id === grantId);
237
369
  if (state.closed) return { expired: 0 };
238
- await store.insertEntries([entry({ accountId: found.accountId, grantId, type: ENTRY_TYPE.expiry, amount: -state.remaining, reason: "expiry", operation: `expire.${grantId}` }, 0, now)]);
370
+ await store.insertEntries([
371
+ entry(
372
+ {
373
+ accountId: found.accountId,
374
+ grantId,
375
+ type: ENTRY_TYPE.expiry,
376
+ amount: -state.remaining,
377
+ reason: "expiry",
378
+ operation: `expire.${grantId}`
379
+ },
380
+ 0,
381
+ now
382
+ )
383
+ ]);
239
384
  return { expired: state.remaining };
240
385
  }
241
386
  return {
@@ -244,13 +389,22 @@ function createCredits(deps) {
244
389
  async consume(tx, input) {
245
390
  checkAmount(input.amount, "a consumption");
246
391
  checkKey(input.idempotencyKey);
247
- if (!input.reason || input.reason.length > MAX_REASON) throw new CreditsError(CREDITS_CODE.badGrant, `A reason is 1 to ${MAX_REASON} characters`);
392
+ if (!input.reason || input.reason.length > MAX_REASON)
393
+ throw new CreditsError(CREDITS_CODE.badGrant, `A reason is 1 to ${MAX_REASON} characters`);
248
394
  const store = storeFor(tx);
249
395
  await store.lock(input.accountId);
250
396
  const operation = `consume.${input.idempotencyKey}`;
251
397
  const before = await replayOf(store, operation, input.accountId);
252
398
  if (before.length > 0) return { draws: drawsOf(before), replayed: true };
253
- return { draws: await spend(tx, store, { accountId: input.accountId, amount: input.amount, reason: input.reason, operation }), replayed: false };
399
+ return {
400
+ draws: await spend(tx, store, {
401
+ accountId: input.accountId,
402
+ amount: input.amount,
403
+ reason: input.reason,
404
+ operation
405
+ }),
406
+ replayed: false
407
+ };
254
408
  },
255
409
  async hold(tx, input) {
256
410
  checkAmount(input.amount, "a hold");
@@ -263,14 +417,25 @@ function createCredits(deps) {
263
417
  const operation = `hold.${input.idempotencyKey}`;
264
418
  const known = await store.holdByOperation(operation);
265
419
  if (known) {
266
- if (known.accountId !== input.accountId) throw new CreditsError(CREDITS_CODE.keyReused, "This idempotency key was used for another account");
420
+ if (known.accountId !== input.accountId)
421
+ throw new CreditsError(CREDITS_CODE.keyReused, "This idempotency key was used for another account");
267
422
  return { holdId: known.id, expiresAt: known.expiresAt, replayed: true };
268
423
  }
269
424
  const now = deps.clock.now();
270
425
  const held = await store.heldAmount(input.accountId, now);
271
426
  const plan = planDraws(await statesOf(store, input.accountId, now, s.timeZone), held + input.amount, now);
272
427
  if (!plan.ok) throw new CreditsError(plan.code, REFUSAL_TEXT[plan.code] ?? plan.code);
273
- const hold = { id: newId(), accountId: input.accountId, amount: input.amount, expiresAt: addSeconds(now, ttl), status: HOLD_STATUS.active, settledAmount: null, createdAt: now, closedAt: null, operation };
428
+ const hold = {
429
+ id: newId(),
430
+ accountId: input.accountId,
431
+ amount: input.amount,
432
+ expiresAt: addSeconds(now, ttl),
433
+ status: HOLD_STATUS.active,
434
+ settledAmount: null,
435
+ createdAt: now,
436
+ closedAt: null,
437
+ operation
438
+ };
274
439
  await store.insertHold(hold);
275
440
  return { holdId: hold.id, expiresAt: hold.expiresAt, replayed: false };
276
441
  },
@@ -281,12 +446,25 @@ function createCredits(deps) {
281
446
  await store.lock(found.accountId);
282
447
  const hold = await store.holdById(holdId);
283
448
  const operation = `settle.${holdId}`;
284
- if (hold.status === HOLD_STATUS.settled) return { draws: drawsOf(await store.entriesOf(operation)), replayed: true };
285
- if (hold.status === HOLD_STATUS.released) throw new CreditsError(CREDITS_CODE.holdClosed, "This hold was released");
449
+ if (hold.status === HOLD_STATUS.settled)
450
+ return { draws: drawsOf(await store.entriesOf(operation)), replayed: true };
451
+ if (hold.status === HOLD_STATUS.released)
452
+ throw new CreditsError(CREDITS_CODE.holdClosed, "This hold was released");
286
453
  const amount = options.amount ?? hold.amount;
287
- if (!Number.isSafeInteger(amount) || amount < 0 || amount > hold.amount) throw new CreditsError(CREDITS_CODE.badAmount, `A hold of ${hold.amount} settles for 0 to ${hold.amount}, got ${amount}`);
454
+ if (!Number.isSafeInteger(amount) || amount < 0 || amount > hold.amount)
455
+ throw new CreditsError(
456
+ CREDITS_CODE.badAmount,
457
+ `A hold of ${hold.amount} settles for 0 to ${hold.amount}, got ${amount}`
458
+ );
288
459
  const now = deps.clock.now();
289
- const draws = amount === 0 ? [] : await spend(tx, store, { accountId: hold.accountId, amount, reason: options.reason ?? `hold:${holdId}`, operation, holdId, exceptHoldId: holdId });
460
+ const draws = amount === 0 ? [] : await spend(tx, store, {
461
+ accountId: hold.accountId,
462
+ amount,
463
+ reason: options.reason ?? `hold:${holdId}`,
464
+ operation,
465
+ holdId,
466
+ exceptHoldId: holdId
467
+ });
290
468
  await store.closeHold(holdId, amount === 0 ? HOLD_STATUS.released : HOLD_STATUS.settled, amount, now);
291
469
  return { draws, replayed: false };
292
470
  },
@@ -302,17 +480,36 @@ function createCredits(deps) {
302
480
  },
303
481
  async refund(tx, input) {
304
482
  const original = input.consumed !== void 0 ? `consume.${checkKey(input.consumed)}` : input.settledHold !== void 0 ? `settle.${input.settledHold}` : null;
305
- if (!original) throw new CreditsError(CREDITS_CODE.badKey, "Name what to refund: a consumed key or a settled hold");
306
- if (!input.reason || input.reason.length > MAX_REASON) throw new CreditsError(CREDITS_CODE.badGrant, `A reason is 1 to ${MAX_REASON} characters`);
483
+ if (!original)
484
+ throw new CreditsError(CREDITS_CODE.badKey, "Name what to refund: a consumed key or a settled hold");
485
+ if (!input.reason || input.reason.length > MAX_REASON)
486
+ throw new CreditsError(CREDITS_CODE.badGrant, `A reason is 1 to ${MAX_REASON} characters`);
307
487
  const store = storeFor(tx);
308
488
  await store.lock(input.accountId);
309
489
  const operation = `refund.${original}`;
310
490
  const done = await replayOf(store, operation, input.accountId);
311
491
  if (done.length > 0) return { refunded: sum(done.map((l) => l.amount)), replayed: true };
312
492
  const spent = (await replayOf(store, original, input.accountId)).filter((l) => l.type === ENTRY_TYPE.debit);
313
- if (spent.length === 0) throw new CreditsError(CREDITS_CODE.nothingToRefund, "Nothing was spent under this key for this account");
493
+ if (spent.length === 0)
494
+ throw new CreditsError(CREDITS_CODE.nothingToRefund, "Nothing was spent under this key for this account");
314
495
  const now = deps.clock.now();
315
- await store.insertEntries(spent.map((l, i) => entry({ accountId: l.accountId, grantId: l.grantId, type: ENTRY_TYPE.refund, amount: -l.amount, reason: input.reason, operation, reverses: original }, i, now)));
496
+ await store.insertEntries(
497
+ spent.map(
498
+ (l, i) => entry(
499
+ {
500
+ accountId: l.accountId,
501
+ grantId: l.grantId,
502
+ type: ENTRY_TYPE.refund,
503
+ amount: -l.amount,
504
+ reason: input.reason,
505
+ operation,
506
+ reverses: original
507
+ },
508
+ i,
509
+ now
510
+ )
511
+ )
512
+ );
316
513
  return { refunded: sum(spent.map((l) => -l.amount)), replayed: false };
317
514
  },
318
515
  expire,
@@ -325,8 +522,10 @@ function createCredits(deps) {
325
522
  },
326
523
  async adjust(tx, input) {
327
524
  checkKey(input.idempotencyKey);
328
- if (!Number.isSafeInteger(input.amount) || input.amount === 0) throw new CreditsError(CREDITS_CODE.badAmount, "An adjustment is a non-zero integer");
329
- if (!input.reason || input.reason.length > MAX_REASON) throw new CreditsError(CREDITS_CODE.badGrant, `A reason is 1 to ${MAX_REASON} characters`);
525
+ if (!Number.isSafeInteger(input.amount) || input.amount === 0)
526
+ throw new CreditsError(CREDITS_CODE.badAmount, "An adjustment is a non-zero integer");
527
+ if (!input.reason || input.reason.length > MAX_REASON)
528
+ throw new CreditsError(CREDITS_CODE.badGrant, `A reason is 1 to ${MAX_REASON} characters`);
330
529
  const store = storeFor(tx);
331
530
  await store.lock(input.accountId);
332
531
  const operation = `adjust.${input.idempotencyKey}`;
@@ -335,15 +534,31 @@ function createCredits(deps) {
335
534
  const now = deps.clock.now();
336
535
  const state = (await statesOf(store, input.accountId, now, timeZone)).find((s) => s.grant.id === input.grantId);
337
536
  if (!state) throw new CreditsError(CREDITS_CODE.unknownGrant, `No grant ${input.grantId} for this account`);
338
- if (state.remaining + input.amount < 0) throw new CreditsError(CREDITS_CODE.badAmount, "An adjustment never takes a pot below zero");
339
- await store.insertEntries([entry({ accountId: input.accountId, grantId: input.grantId, type: ENTRY_TYPE.adjustment, amount: input.amount, reason: input.reason, operation }, 0, now)]);
537
+ if (state.remaining + input.amount < 0)
538
+ throw new CreditsError(CREDITS_CODE.badAmount, "An adjustment never takes a pot below zero");
539
+ await store.insertEntries([
540
+ entry(
541
+ {
542
+ accountId: input.accountId,
543
+ grantId: input.grantId,
544
+ type: ENTRY_TYPE.adjustment,
545
+ amount: input.amount,
546
+ reason: input.reason,
547
+ operation
548
+ },
549
+ 0,
550
+ now
551
+ )
552
+ ]);
340
553
  return { replayed: false };
341
554
  },
342
555
  async balance(query, accountId) {
343
556
  const store = storeFor(query);
344
557
  const { timeZone } = await settings();
345
558
  const now = deps.clock.now();
346
- const live = (await statesOf(store, accountId, now, timeZone)).filter((s) => !s.closed && (s.grant.expiresAt === null || s.grant.expiresAt > now) && s.remaining > 0);
559
+ const live = (await statesOf(store, accountId, now, timeZone)).filter(
560
+ (s) => !s.closed && (s.grant.expiresAt === null || s.grant.expiresAt > now) && s.remaining > 0
561
+ );
347
562
  const of = (bucket) => sum(live.filter((s) => s.grant.bucket === bucket).map((s) => s.remaining));
348
563
  const total = of(BUCKET.granted) + of(BUCKET.purchased);
349
564
  const held = await store.heldAmount(accountId, now);
@@ -353,10 +568,22 @@ function createCredits(deps) {
353
568
  purchased: of(BUCKET.purchased),
354
569
  held,
355
570
  available: Math.max(0, total - held),
356
- grants: live.map((s) => ({ id: s.grant.id, bucket: s.grant.bucket, remaining: s.remaining, expiresAt: s.grant.expiresAt, source: s.grant.source, capPerDay: s.grant.capPerDay, capPerMonth: s.grant.capPerMonth, onExhausted: s.grant.onExhausted }))
571
+ grants: live.map((s) => ({
572
+ id: s.grant.id,
573
+ bucket: s.grant.bucket,
574
+ remaining: s.remaining,
575
+ expiresAt: s.grant.expiresAt,
576
+ source: s.grant.source,
577
+ capPerDay: s.grant.capPerDay,
578
+ capPerMonth: s.grant.capPerMonth,
579
+ onExhausted: s.grant.onExhausted
580
+ }))
357
581
  };
358
582
  },
359
- history: (query, accountId, options = {}) => storeFor(query).history(accountId, { limit: Math.min(options.limit ?? DEFAULT_HISTORY, MAX_HISTORY), ...options.before ? { before: options.before } : {} })
583
+ history: (query, accountId, options = {}) => storeFor(query).history(accountId, {
584
+ limit: Math.min(options.limit ?? DEFAULT_HISTORY, MAX_HISTORY),
585
+ ...options.before ? { before: options.before } : {}
586
+ })
360
587
  };
361
588
  }
362
589
  var REFUSAL_TEXT = {
@@ -399,7 +626,8 @@ function memoryCreditStore() {
399
626
  },
400
627
  async insertEntries(batch) {
401
628
  for (const e of batch) {
402
- if (entries.some((x) => x.idempotencyKey === e.idempotencyKey)) throw new Error(`duplicate idempotency key ${e.idempotencyKey}`);
629
+ if (entries.some((x) => x.idempotencyKey === e.idempotencyKey))
630
+ throw new Error(`duplicate idempotency key ${e.idempotencyKey}`);
403
631
  }
404
632
  entries.push(...batch.map((e) => ({ ...e })));
405
633
  },
@@ -421,7 +649,9 @@ function memoryCreditStore() {
421
649
  return h ? { ...h } : null;
422
650
  },
423
651
  async heldAmount(accountId, now, exceptHoldId) {
424
- return [...holds.values()].filter((h) => h.accountId === accountId && h.status === HOLD_STATUS.active && isAfter(h.expiresAt, now) && h.id !== exceptHoldId).reduce((a, h) => a + h.amount, 0);
652
+ return [...holds.values()].filter(
653
+ (h) => h.accountId === accountId && h.status === HOLD_STATUS.active && isAfter(h.expiresAt, now) && h.id !== exceptHoldId
654
+ ).reduce((a, h) => a + h.amount, 0);
425
655
  },
426
656
  async closeHold(id, status, settledAmount, at) {
427
657
  const h = holds.get(id);