@krizaka/coupons 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/{chunk-ECCGFGSV.js → chunk-V2HCEF26.js} +346 -62
- package/dist/chunk-V2HCEF26.js.map +1 -0
- package/dist/{chunk-JMQO6PTK.js → chunk-XXFDAM5G.js} +43 -13
- package/dist/chunk-XXFDAM5G.js.map +1 -0
- package/dist/index.js +2 -2
- package/dist/settings.js +1 -1
- package/dist/settings.js.map +1 -1
- package/dist/testkit.js +270 -47
- package/dist/testkit.js.map +1 -1
- package/package.json +10 -10
- package/dist/chunk-ECCGFGSV.js.map +0 -1
- package/dist/chunk-JMQO6PTK.js.map +0 -1
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
normalizeCode,
|
|
15
15
|
refusalOf,
|
|
16
16
|
termsOf
|
|
17
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-XXFDAM5G.js";
|
|
18
18
|
|
|
19
19
|
// src/kernel.ts
|
|
20
20
|
import { randomUUID } from "crypto";
|
|
@@ -41,7 +41,18 @@ var COUPON_EVENT_SCHEMAS = {
|
|
|
41
41
|
[COUPON_EVENT.redeemed]: {
|
|
42
42
|
type: "object",
|
|
43
43
|
additionalProperties: false,
|
|
44
|
-
required: [
|
|
44
|
+
required: [
|
|
45
|
+
"redemptionId",
|
|
46
|
+
"couponId",
|
|
47
|
+
"kind",
|
|
48
|
+
"purpose",
|
|
49
|
+
"partnerId",
|
|
50
|
+
"amountPaidMinor",
|
|
51
|
+
"amountDiscountMinor",
|
|
52
|
+
"currency",
|
|
53
|
+
"credits",
|
|
54
|
+
"overLimit"
|
|
55
|
+
],
|
|
45
56
|
properties: {
|
|
46
57
|
redemptionId: { type: "string" },
|
|
47
58
|
couponId: { type: "string" },
|
|
@@ -55,14 +66,31 @@ var COUPON_EVENT_SCHEMAS = {
|
|
|
55
66
|
overLimit: { type: "boolean" }
|
|
56
67
|
}
|
|
57
68
|
},
|
|
58
|
-
[COUPON_EVENT.released]: {
|
|
69
|
+
[COUPON_EVENT.released]: {
|
|
70
|
+
type: "object",
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
required: ["redemptionId", "couponId"],
|
|
73
|
+
properties: { redemptionId: { type: "string" }, couponId: { type: "string" } }
|
|
74
|
+
},
|
|
59
75
|
[COUPON_EVENT.refunded]: {
|
|
60
76
|
type: "object",
|
|
61
77
|
additionalProperties: false,
|
|
62
78
|
required: ["redemptionId", "couponId", "partnerId", "amountRefundedMinor", "currency", "useRestored"],
|
|
63
|
-
properties: {
|
|
79
|
+
properties: {
|
|
80
|
+
redemptionId: { type: "string" },
|
|
81
|
+
couponId: { type: "string" },
|
|
82
|
+
partnerId: nullableString,
|
|
83
|
+
amountRefundedMinor: { type: "integer", minimum: 0 },
|
|
84
|
+
currency: nullableString,
|
|
85
|
+
useRestored: { type: "boolean" }
|
|
86
|
+
}
|
|
64
87
|
},
|
|
65
|
-
[COUPON_EVENT.exhausted]: {
|
|
88
|
+
[COUPON_EVENT.exhausted]: {
|
|
89
|
+
type: "object",
|
|
90
|
+
additionalProperties: false,
|
|
91
|
+
required: ["couponId", "partnerId"],
|
|
92
|
+
properties: { couponId: { type: "string" }, partnerId: nullableString }
|
|
93
|
+
}
|
|
66
94
|
};
|
|
67
95
|
|
|
68
96
|
// src/persistence.ts
|
|
@@ -170,10 +198,18 @@ var REDEMPTION_COLUMNS = {
|
|
|
170
198
|
confirmedAt: "confirmed_at",
|
|
171
199
|
endedAt: "ended_at"
|
|
172
200
|
};
|
|
173
|
-
var PARTNER_COLUMNS = {
|
|
201
|
+
var PARTNER_COLUMNS = {
|
|
202
|
+
displayName: "display_name",
|
|
203
|
+
contactEmail: "contact_email",
|
|
204
|
+
status: "status",
|
|
205
|
+
erasedAt: "erased_at"
|
|
206
|
+
};
|
|
174
207
|
function setClause(patch, columns, first) {
|
|
175
208
|
const keys = Object.keys(patch).filter((key) => patch[key] !== void 0 && key in columns);
|
|
176
|
-
return {
|
|
209
|
+
return {
|
|
210
|
+
sql: keys.map((key, i) => `${columns[key]} = $${first + i}`).join(", "),
|
|
211
|
+
values: keys.map((key) => patch[key])
|
|
212
|
+
};
|
|
177
213
|
}
|
|
178
214
|
var COUNTS = `((status IN ('confirmed', 'revoked') AND NOT use_restored) OR (status = 'reserved' AND reserved_until > $NOW))`;
|
|
179
215
|
function dbCouponStore(query, schema = "coupons") {
|
|
@@ -187,10 +223,40 @@ function dbCouponStore(query, schema = "coupons") {
|
|
|
187
223
|
await query(
|
|
188
224
|
`INSERT INTO ${coupon} (id, code, code_key, kind, purpose, partner_id, status, starts_at, ends_at, max_redemptions, max_per_account, new_customers_only, plans, percent_off_bp, amount_off_minor, currency, duration, duration_months, minimum_minor, credits, cap_per_day, cap_per_month, credits_valid_days, on_exhausted, terms_version, created_at, created_by, updated_at)
|
|
189
225
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)`,
|
|
190
|
-
[
|
|
226
|
+
[
|
|
227
|
+
c.id,
|
|
228
|
+
c.code,
|
|
229
|
+
c.codeKey,
|
|
230
|
+
c.kind,
|
|
231
|
+
c.purpose,
|
|
232
|
+
c.partnerId,
|
|
233
|
+
c.status,
|
|
234
|
+
c.startsAt,
|
|
235
|
+
c.endsAt,
|
|
236
|
+
c.maxRedemptions,
|
|
237
|
+
c.maxPerAccount,
|
|
238
|
+
c.newCustomersOnly,
|
|
239
|
+
c.plans,
|
|
240
|
+
c.percentOffBp,
|
|
241
|
+
c.amountOffMinor,
|
|
242
|
+
c.currency,
|
|
243
|
+
c.duration,
|
|
244
|
+
c.durationMonths,
|
|
245
|
+
c.minimumMinor,
|
|
246
|
+
c.credits,
|
|
247
|
+
c.capPerDay,
|
|
248
|
+
c.capPerMonth,
|
|
249
|
+
c.creditsValidDays,
|
|
250
|
+
c.onExhausted,
|
|
251
|
+
c.termsVersion,
|
|
252
|
+
c.createdAt,
|
|
253
|
+
c.createdBy,
|
|
254
|
+
c.updatedAt
|
|
255
|
+
]
|
|
191
256
|
);
|
|
192
257
|
} catch (error) {
|
|
193
|
-
if (error.code === "23505")
|
|
258
|
+
if (error.code === "23505")
|
|
259
|
+
throw new CouponError(COUPON_ERROR.codeTaken, "This code is already used");
|
|
194
260
|
throw error;
|
|
195
261
|
}
|
|
196
262
|
},
|
|
@@ -199,12 +265,17 @@ function dbCouponStore(query, schema = "coupons") {
|
|
|
199
265
|
return row ? toCoupon(row) : null;
|
|
200
266
|
},
|
|
201
267
|
async couponByKey(codeKey, forUpdate = false) {
|
|
202
|
-
const [row] = await query(`SELECT * FROM ${coupon} WHERE code_key = $1${forUpdate ? " FOR UPDATE" : ""}`, [
|
|
268
|
+
const [row] = await query(`SELECT * FROM ${coupon} WHERE code_key = $1${forUpdate ? " FOR UPDATE" : ""}`, [
|
|
269
|
+
codeKey
|
|
270
|
+
]);
|
|
203
271
|
return row ? toCoupon(row) : null;
|
|
204
272
|
},
|
|
205
273
|
async updateCoupon(id, patch, at) {
|
|
206
274
|
const set = setClause(patch, COUPON_COLUMNS, 3);
|
|
207
|
-
const [row] = await query(
|
|
275
|
+
const [row] = await query(
|
|
276
|
+
`UPDATE ${coupon} SET ${set.sql ? `${set.sql}, ` : ""}updated_at = $2 WHERE id = $1 RETURNING *`,
|
|
277
|
+
[id, at, ...set.values]
|
|
278
|
+
);
|
|
208
279
|
return row ? toCoupon(row) : null;
|
|
209
280
|
},
|
|
210
281
|
async listCoupons(f) {
|
|
@@ -219,7 +290,10 @@ function dbCouponStore(query, schema = "coupons") {
|
|
|
219
290
|
return (await query(`SELECT 1 FROM ${redemption} WHERE coupon_id = $1 LIMIT 1`, [couponId])).length > 0;
|
|
220
291
|
},
|
|
221
292
|
async insertPartner(p) {
|
|
222
|
-
await query(
|
|
293
|
+
await query(
|
|
294
|
+
`INSERT INTO ${partner} (id, display_name, contact_email, status, created_at, created_by, erased_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
295
|
+
[p.id, p.displayName, p.contactEmail, p.status, p.createdAt, p.createdBy, p.erasedAt]
|
|
296
|
+
);
|
|
223
297
|
},
|
|
224
298
|
async partnerById(id) {
|
|
225
299
|
const [row] = await query(`SELECT * FROM ${partner} WHERE id = $1`, [id]);
|
|
@@ -232,14 +306,37 @@ function dbCouponStore(query, schema = "coupons") {
|
|
|
232
306
|
return row ? toPartner(row) : null;
|
|
233
307
|
},
|
|
234
308
|
async listPartners(o) {
|
|
235
|
-
const rows = await query(
|
|
309
|
+
const rows = await query(
|
|
310
|
+
`SELECT * FROM ${partner} WHERE ($1 OR status = 'active') ORDER BY created_at DESC, id LIMIT $2 OFFSET $3`,
|
|
311
|
+
[o.includeArchived, o.limit, o.offset]
|
|
312
|
+
);
|
|
236
313
|
return rows.map(toPartner);
|
|
237
314
|
},
|
|
238
315
|
async insertRedemption(r) {
|
|
239
316
|
await query(
|
|
240
317
|
`INSERT INTO ${redemption} (id, coupon_id, account_id, partner_id, status, idempotency_key, reserved_until, checkout_ref, payment_ref, amount_paid_minor, amount_discount_minor, amount_refunded_minor, use_restored, currency, credit_grant_id, over_limit, created_at, confirmed_at, ended_at)
|
|
241
318
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
|
|
242
|
-
[
|
|
319
|
+
[
|
|
320
|
+
r.id,
|
|
321
|
+
r.couponId,
|
|
322
|
+
r.accountId,
|
|
323
|
+
r.partnerId,
|
|
324
|
+
r.status,
|
|
325
|
+
r.idempotencyKey,
|
|
326
|
+
r.reservedUntil,
|
|
327
|
+
r.checkoutRef,
|
|
328
|
+
r.paymentRef,
|
|
329
|
+
r.amountPaidMinor,
|
|
330
|
+
r.amountDiscountMinor,
|
|
331
|
+
r.amountRefundedMinor,
|
|
332
|
+
r.useRestored,
|
|
333
|
+
r.currency,
|
|
334
|
+
r.creditGrantId,
|
|
335
|
+
r.overLimit,
|
|
336
|
+
r.createdAt,
|
|
337
|
+
r.confirmedAt,
|
|
338
|
+
r.endedAt
|
|
339
|
+
]
|
|
243
340
|
);
|
|
244
341
|
},
|
|
245
342
|
async redemptionById(id) {
|
|
@@ -260,7 +357,10 @@ function dbCouponStore(query, schema = "coupons") {
|
|
|
260
357
|
},
|
|
261
358
|
async updateRedemption(id, from, patch) {
|
|
262
359
|
const set = setClause(patch, REDEMPTION_COLUMNS, 3);
|
|
263
|
-
const [row] = await query(
|
|
360
|
+
const [row] = await query(
|
|
361
|
+
`UPDATE ${redemption} SET ${set.sql} WHERE id = $1 AND ($2::text[] IS NULL OR status = ANY($2::text[])) RETURNING *`,
|
|
362
|
+
[id, from ? [...from] : null, ...set.values]
|
|
363
|
+
);
|
|
264
364
|
return row ? toRedemption(row) : null;
|
|
265
365
|
},
|
|
266
366
|
async usage(couponId, accountId, now) {
|
|
@@ -304,10 +404,20 @@ function dbCouponStore(query, schema = "coupons") {
|
|
|
304
404
|
GROUP BY c.id, c.code, r.currency ORDER BY c.code, r.currency NULLS FIRST`,
|
|
305
405
|
[partnerId, now, period.from ?? null, period.to ?? null]
|
|
306
406
|
);
|
|
307
|
-
return rows.map((r) => ({
|
|
407
|
+
return rows.map((r) => ({
|
|
408
|
+
couponId: r.coupon_id,
|
|
409
|
+
code: r.code,
|
|
410
|
+
currency: r.currency ?? null,
|
|
411
|
+
redemptions: n(r.redemptions),
|
|
412
|
+
discountMinor: n(r.discount),
|
|
413
|
+
revenueMinor: n(r.revenue),
|
|
414
|
+
creditsGranted: n(r.credits_granted)
|
|
415
|
+
}));
|
|
308
416
|
},
|
|
309
417
|
async activeAmountCoupons() {
|
|
310
|
-
return (await query(
|
|
418
|
+
return (await query(
|
|
419
|
+
`SELECT * FROM ${coupon} WHERE status = 'active' AND amount_off_minor IS NOT NULL ORDER BY created_at, id`
|
|
420
|
+
)).map(toCoupon);
|
|
311
421
|
}
|
|
312
422
|
};
|
|
313
423
|
}
|
|
@@ -334,8 +444,22 @@ var bad = (message) => new CouponError(COUPON_ERROR.badInput, message);
|
|
|
334
444
|
var PAGE = 50;
|
|
335
445
|
var MAX_PAGE = 200;
|
|
336
446
|
var CODE_TRIES = 5;
|
|
337
|
-
var TERMS = [
|
|
338
|
-
|
|
447
|
+
var TERMS = [
|
|
448
|
+
"percentOffBp",
|
|
449
|
+
"amountOffMinor",
|
|
450
|
+
"currency",
|
|
451
|
+
"duration",
|
|
452
|
+
"durationMonths",
|
|
453
|
+
"credits",
|
|
454
|
+
"capPerDay",
|
|
455
|
+
"capPerMonth",
|
|
456
|
+
"creditsValidDays",
|
|
457
|
+
"onExhausted"
|
|
458
|
+
];
|
|
459
|
+
var page = (limit, offset) => ({
|
|
460
|
+
limit: Math.min(Math.max(limit ?? PAGE, 1), MAX_PAGE),
|
|
461
|
+
offset: Math.max(offset ?? 0, 0)
|
|
462
|
+
});
|
|
339
463
|
function makeAdmin(k) {
|
|
340
464
|
const must = async (found, code, what) => {
|
|
341
465
|
const value = await found;
|
|
@@ -343,7 +467,8 @@ function makeAdmin(k) {
|
|
|
343
467
|
return value;
|
|
344
468
|
};
|
|
345
469
|
const partnerOk = async (tx, partnerId) => {
|
|
346
|
-
if (partnerId)
|
|
470
|
+
if (partnerId)
|
|
471
|
+
await must(k.store(tx).partnerById(partnerId), COUPON_ERROR.partnerNotFound, `No partner ${partnerId}`);
|
|
347
472
|
};
|
|
348
473
|
async function generateFreeCode(query) {
|
|
349
474
|
const length = (await k.settings()).generatedCodeLength;
|
|
@@ -392,20 +517,42 @@ function makeAdmin(k) {
|
|
|
392
517
|
updatedAt: now
|
|
393
518
|
};
|
|
394
519
|
await store.insertCoupon(coupon);
|
|
395
|
-
await k.record(
|
|
520
|
+
await k.record(
|
|
521
|
+
tx,
|
|
522
|
+
actor,
|
|
523
|
+
"coupon.created",
|
|
524
|
+
{ type: "coupon", id: coupon.id },
|
|
525
|
+
{ code: coupon.code, kind: coupon.kind, purpose: coupon.purpose, partnerId: coupon.partnerId }
|
|
526
|
+
);
|
|
396
527
|
return coupon;
|
|
397
528
|
}
|
|
398
529
|
async function update(tx, actor, id, patch) {
|
|
399
530
|
const store = k.store(tx);
|
|
400
531
|
const before = await must(store.couponById(id, true), COUPON_ERROR.notFound, `No coupon ${id}`);
|
|
401
|
-
const changed = Object.keys(patch).filter(
|
|
532
|
+
const changed = Object.keys(patch).filter(
|
|
533
|
+
(key) => patch[key] !== void 0 && JSON.stringify(patch[key]) !== JSON.stringify(before[key])
|
|
534
|
+
);
|
|
402
535
|
if (changed.length === 0) return before;
|
|
403
|
-
if (changed.some((key) => TERMS.includes(key)) && await store.hasRedemptions(id))
|
|
536
|
+
if (changed.some((key) => TERMS.includes(key)) && await store.hasRedemptions(id))
|
|
537
|
+
throw new CouponError(
|
|
538
|
+
COUPON_ERROR.termsLocked,
|
|
539
|
+
"The terms of a coupon that has been used are locked: make a new coupon"
|
|
540
|
+
);
|
|
404
541
|
const next = { ...before, ...patch };
|
|
405
542
|
checkCoupon(next, await k.settings());
|
|
406
543
|
await partnerOk(tx, patch.partnerId);
|
|
407
544
|
const after = await store.updateCoupon(id, patch, k.now());
|
|
408
|
-
await k.record(
|
|
545
|
+
await k.record(
|
|
546
|
+
tx,
|
|
547
|
+
actor,
|
|
548
|
+
"coupon.updated",
|
|
549
|
+
{ type: "coupon", id },
|
|
550
|
+
{
|
|
551
|
+
changed,
|
|
552
|
+
before: Object.fromEntries(changed.map((key) => [key, before[key]])),
|
|
553
|
+
after: Object.fromEntries(changed.map((key) => [key, after[key]]))
|
|
554
|
+
}
|
|
555
|
+
);
|
|
409
556
|
return after;
|
|
410
557
|
}
|
|
411
558
|
async function setStatus(tx, actor, id, status, action) {
|
|
@@ -421,7 +568,9 @@ function makeAdmin(k) {
|
|
|
421
568
|
const { maxPercentOff } = await k.settings();
|
|
422
569
|
const paused = [];
|
|
423
570
|
for (const c of await k.store(tx).activeAmountCoupons()) {
|
|
424
|
-
const applicable = prices.filter(
|
|
571
|
+
const applicable = prices.filter(
|
|
572
|
+
(p) => p.currency === c.currency && (c.plans.length === 0 || c.plans.includes(p.plan))
|
|
573
|
+
);
|
|
425
574
|
if (!applicable.some((p) => c.amountOffMinor * 100 > p.amountMinor * maxPercentOff)) continue;
|
|
426
575
|
await k.store(tx).updateCoupon(c.id, { status: COUPON_STATUS.paused }, k.now());
|
|
427
576
|
await k.record(tx, SYSTEM, "coupon.paused_by_price", { type: "coupon", id: c.id }, { maxPercentOff });
|
|
@@ -434,7 +583,15 @@ function makeAdmin(k) {
|
|
|
434
583
|
const email = input.contactEmail?.trim().toLowerCase() || null;
|
|
435
584
|
if (name.length < 1 || name.length > 80) throw bad("A partner has a display name of 1 to 80 characters");
|
|
436
585
|
if (email !== null && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw bad("A contact e-mail looks like a@b.c");
|
|
437
|
-
const partner = {
|
|
586
|
+
const partner = {
|
|
587
|
+
id: k.newId(),
|
|
588
|
+
displayName: name,
|
|
589
|
+
contactEmail: email,
|
|
590
|
+
status: "active",
|
|
591
|
+
createdAt: k.now(),
|
|
592
|
+
createdBy: actor.id,
|
|
593
|
+
erasedAt: null
|
|
594
|
+
};
|
|
438
595
|
await k.store(tx).insertPartner(partner);
|
|
439
596
|
await k.record(tx, actor, "partner.created", { type: "partner", id: partner.id }, {});
|
|
440
597
|
return partner;
|
|
@@ -487,7 +644,13 @@ function makeAdmin(k) {
|
|
|
487
644
|
|
|
488
645
|
// src/flows.ts
|
|
489
646
|
import { addHours, addMinutes } from "@krizaka/time";
|
|
490
|
-
var view = (c) => ({
|
|
647
|
+
var view = (c) => ({
|
|
648
|
+
id: c.id,
|
|
649
|
+
code: c.code,
|
|
650
|
+
kind: c.kind,
|
|
651
|
+
purpose: c.purpose,
|
|
652
|
+
endsAt: c.endsAt
|
|
653
|
+
});
|
|
491
654
|
var REFUSALS = Object.values(COUPON_CODE);
|
|
492
655
|
var isRefusal = (code) => REFUSALS.includes(code);
|
|
493
656
|
var refuse = (code, retryAfterSeconds) => new CouponError(code, code, retryAfterSeconds);
|
|
@@ -528,7 +691,12 @@ function makeFlows(k) {
|
|
|
528
691
|
await check(query, coupon, ctx);
|
|
529
692
|
return { ok: true, coupon: view(coupon), effect: effectOf(coupon, ctx.amountMinor ?? 0) };
|
|
530
693
|
} catch (error) {
|
|
531
|
-
if (error instanceof CouponError && isRefusal(error.code))
|
|
694
|
+
if (error instanceof CouponError && isRefusal(error.code))
|
|
695
|
+
return {
|
|
696
|
+
ok: false,
|
|
697
|
+
code: error.code,
|
|
698
|
+
...error.retryAfterSeconds === void 0 ? {} : { retryAfterSeconds: error.retryAfterSeconds }
|
|
699
|
+
};
|
|
532
700
|
throw error;
|
|
533
701
|
}
|
|
534
702
|
}
|
|
@@ -541,8 +709,15 @@ function makeFlows(k) {
|
|
|
541
709
|
const store = k.store(tx);
|
|
542
710
|
const known = await store.redemptionByKey(input.idempotencyKey);
|
|
543
711
|
if (known) {
|
|
544
|
-
if (known.couponId !== coupon.id || known.accountId !== input.accountId)
|
|
545
|
-
|
|
712
|
+
if (known.couponId !== coupon.id || known.accountId !== input.accountId)
|
|
713
|
+
throw new CouponError(COUPON_ERROR.keyReused, "This idempotency key was used for another coupon or account");
|
|
714
|
+
return {
|
|
715
|
+
redemptionId: known.id,
|
|
716
|
+
discount: termsOf(coupon),
|
|
717
|
+
effect: effectOf(coupon, input.amountMinor),
|
|
718
|
+
reservedUntil: known.reservedUntil ?? known.createdAt,
|
|
719
|
+
replayed: true
|
|
720
|
+
};
|
|
546
721
|
}
|
|
547
722
|
await check(tx, coupon, ctx);
|
|
548
723
|
const now = k.now();
|
|
@@ -569,7 +744,13 @@ function makeFlows(k) {
|
|
|
569
744
|
endedAt: null
|
|
570
745
|
};
|
|
571
746
|
await store.insertRedemption(redemption);
|
|
572
|
-
return {
|
|
747
|
+
return {
|
|
748
|
+
redemptionId: redemption.id,
|
|
749
|
+
discount: termsOf(coupon),
|
|
750
|
+
effect: effectOf(coupon, input.amountMinor),
|
|
751
|
+
reservedUntil,
|
|
752
|
+
replayed: false
|
|
753
|
+
};
|
|
573
754
|
}
|
|
574
755
|
async function attachCheckout(tx, redemptionId, checkoutRef, sessionExpiresAt) {
|
|
575
756
|
const store = k.store(tx);
|
|
@@ -578,7 +759,8 @@ function makeFlows(k) {
|
|
|
578
759
|
await store.updateRedemption(redemptionId, null, { checkoutRef });
|
|
579
760
|
if (r.status !== REDEMPTION_STATUS.reserved || !sessionExpiresAt) return;
|
|
580
761
|
const wanted = addMinutes(sessionExpiresAt, ATTACH_GRACE_MINUTES);
|
|
581
|
-
if (!r.reservedUntil || wanted > r.reservedUntil)
|
|
762
|
+
if (!r.reservedUntil || wanted > r.reservedUntil)
|
|
763
|
+
await store.updateRedemption(redemptionId, [REDEMPTION_STATUS.reserved], { reservedUntil: wanted });
|
|
582
764
|
}
|
|
583
765
|
async function findFor(tx, ref) {
|
|
584
766
|
const store = k.store(tx);
|
|
@@ -591,7 +773,8 @@ function makeFlows(k) {
|
|
|
591
773
|
const coupon = await store.couponById(found.couponId, true);
|
|
592
774
|
const r = await store.redemptionById(found.id);
|
|
593
775
|
const withRef = input.paymentRef ? await store.redemptionByPaymentRef(input.paymentRef) : null;
|
|
594
|
-
if (withRef || r.status !== REDEMPTION_STATUS.reserved && r.status !== REDEMPTION_STATUS.released)
|
|
776
|
+
if (withRef || r.status !== REDEMPTION_STATUS.reserved && r.status !== REDEMPTION_STATUS.released)
|
|
777
|
+
return "already";
|
|
595
778
|
const now = k.now();
|
|
596
779
|
const others = (await store.usage(coupon.id, null, now)).total - (countsAsUse(r, now) ? 1 : 0);
|
|
597
780
|
const late = r.status === REDEMPTION_STATUS.released;
|
|
@@ -612,7 +795,19 @@ function makeFlows(k) {
|
|
|
612
795
|
return "confirmed";
|
|
613
796
|
}
|
|
614
797
|
async function confirmed(tx, coupon, r, usesNow) {
|
|
615
|
-
await k.record(
|
|
798
|
+
await k.record(
|
|
799
|
+
tx,
|
|
800
|
+
SYSTEM,
|
|
801
|
+
"coupon.redeemed",
|
|
802
|
+
{ type: "coupon", id: coupon.id },
|
|
803
|
+
{
|
|
804
|
+
redemptionId: r.id,
|
|
805
|
+
kind: coupon.kind,
|
|
806
|
+
overLimit: r.overLimit,
|
|
807
|
+
amountPaidMinor: r.amountPaidMinor,
|
|
808
|
+
amountDiscountMinor: r.amountDiscountMinor
|
|
809
|
+
}
|
|
810
|
+
);
|
|
616
811
|
await k.announce(tx, COUPON_EVENT.redeemed, `redeemed:${r.id}`, {
|
|
617
812
|
redemptionId: r.id,
|
|
618
813
|
couponId: coupon.id,
|
|
@@ -625,19 +820,31 @@ function makeFlows(k) {
|
|
|
625
820
|
credits: coupon.kind === KIND.credit ? coupon.credits : null,
|
|
626
821
|
overLimit: r.overLimit
|
|
627
822
|
});
|
|
628
|
-
if (coupon.maxRedemptions !== null && usesNow === coupon.maxRedemptions)
|
|
823
|
+
if (coupon.maxRedemptions !== null && usesNow === coupon.maxRedemptions)
|
|
824
|
+
await k.announce(tx, COUPON_EVENT.exhausted, `exhausted:${coupon.id}`, {
|
|
825
|
+
couponId: coupon.id,
|
|
826
|
+
partnerId: coupon.partnerId
|
|
827
|
+
});
|
|
629
828
|
}
|
|
630
829
|
async function release(tx, ref) {
|
|
631
830
|
const found = await findFor(tx, ref);
|
|
632
831
|
if (!found) return false;
|
|
633
|
-
const done = await k.store(tx).updateRedemption(found.id, [REDEMPTION_STATUS.reserved], {
|
|
832
|
+
const done = await k.store(tx).updateRedemption(found.id, [REDEMPTION_STATUS.reserved], {
|
|
833
|
+
status: REDEMPTION_STATUS.released,
|
|
834
|
+
reservedUntil: null,
|
|
835
|
+
endedAt: k.now()
|
|
836
|
+
});
|
|
634
837
|
if (!done) return false;
|
|
635
|
-
await k.announce(tx, COUPON_EVENT.released, `released:${done.id}`, {
|
|
838
|
+
await k.announce(tx, COUPON_EVENT.released, `released:${done.id}`, {
|
|
839
|
+
redemptionId: done.id,
|
|
840
|
+
couponId: done.couponId
|
|
841
|
+
});
|
|
636
842
|
return true;
|
|
637
843
|
}
|
|
638
844
|
async function releaseExpired(tx) {
|
|
639
845
|
const released = await k.store(tx).releaseExpired(k.now(), SWEEP_BATCH);
|
|
640
|
-
for (const r of released)
|
|
846
|
+
for (const r of released)
|
|
847
|
+
await k.announce(tx, COUPON_EVENT.released, `released:${r.id}`, { redemptionId: r.id, couponId: r.couponId });
|
|
641
848
|
return released.length;
|
|
642
849
|
}
|
|
643
850
|
async function refund(tx, input) {
|
|
@@ -648,21 +855,45 @@ function makeFlows(k) {
|
|
|
648
855
|
if (total === r.amountRefundedMinor) return "already";
|
|
649
856
|
const full = r.amountPaidMinor !== null && r.amountPaidMinor > 0 && total >= r.amountPaidMinor;
|
|
650
857
|
const restore = full && (await k.settings()).refundRestoresUse && !r.useRestored;
|
|
651
|
-
const done = await store.updateRedemption(r.id, null, {
|
|
652
|
-
|
|
653
|
-
|
|
858
|
+
const done = await store.updateRedemption(r.id, null, {
|
|
859
|
+
amountRefundedMinor: total,
|
|
860
|
+
...restore ? { useRestored: true } : {}
|
|
861
|
+
});
|
|
862
|
+
await k.record(
|
|
863
|
+
tx,
|
|
864
|
+
SYSTEM,
|
|
865
|
+
"coupon.refunded",
|
|
866
|
+
{ type: "coupon", id: r.couponId },
|
|
867
|
+
{ redemptionId: r.id, amountRefundedMinor: total, useRestored: done.useRestored }
|
|
868
|
+
);
|
|
869
|
+
await k.announce(tx, COUPON_EVENT.refunded, `refunded:${r.id}:${total}`, {
|
|
870
|
+
redemptionId: r.id,
|
|
871
|
+
couponId: r.couponId,
|
|
872
|
+
partnerId: r.partnerId,
|
|
873
|
+
amountRefundedMinor: total,
|
|
874
|
+
currency: r.currency,
|
|
875
|
+
useRestored: done.useRestored
|
|
876
|
+
});
|
|
654
877
|
return "refunded";
|
|
655
878
|
}
|
|
656
879
|
async function redeemCredit(tx, input) {
|
|
657
880
|
const credits = k.deps.credits;
|
|
658
|
-
if (!credits)
|
|
881
|
+
if (!credits)
|
|
882
|
+
throw new CouponError(
|
|
883
|
+
COUPON_ERROR.badSettings,
|
|
884
|
+
"Credit coupons need the credits ledger in the module's dependencies"
|
|
885
|
+
);
|
|
659
886
|
await mustBeEnabled();
|
|
660
887
|
await attempt(input);
|
|
661
888
|
const coupon = await lookup(tx, input.code, true);
|
|
662
889
|
const store = k.store(tx);
|
|
663
890
|
const known = await store.redemptionByKey(input.idempotencyKey);
|
|
664
891
|
if (known) return replayOf(known, coupon, input.accountId);
|
|
665
|
-
await check(tx, coupon, {
|
|
892
|
+
await check(tx, coupon, {
|
|
893
|
+
mode: "redeem",
|
|
894
|
+
accountId: input.accountId,
|
|
895
|
+
...input.hasPaidBefore === void 0 ? {} : { hasPaidBefore: input.hasPaidBefore }
|
|
896
|
+
});
|
|
666
897
|
const now = k.now();
|
|
667
898
|
const expiresAt = addHours(now, coupon.creditsValidDays * 24);
|
|
668
899
|
const redemption = {
|
|
@@ -687,26 +918,52 @@ function makeFlows(k) {
|
|
|
687
918
|
endedAt: null
|
|
688
919
|
};
|
|
689
920
|
await store.insertRedemption(redemption);
|
|
690
|
-
const { grantId } = await credits.grant(tx, {
|
|
921
|
+
const { grantId } = await credits.grant(tx, {
|
|
922
|
+
accountId: input.accountId,
|
|
923
|
+
amount: coupon.credits,
|
|
924
|
+
capPerDay: coupon.capPerDay,
|
|
925
|
+
capPerMonth: coupon.capPerMonth,
|
|
926
|
+
expiresAt,
|
|
927
|
+
onExhausted: coupon.onExhausted,
|
|
928
|
+
source: `coupon:${redemption.id}`,
|
|
929
|
+
idempotencyKey: `coupon-${redemption.id}`
|
|
930
|
+
});
|
|
691
931
|
const done = await store.updateRedemption(redemption.id, null, { creditGrantId: grantId });
|
|
692
932
|
await confirmed(tx, coupon, done, (await store.usage(coupon.id, null, now)).total);
|
|
693
933
|
return { redemptionId: done.id, grantId, credits: coupon.credits, expiresAt, replayed: false };
|
|
694
934
|
}
|
|
695
935
|
function replayOf(known, coupon, accountId) {
|
|
696
|
-
if (known.couponId !== coupon.id || known.accountId !== accountId || !known.creditGrantId)
|
|
697
|
-
|
|
936
|
+
if (known.couponId !== coupon.id || known.accountId !== accountId || !known.creditGrantId)
|
|
937
|
+
throw new CouponError(COUPON_ERROR.keyReused, "This idempotency key was used for another coupon or account");
|
|
938
|
+
return {
|
|
939
|
+
redemptionId: known.id,
|
|
940
|
+
grantId: known.creditGrantId,
|
|
941
|
+
credits: coupon.credits,
|
|
942
|
+
expiresAt: addHours(known.createdAt, coupon.creditsValidDays * 24),
|
|
943
|
+
replayed: true
|
|
944
|
+
};
|
|
698
945
|
}
|
|
699
946
|
async function revoke(tx, actor, redemptionId) {
|
|
700
947
|
const store = k.store(tx);
|
|
701
948
|
const r = await store.redemptionById(redemptionId);
|
|
702
949
|
if (!r) throw new CouponError(COUPON_ERROR.redemptionNotFound, `No redemption ${redemptionId}`);
|
|
703
950
|
if (r.status === REDEMPTION_STATUS.revoked) return;
|
|
704
|
-
if (r.status !== REDEMPTION_STATUS.confirmed || !r.creditGrantId || !k.deps.credits)
|
|
951
|
+
if (r.status !== REDEMPTION_STATUS.confirmed || !r.creditGrantId || !k.deps.credits)
|
|
952
|
+
throw new CouponError(COUPON_ERROR.notRevocable, "Only a confirmed credit redemption can be revoked");
|
|
705
953
|
await store.couponById(r.couponId, true);
|
|
706
|
-
const done = await store.updateRedemption(r.id, [REDEMPTION_STATUS.confirmed], {
|
|
954
|
+
const done = await store.updateRedemption(r.id, [REDEMPTION_STATUS.confirmed], {
|
|
955
|
+
status: REDEMPTION_STATUS.revoked,
|
|
956
|
+
endedAt: k.now()
|
|
957
|
+
});
|
|
707
958
|
if (!done) return;
|
|
708
959
|
const { expired } = await k.deps.credits.expire(tx, r.creditGrantId);
|
|
709
|
-
await k.record(
|
|
960
|
+
await k.record(
|
|
961
|
+
tx,
|
|
962
|
+
actor,
|
|
963
|
+
"coupon.revoked",
|
|
964
|
+
{ type: "coupon", id: r.couponId },
|
|
965
|
+
{ redemptionId: r.id, creditsWrittenOff: expired }
|
|
966
|
+
);
|
|
710
967
|
}
|
|
711
968
|
return { validate, apply, attachCheckout, redeem, release, releaseExpired, refund, redeemCredit, revoke };
|
|
712
969
|
}
|
|
@@ -725,13 +982,15 @@ function memoryCouponStore() {
|
|
|
725
982
|
const partners = /* @__PURE__ */ new Map();
|
|
726
983
|
const redemptions = /* @__PURE__ */ new Map();
|
|
727
984
|
const unique = (field, value, except) => {
|
|
728
|
-
if (value !== null && [...redemptions.values()].some((r) => r.id !== except && r[field] === value))
|
|
985
|
+
if (value !== null && [...redemptions.values()].some((r) => r.id !== except && r[field] === value))
|
|
986
|
+
throw new Error(`duplicate ${field}`);
|
|
729
987
|
};
|
|
730
988
|
const page2 = (rows, limit, offset) => rows.slice(offset, offset + limit);
|
|
731
989
|
const newestFirst = (rows) => rows.sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf() || a.id.localeCompare(b.id));
|
|
732
990
|
return {
|
|
733
991
|
async insertCoupon(c) {
|
|
734
|
-
if ([...coupons.values()].some((x) => x.codeKey === c.codeKey))
|
|
992
|
+
if ([...coupons.values()].some((x) => x.codeKey === c.codeKey))
|
|
993
|
+
throw Object.assign(new Error("duplicate code_key"), { code: "23505" });
|
|
735
994
|
coupons.set(c.id, clone(c));
|
|
736
995
|
},
|
|
737
996
|
async couponById(id) {
|
|
@@ -745,12 +1004,15 @@ function memoryCouponStore() {
|
|
|
745
1004
|
async updateCoupon(id, patch, at) {
|
|
746
1005
|
const c = coupons.get(id);
|
|
747
1006
|
if (!c) return null;
|
|
748
|
-
for (const [key, value] of Object.entries(patch))
|
|
1007
|
+
for (const [key, value] of Object.entries(patch))
|
|
1008
|
+
if (value !== void 0) c[key] = value;
|
|
749
1009
|
c.updatedAt = at;
|
|
750
1010
|
return clone(c);
|
|
751
1011
|
},
|
|
752
1012
|
async listCoupons(f) {
|
|
753
|
-
const rows = [...coupons.values()].filter(
|
|
1013
|
+
const rows = [...coupons.values()].filter(
|
|
1014
|
+
(c) => (!f.status || c.status === f.status) && (!f.kind || c.kind === f.kind) && (!f.purpose || c.purpose === f.purpose) && (!f.partnerId || c.partnerId === f.partnerId)
|
|
1015
|
+
);
|
|
754
1016
|
return page2(newestFirst(rows), f.limit, f.offset).map(clone);
|
|
755
1017
|
},
|
|
756
1018
|
async hasRedemptions(couponId) {
|
|
@@ -766,11 +1028,16 @@ function memoryCouponStore() {
|
|
|
766
1028
|
async updatePartner(id, patch) {
|
|
767
1029
|
const p = partners.get(id);
|
|
768
1030
|
if (!p) return null;
|
|
769
|
-
for (const [key, value] of Object.entries(patch))
|
|
1031
|
+
for (const [key, value] of Object.entries(patch))
|
|
1032
|
+
if (value !== void 0) p[key] = value;
|
|
770
1033
|
return clone(p);
|
|
771
1034
|
},
|
|
772
1035
|
async listPartners(o) {
|
|
773
|
-
return page2(
|
|
1036
|
+
return page2(
|
|
1037
|
+
newestFirst([...partners.values()].filter((p) => o.includeArchived || p.status === PARTNER_ACTIVE)),
|
|
1038
|
+
o.limit,
|
|
1039
|
+
o.offset
|
|
1040
|
+
).map(clone);
|
|
774
1041
|
},
|
|
775
1042
|
async insertRedemption(r) {
|
|
776
1043
|
unique("idempotencyKey", r.idempotencyKey, r.id);
|
|
@@ -799,15 +1066,21 @@ function memoryCouponStore() {
|
|
|
799
1066
|
if (!r || from && !from.includes(r.status)) return null;
|
|
800
1067
|
unique("checkoutRef", patch.checkoutRef ?? null, id);
|
|
801
1068
|
unique("paymentRef", patch.paymentRef ?? null, id);
|
|
802
|
-
for (const [key, value] of Object.entries(patch))
|
|
1069
|
+
for (const [key, value] of Object.entries(patch))
|
|
1070
|
+
if (value !== void 0) r[key] = value;
|
|
803
1071
|
return clone(r);
|
|
804
1072
|
},
|
|
805
1073
|
async usage(couponId, accountId, now) {
|
|
806
1074
|
const live = [...redemptions.values()].filter((r) => r.couponId === couponId && countsAsUse(r, now));
|
|
807
|
-
return {
|
|
1075
|
+
return {
|
|
1076
|
+
total: live.length,
|
|
1077
|
+
account: accountId === null ? 0 : live.filter((r) => r.accountId === accountId).length
|
|
1078
|
+
};
|
|
808
1079
|
},
|
|
809
1080
|
async listRedemptions(f) {
|
|
810
|
-
const rows = [...redemptions.values()].filter(
|
|
1081
|
+
const rows = [...redemptions.values()].filter(
|
|
1082
|
+
(r) => (!f.couponId || r.couponId === f.couponId) && (!f.partnerId || r.partnerId === f.partnerId) && (!f.status || r.status === f.status) && (!f.from || r.createdAt >= f.from) && (!f.to || r.createdAt < f.to)
|
|
1083
|
+
);
|
|
811
1084
|
return page2(newestFirst(rows), f.limit, f.offset).map(clone);
|
|
812
1085
|
},
|
|
813
1086
|
async releaseExpired(now, limit) {
|
|
@@ -819,17 +1092,28 @@ function memoryCouponStore() {
|
|
|
819
1092
|
const rows = /* @__PURE__ */ new Map();
|
|
820
1093
|
for (const r of redemptions.values()) {
|
|
821
1094
|
const at = r.confirmedAt ?? r.createdAt;
|
|
822
|
-
if (r.partnerId !== partnerId || !countsAsUse(r, now) || period.from && at < period.from || period.to && at >= period.to)
|
|
1095
|
+
if (r.partnerId !== partnerId || !countsAsUse(r, now) || period.from && at < period.from || period.to && at >= period.to)
|
|
1096
|
+
continue;
|
|
823
1097
|
const c = coupons.get(r.couponId);
|
|
824
1098
|
const key = `${c.id}|${r.currency ?? ""}`;
|
|
825
|
-
const row = rows.get(key) ?? {
|
|
1099
|
+
const row = rows.get(key) ?? {
|
|
1100
|
+
couponId: c.id,
|
|
1101
|
+
code: c.code,
|
|
1102
|
+
currency: r.currency,
|
|
1103
|
+
redemptions: 0,
|
|
1104
|
+
discountMinor: 0,
|
|
1105
|
+
revenueMinor: 0,
|
|
1106
|
+
creditsGranted: 0
|
|
1107
|
+
};
|
|
826
1108
|
row.redemptions += 1;
|
|
827
1109
|
row.discountMinor += r.amountDiscountMinor ?? 0;
|
|
828
1110
|
row.revenueMinor += (r.amountPaidMinor ?? 0) - r.amountRefundedMinor;
|
|
829
1111
|
row.creditsGranted += c.kind === KIND.credit ? c.credits ?? 0 : 0;
|
|
830
1112
|
rows.set(key, row);
|
|
831
1113
|
}
|
|
832
|
-
return [...rows.values()].sort(
|
|
1114
|
+
return [...rows.values()].sort(
|
|
1115
|
+
(a, b) => a.code.localeCompare(b.code) || (a.currency ?? "").localeCompare(b.currency ?? "")
|
|
1116
|
+
);
|
|
833
1117
|
},
|
|
834
1118
|
async activeAmountCoupons() {
|
|
835
1119
|
return [...coupons.values()].filter((c) => c.status === COUPON_STATUS.active && c.amountOffMinor !== null).sort((a, b) => a.createdAt.valueOf() - b.createdAt.valueOf() || a.id.localeCompare(b.id)).map(clone);
|
|
@@ -937,4 +1221,4 @@ export {
|
|
|
937
1221
|
memoryCouponStore,
|
|
938
1222
|
couponsSchema
|
|
939
1223
|
};
|
|
940
|
-
//# sourceMappingURL=chunk-
|
|
1224
|
+
//# sourceMappingURL=chunk-V2HCEF26.js.map
|