@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/README.md +1 -1
- 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 +5 -5
- package/dist/chunk-34Y2HXOB.js.map +0 -1
- package/dist/chunk-WDHQ7QHY.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
checkSettings,
|
|
8
8
|
planDraws,
|
|
9
9
|
skipFirst
|
|
10
|
-
} from "./chunk-
|
|
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-
|
|
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) => ({
|
|
74
|
-
|
|
75
|
-
|
|
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(
|
|
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) => ({
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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`, [
|
|
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))
|
|
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) => ({
|
|
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 [
|
|
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([
|
|
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(
|
|
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([
|
|
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)
|
|
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 {
|
|
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)
|
|
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 = {
|
|
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)
|
|
285
|
-
|
|
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)
|
|
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, {
|
|
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)
|
|
306
|
-
|
|
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)
|
|
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(
|
|
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)
|
|
329
|
-
|
|
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)
|
|
339
|
-
|
|
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(
|
|
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) => ({
|
|
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, {
|
|
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))
|
|
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(
|
|
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);
|