@krizaka/coupons 0.1.3 → 0.1.5

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.
@@ -14,7 +14,7 @@ import {
14
14
  normalizeCode,
15
15
  refusalOf,
16
16
  termsOf
17
- } from "./chunk-JMQO6PTK.js";
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: ["redemptionId", "couponId", "kind", "purpose", "partnerId", "amountPaidMinor", "amountDiscountMinor", "currency", "credits", "overLimit"],
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]: { type: "object", additionalProperties: false, required: ["redemptionId", "couponId"], properties: { redemptionId: { type: "string" }, couponId: { type: "string" } } },
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: { redemptionId: { type: "string" }, couponId: { type: "string" }, partnerId: nullableString, amountRefundedMinor: { type: "integer", minimum: 0 }, currency: nullableString, useRestored: { type: "boolean" } }
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]: { type: "object", additionalProperties: false, required: ["couponId", "partnerId"], properties: { couponId: { type: "string" }, partnerId: nullableString } }
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 = { displayName: "display_name", contactEmail: "contact_email", status: "status", erasedAt: "erased_at" };
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 { sql: keys.map((key, i) => `${columns[key]} = $${first + i}`).join(", "), values: keys.map((key) => patch[key]) };
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
- [c.id, c.code, c.codeKey, c.kind, c.purpose, c.partnerId, c.status, c.startsAt, c.endsAt, c.maxRedemptions, c.maxPerAccount, c.newCustomersOnly, c.plans, c.percentOffBp, c.amountOffMinor, c.currency, c.duration, c.durationMonths, c.minimumMinor, c.credits, c.capPerDay, c.capPerMonth, c.creditsValidDays, c.onExhausted, c.termsVersion, c.createdAt, c.createdBy, c.updatedAt]
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") throw new CouponError(COUPON_ERROR.codeTaken, "This code is already used");
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" : ""}`, [codeKey]);
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(`UPDATE ${coupon} SET ${set.sql ? `${set.sql}, ` : ""}updated_at = $2 WHERE id = $1 RETURNING *`, [id, at, ...set.values]);
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(`INSERT INTO ${partner} (id, display_name, contact_email, status, created_at, created_by, erased_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [p.id, p.displayName, p.contactEmail, p.status, p.createdAt, p.createdBy, p.erasedAt]);
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(`SELECT * FROM ${partner} WHERE ($1 OR status = 'active') ORDER BY created_at DESC, id LIMIT $2 OFFSET $3`, [o.includeArchived, o.limit, o.offset]);
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
- [r.id, r.couponId, r.accountId, r.partnerId, r.status, r.idempotencyKey, r.reservedUntil, r.checkoutRef, r.paymentRef, r.amountPaidMinor, r.amountDiscountMinor, r.amountRefundedMinor, r.useRestored, r.currency, r.creditGrantId, r.overLimit, r.createdAt, r.confirmedAt, r.endedAt]
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(`UPDATE ${redemption} SET ${set.sql} WHERE id = $1 AND ($2::text[] IS NULL OR status = ANY($2::text[])) RETURNING *`, [id, from ? [...from] : null, ...set.values]);
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) => ({ couponId: r.coupon_id, code: r.code, currency: r.currency ?? null, redemptions: n(r.redemptions), discountMinor: n(r.discount), revenueMinor: n(r.revenue), creditsGranted: n(r.credits_granted) }));
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(`SELECT * FROM ${coupon} WHERE status = 'active' AND amount_off_minor IS NOT NULL ORDER BY created_at, id`)).map(toCoupon);
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 = ["percentOffBp", "amountOffMinor", "currency", "duration", "durationMonths", "credits", "capPerDay", "capPerMonth", "creditsValidDays", "onExhausted"];
338
- var page = (limit, offset) => ({ limit: Math.min(Math.max(limit ?? PAGE, 1), MAX_PAGE), offset: Math.max(offset ?? 0, 0) });
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) await must(k.store(tx).partnerById(partnerId), COUPON_ERROR.partnerNotFound, `No partner ${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(tx, actor, "coupon.created", { type: "coupon", id: coupon.id }, { code: coupon.code, kind: coupon.kind, purpose: coupon.purpose, partnerId: coupon.partnerId });
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((key) => patch[key] !== void 0 && JSON.stringify(patch[key]) !== JSON.stringify(before[key]));
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)) throw new CouponError(COUPON_ERROR.termsLocked, "The terms of a coupon that has been used are locked: make a new coupon");
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(tx, actor, "coupon.updated", { type: "coupon", id }, { changed, before: Object.fromEntries(changed.map((key) => [key, before[key]])), after: Object.fromEntries(changed.map((key) => [key, after[key]])) });
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((p) => p.currency === c.currency && (c.plans.length === 0 || c.plans.includes(p.plan)));
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 = { id: k.newId(), displayName: name, contactEmail: email, status: "active", createdAt: k.now(), createdBy: actor.id, erasedAt: null };
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) => ({ id: c.id, code: c.code, kind: c.kind, purpose: c.purpose, endsAt: c.endsAt });
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)) return { ok: false, code: error.code, ...error.retryAfterSeconds === void 0 ? {} : { retryAfterSeconds: error.retryAfterSeconds } };
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) throw new CouponError(COUPON_ERROR.keyReused, "This idempotency key was used for another coupon or account");
545
- return { redemptionId: known.id, discount: termsOf(coupon), effect: effectOf(coupon, input.amountMinor), reservedUntil: known.reservedUntil ?? known.createdAt, replayed: true };
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 { redemptionId: redemption.id, discount: termsOf(coupon), effect: effectOf(coupon, input.amountMinor), reservedUntil, replayed: false };
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) await store.updateRedemption(redemptionId, [REDEMPTION_STATUS.reserved], { reservedUntil: wanted });
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) return "already";
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(tx, SYSTEM, "coupon.redeemed", { type: "coupon", id: coupon.id }, { redemptionId: r.id, kind: coupon.kind, overLimit: r.overLimit, amountPaidMinor: r.amountPaidMinor, amountDiscountMinor: r.amountDiscountMinor });
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) await k.announce(tx, COUPON_EVENT.exhausted, `exhausted:${coupon.id}`, { couponId: coupon.id, partnerId: coupon.partnerId });
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], { status: REDEMPTION_STATUS.released, reservedUntil: null, endedAt: k.now() });
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}`, { redemptionId: done.id, couponId: done.couponId });
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) await k.announce(tx, COUPON_EVENT.released, `released:${r.id}`, { redemptionId: r.id, couponId: r.couponId });
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, { amountRefundedMinor: total, ...restore ? { useRestored: true } : {} });
652
- await k.record(tx, SYSTEM, "coupon.refunded", { type: "coupon", id: r.couponId }, { redemptionId: r.id, amountRefundedMinor: total, useRestored: done.useRestored });
653
- await k.announce(tx, COUPON_EVENT.refunded, `refunded:${r.id}:${total}`, { redemptionId: r.id, couponId: r.couponId, partnerId: r.partnerId, amountRefundedMinor: total, currency: r.currency, useRestored: done.useRestored });
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) throw new CouponError(COUPON_ERROR.badSettings, "Credit coupons need the credits ledger in the module's dependencies");
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, { mode: "redeem", accountId: input.accountId, ...input.hasPaidBefore === void 0 ? {} : { hasPaidBefore: input.hasPaidBefore } });
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, { accountId: input.accountId, amount: coupon.credits, capPerDay: coupon.capPerDay, capPerMonth: coupon.capPerMonth, expiresAt, onExhausted: coupon.onExhausted, source: `coupon:${redemption.id}`, idempotencyKey: `coupon-${redemption.id}` });
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) throw new CouponError(COUPON_ERROR.keyReused, "This idempotency key was used for another coupon or account");
697
- return { redemptionId: known.id, grantId: known.creditGrantId, credits: coupon.credits, expiresAt: addHours(known.createdAt, coupon.creditsValidDays * 24), replayed: true };
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) throw new CouponError(COUPON_ERROR.notRevocable, "Only a confirmed credit redemption can be revoked");
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], { status: REDEMPTION_STATUS.revoked, endedAt: k.now() });
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(tx, actor, "coupon.revoked", { type: "coupon", id: r.couponId }, { redemptionId: r.id, creditsWrittenOff: expired });
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)) throw new Error(`duplicate ${field}`);
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)) throw Object.assign(new Error("duplicate code_key"), { code: "23505" });
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)) if (value !== void 0) c[key] = value;
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((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));
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)) if (value !== void 0) p[key] = value;
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(newestFirst([...partners.values()].filter((p) => o.includeArchived || p.status === PARTNER_ACTIVE)), o.limit, o.offset).map(clone);
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)) if (value !== void 0) r[key] = value;
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 { total: live.length, account: accountId === null ? 0 : live.filter((r) => r.accountId === accountId).length };
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((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));
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) continue;
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) ?? { couponId: c.id, code: c.code, currency: r.currency, redemptions: 0, discountMinor: 0, revenueMinor: 0, creditsGranted: 0 };
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((a, b) => a.code.localeCompare(b.code) || (a.currency ?? "").localeCompare(b.currency ?? ""));
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-ECCGFGSV.js.map
1224
+ //# sourceMappingURL=chunk-V2HCEF26.js.map