@krizaka/coupons 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +63 -2
- package/dist/chunk-ECCGFGSV.js +940 -0
- package/dist/chunk-ECCGFGSV.js.map +1 -0
- package/dist/chunk-JMQO6PTK.js +228 -0
- package/dist/chunk-JMQO6PTK.js.map +1 -0
- package/dist/coupons-BF-3HFdw.d.ts +172 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/rules-CCd1HhOA.d.ts +408 -0
- package/dist/settings.d.ts +34 -0
- package/dist/settings.js +52 -0
- package/dist/settings.js.map +1 -0
- package/dist/testkit.d.ts +60 -0
- package/dist/testkit.js +427 -0
- package/dist/testkit.js.map +1 -0
- package/package.json +88 -3
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COUPON_ATTEMPT_ACTION,
|
|
3
|
+
COUPON_CODE,
|
|
4
|
+
COUPON_ERROR,
|
|
5
|
+
COUPON_STATUS,
|
|
6
|
+
CouponError,
|
|
7
|
+
KIND,
|
|
8
|
+
REDEMPTION_STATUS,
|
|
9
|
+
checkCoupon,
|
|
10
|
+
checkSettings,
|
|
11
|
+
countsAsUse,
|
|
12
|
+
effectOf,
|
|
13
|
+
generateCode,
|
|
14
|
+
normalizeCode,
|
|
15
|
+
refusalOf,
|
|
16
|
+
termsOf
|
|
17
|
+
} from "./chunk-JMQO6PTK.js";
|
|
18
|
+
|
|
19
|
+
// src/kernel.ts
|
|
20
|
+
import { randomUUID } from "crypto";
|
|
21
|
+
|
|
22
|
+
// src/events.ts
|
|
23
|
+
import { createHash } from "crypto";
|
|
24
|
+
var COUPONS_TOPIC = "coupons";
|
|
25
|
+
var COUPON_EVENT = {
|
|
26
|
+
redeemed: "evt.coupon.redeemed.v1",
|
|
27
|
+
released: "evt.coupon.released.v1",
|
|
28
|
+
refunded: "evt.coupon.refunded.v1",
|
|
29
|
+
exhausted: "evt.coupon.exhausted.v1"
|
|
30
|
+
};
|
|
31
|
+
function uuidOf(seed) {
|
|
32
|
+
const h = createHash("sha256").update(`krizaka.coupons:${seed}`).digest();
|
|
33
|
+
h[6] = h[6] & 15 | 80;
|
|
34
|
+
h[8] = h[8] & 63 | 128;
|
|
35
|
+
const x = h.subarray(0, 16).toString("hex");
|
|
36
|
+
return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;
|
|
37
|
+
}
|
|
38
|
+
var nullableString = { type: ["string", "null"] };
|
|
39
|
+
var nullableInt = { type: ["integer", "null"] };
|
|
40
|
+
var COUPON_EVENT_SCHEMAS = {
|
|
41
|
+
[COUPON_EVENT.redeemed]: {
|
|
42
|
+
type: "object",
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
required: ["redemptionId", "couponId", "kind", "purpose", "partnerId", "amountPaidMinor", "amountDiscountMinor", "currency", "credits", "overLimit"],
|
|
45
|
+
properties: {
|
|
46
|
+
redemptionId: { type: "string" },
|
|
47
|
+
couponId: { type: "string" },
|
|
48
|
+
kind: { enum: ["discount", "credit"] },
|
|
49
|
+
purpose: { enum: ["partner", "promo", "demo", "support"] },
|
|
50
|
+
partnerId: nullableString,
|
|
51
|
+
amountPaidMinor: nullableInt,
|
|
52
|
+
amountDiscountMinor: nullableInt,
|
|
53
|
+
currency: nullableString,
|
|
54
|
+
credits: nullableInt,
|
|
55
|
+
overLimit: { type: "boolean" }
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
[COUPON_EVENT.released]: { type: "object", additionalProperties: false, required: ["redemptionId", "couponId"], properties: { redemptionId: { type: "string" }, couponId: { type: "string" } } },
|
|
59
|
+
[COUPON_EVENT.refunded]: {
|
|
60
|
+
type: "object",
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
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" } }
|
|
64
|
+
},
|
|
65
|
+
[COUPON_EVENT.exhausted]: { type: "object", additionalProperties: false, required: ["couponId", "partnerId"], properties: { couponId: { type: "string" }, partnerId: nullableString } }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// src/persistence.ts
|
|
69
|
+
import { ident } from "@krizaka/db";
|
|
70
|
+
var n = (v) => Number(v);
|
|
71
|
+
var nOrNull = (v) => v === null || v === void 0 ? null : Number(v);
|
|
72
|
+
var d = (v) => new Date(v);
|
|
73
|
+
var dOrNull = (v) => v === null || v === void 0 ? null : new Date(v);
|
|
74
|
+
var toCoupon = (r) => ({
|
|
75
|
+
id: r.id,
|
|
76
|
+
code: r.code,
|
|
77
|
+
codeKey: r.code_key,
|
|
78
|
+
kind: r.kind,
|
|
79
|
+
purpose: r.purpose,
|
|
80
|
+
partnerId: r.partner_id ?? null,
|
|
81
|
+
status: r.status,
|
|
82
|
+
startsAt: d(r.starts_at),
|
|
83
|
+
endsAt: dOrNull(r.ends_at),
|
|
84
|
+
maxRedemptions: nOrNull(r.max_redemptions),
|
|
85
|
+
maxPerAccount: n(r.max_per_account),
|
|
86
|
+
newCustomersOnly: r.new_customers_only,
|
|
87
|
+
plans: r.plans,
|
|
88
|
+
percentOffBp: nOrNull(r.percent_off_bp),
|
|
89
|
+
amountOffMinor: nOrNull(r.amount_off_minor),
|
|
90
|
+
currency: r.currency ?? null,
|
|
91
|
+
duration: r.duration ?? null,
|
|
92
|
+
durationMonths: nOrNull(r.duration_months),
|
|
93
|
+
minimumMinor: nOrNull(r.minimum_minor),
|
|
94
|
+
credits: nOrNull(r.credits),
|
|
95
|
+
capPerDay: nOrNull(r.cap_per_day),
|
|
96
|
+
capPerMonth: nOrNull(r.cap_per_month),
|
|
97
|
+
creditsValidDays: nOrNull(r.credits_valid_days),
|
|
98
|
+
onExhausted: r.on_exhausted ?? null,
|
|
99
|
+
termsVersion: n(r.terms_version),
|
|
100
|
+
createdAt: d(r.created_at),
|
|
101
|
+
createdBy: r.created_by ?? null,
|
|
102
|
+
updatedAt: d(r.updated_at)
|
|
103
|
+
});
|
|
104
|
+
var toPartner = (r) => ({
|
|
105
|
+
id: r.id,
|
|
106
|
+
displayName: r.display_name ?? null,
|
|
107
|
+
contactEmail: r.contact_email ?? null,
|
|
108
|
+
status: r.status,
|
|
109
|
+
createdAt: d(r.created_at),
|
|
110
|
+
createdBy: r.created_by ?? null,
|
|
111
|
+
erasedAt: dOrNull(r.erased_at)
|
|
112
|
+
});
|
|
113
|
+
var toRedemption = (r) => ({
|
|
114
|
+
id: r.id,
|
|
115
|
+
couponId: r.coupon_id,
|
|
116
|
+
accountId: r.account_id ?? null,
|
|
117
|
+
partnerId: r.partner_id ?? null,
|
|
118
|
+
status: r.status,
|
|
119
|
+
idempotencyKey: r.idempotency_key,
|
|
120
|
+
reservedUntil: dOrNull(r.reserved_until),
|
|
121
|
+
checkoutRef: r.checkout_ref ?? null,
|
|
122
|
+
paymentRef: r.payment_ref ?? null,
|
|
123
|
+
amountPaidMinor: nOrNull(r.amount_paid_minor),
|
|
124
|
+
amountDiscountMinor: nOrNull(r.amount_discount_minor),
|
|
125
|
+
amountRefundedMinor: n(r.amount_refunded_minor),
|
|
126
|
+
useRestored: r.use_restored,
|
|
127
|
+
currency: r.currency ?? null,
|
|
128
|
+
creditGrantId: r.credit_grant_id ?? null,
|
|
129
|
+
overLimit: r.over_limit,
|
|
130
|
+
createdAt: d(r.created_at),
|
|
131
|
+
confirmedAt: dOrNull(r.confirmed_at),
|
|
132
|
+
endedAt: dOrNull(r.ended_at)
|
|
133
|
+
});
|
|
134
|
+
var COUPON_COLUMNS = {
|
|
135
|
+
code: "code",
|
|
136
|
+
purpose: "purpose",
|
|
137
|
+
partnerId: "partner_id",
|
|
138
|
+
status: "status",
|
|
139
|
+
startsAt: "starts_at",
|
|
140
|
+
endsAt: "ends_at",
|
|
141
|
+
maxRedemptions: "max_redemptions",
|
|
142
|
+
maxPerAccount: "max_per_account",
|
|
143
|
+
newCustomersOnly: "new_customers_only",
|
|
144
|
+
plans: "plans",
|
|
145
|
+
percentOffBp: "percent_off_bp",
|
|
146
|
+
amountOffMinor: "amount_off_minor",
|
|
147
|
+
currency: "currency",
|
|
148
|
+
duration: "duration",
|
|
149
|
+
durationMonths: "duration_months",
|
|
150
|
+
minimumMinor: "minimum_minor",
|
|
151
|
+
credits: "credits",
|
|
152
|
+
capPerDay: "cap_per_day",
|
|
153
|
+
capPerMonth: "cap_per_month",
|
|
154
|
+
creditsValidDays: "credits_valid_days",
|
|
155
|
+
onExhausted: "on_exhausted",
|
|
156
|
+
termsVersion: "terms_version"
|
|
157
|
+
};
|
|
158
|
+
var REDEMPTION_COLUMNS = {
|
|
159
|
+
status: "status",
|
|
160
|
+
reservedUntil: "reserved_until",
|
|
161
|
+
checkoutRef: "checkout_ref",
|
|
162
|
+
paymentRef: "payment_ref",
|
|
163
|
+
amountPaidMinor: "amount_paid_minor",
|
|
164
|
+
amountDiscountMinor: "amount_discount_minor",
|
|
165
|
+
amountRefundedMinor: "amount_refunded_minor",
|
|
166
|
+
useRestored: "use_restored",
|
|
167
|
+
currency: "currency",
|
|
168
|
+
creditGrantId: "credit_grant_id",
|
|
169
|
+
overLimit: "over_limit",
|
|
170
|
+
confirmedAt: "confirmed_at",
|
|
171
|
+
endedAt: "ended_at"
|
|
172
|
+
};
|
|
173
|
+
var PARTNER_COLUMNS = { displayName: "display_name", contactEmail: "contact_email", status: "status", erasedAt: "erased_at" };
|
|
174
|
+
function setClause(patch, columns, first) {
|
|
175
|
+
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]) };
|
|
177
|
+
}
|
|
178
|
+
var COUNTS = `((status IN ('confirmed', 'revoked') AND NOT use_restored) OR (status = 'reserved' AND reserved_until > $NOW))`;
|
|
179
|
+
function dbCouponStore(query, schema = "coupons") {
|
|
180
|
+
const s = ident(schema);
|
|
181
|
+
const coupon = `${s}.coupon`;
|
|
182
|
+
const partner = `${s}.partner`;
|
|
183
|
+
const redemption = `${s}.redemption`;
|
|
184
|
+
return {
|
|
185
|
+
async insertCoupon(c) {
|
|
186
|
+
try {
|
|
187
|
+
await query(
|
|
188
|
+
`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
|
+
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]
|
|
191
|
+
);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error.code === "23505") throw new CouponError(COUPON_ERROR.codeTaken, "This code is already used");
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
async couponById(id, forUpdate = false) {
|
|
198
|
+
const [row] = await query(`SELECT * FROM ${coupon} WHERE id = $1${forUpdate ? " FOR UPDATE" : ""}`, [id]);
|
|
199
|
+
return row ? toCoupon(row) : null;
|
|
200
|
+
},
|
|
201
|
+
async couponByKey(codeKey, forUpdate = false) {
|
|
202
|
+
const [row] = await query(`SELECT * FROM ${coupon} WHERE code_key = $1${forUpdate ? " FOR UPDATE" : ""}`, [codeKey]);
|
|
203
|
+
return row ? toCoupon(row) : null;
|
|
204
|
+
},
|
|
205
|
+
async updateCoupon(id, patch, at) {
|
|
206
|
+
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]);
|
|
208
|
+
return row ? toCoupon(row) : null;
|
|
209
|
+
},
|
|
210
|
+
async listCoupons(f) {
|
|
211
|
+
const rows = await query(
|
|
212
|
+
`SELECT * FROM ${coupon} WHERE ($1::text IS NULL OR status = $1) AND ($2::text IS NULL OR kind = $2) AND ($3::text IS NULL OR purpose = $3) AND ($4::uuid IS NULL OR partner_id = $4)
|
|
213
|
+
ORDER BY created_at DESC, id LIMIT $5 OFFSET $6`,
|
|
214
|
+
[f.status ?? null, f.kind ?? null, f.purpose ?? null, f.partnerId ?? null, f.limit, f.offset]
|
|
215
|
+
);
|
|
216
|
+
return rows.map(toCoupon);
|
|
217
|
+
},
|
|
218
|
+
async hasRedemptions(couponId) {
|
|
219
|
+
return (await query(`SELECT 1 FROM ${redemption} WHERE coupon_id = $1 LIMIT 1`, [couponId])).length > 0;
|
|
220
|
+
},
|
|
221
|
+
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]);
|
|
223
|
+
},
|
|
224
|
+
async partnerById(id) {
|
|
225
|
+
const [row] = await query(`SELECT * FROM ${partner} WHERE id = $1`, [id]);
|
|
226
|
+
return row ? toPartner(row) : null;
|
|
227
|
+
},
|
|
228
|
+
async updatePartner(id, patch) {
|
|
229
|
+
const set = setClause(patch, PARTNER_COLUMNS, 2);
|
|
230
|
+
if (!set.sql) return await this.partnerById(id) ?? null;
|
|
231
|
+
const [row] = await query(`UPDATE ${partner} SET ${set.sql} WHERE id = $1 RETURNING *`, [id, ...set.values]);
|
|
232
|
+
return row ? toPartner(row) : null;
|
|
233
|
+
},
|
|
234
|
+
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]);
|
|
236
|
+
return rows.map(toPartner);
|
|
237
|
+
},
|
|
238
|
+
async insertRedemption(r) {
|
|
239
|
+
await query(
|
|
240
|
+
`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
|
+
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]
|
|
243
|
+
);
|
|
244
|
+
},
|
|
245
|
+
async redemptionById(id) {
|
|
246
|
+
const [row] = await query(`SELECT * FROM ${redemption} WHERE id = $1`, [id]);
|
|
247
|
+
return row ? toRedemption(row) : null;
|
|
248
|
+
},
|
|
249
|
+
async redemptionByKey(key) {
|
|
250
|
+
const [row] = await query(`SELECT * FROM ${redemption} WHERE idempotency_key = $1`, [key]);
|
|
251
|
+
return row ? toRedemption(row) : null;
|
|
252
|
+
},
|
|
253
|
+
async redemptionByCheckoutRef(ref) {
|
|
254
|
+
const [row] = await query(`SELECT * FROM ${redemption} WHERE checkout_ref = $1`, [ref]);
|
|
255
|
+
return row ? toRedemption(row) : null;
|
|
256
|
+
},
|
|
257
|
+
async redemptionByPaymentRef(ref) {
|
|
258
|
+
const [row] = await query(`SELECT * FROM ${redemption} WHERE payment_ref = $1`, [ref]);
|
|
259
|
+
return row ? toRedemption(row) : null;
|
|
260
|
+
},
|
|
261
|
+
async updateRedemption(id, from, patch) {
|
|
262
|
+
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]);
|
|
264
|
+
return row ? toRedemption(row) : null;
|
|
265
|
+
},
|
|
266
|
+
async usage(couponId, accountId, now) {
|
|
267
|
+
const counted = COUNTS.replaceAll("$NOW", () => "$3");
|
|
268
|
+
const [row] = await query(
|
|
269
|
+
`SELECT count(*) FILTER (WHERE ${counted})::int AS total, count(*) FILTER (WHERE ${counted} AND account_id = $2)::int AS account FROM ${redemption} WHERE coupon_id = $1`,
|
|
270
|
+
[couponId, accountId, now]
|
|
271
|
+
);
|
|
272
|
+
return { total: row?.total ?? 0, account: row?.account ?? 0 };
|
|
273
|
+
},
|
|
274
|
+
async listRedemptions(f) {
|
|
275
|
+
const rows = await query(
|
|
276
|
+
`SELECT r.* FROM ${redemption} r
|
|
277
|
+
WHERE ($1::uuid IS NULL OR r.coupon_id = $1) AND ($2::uuid IS NULL OR r.partner_id = $2) AND ($3::text IS NULL OR r.status = $3)
|
|
278
|
+
AND ($4::timestamptz IS NULL OR r.created_at >= $4) AND ($5::timestamptz IS NULL OR r.created_at < $5)
|
|
279
|
+
ORDER BY r.created_at DESC, r.id LIMIT $6 OFFSET $7`,
|
|
280
|
+
[f.couponId ?? null, f.partnerId ?? null, f.status ?? null, f.from ?? null, f.to ?? null, f.limit, f.offset]
|
|
281
|
+
);
|
|
282
|
+
return rows.map(toRedemption);
|
|
283
|
+
},
|
|
284
|
+
async releaseExpired(now, limit) {
|
|
285
|
+
const rows = await query(
|
|
286
|
+
`UPDATE ${redemption} SET status = 'released', reserved_until = NULL, ended_at = $1
|
|
287
|
+
WHERE id IN (SELECT id FROM ${redemption} WHERE status = 'reserved' AND reserved_until <= $1 ORDER BY reserved_until, id LIMIT $2 FOR UPDATE SKIP LOCKED)
|
|
288
|
+
RETURNING *`,
|
|
289
|
+
[now, limit]
|
|
290
|
+
);
|
|
291
|
+
return rows.map(toRedemption);
|
|
292
|
+
},
|
|
293
|
+
async partnerReport(partnerId, period, now) {
|
|
294
|
+
const rows = await query(
|
|
295
|
+
`SELECT c.id AS coupon_id, c.code, r.currency,
|
|
296
|
+
count(*)::int AS redemptions,
|
|
297
|
+
COALESCE(sum(r.amount_discount_minor), 0) AS discount,
|
|
298
|
+
COALESCE(sum(COALESCE(r.amount_paid_minor, 0) - r.amount_refunded_minor), 0) AS revenue,
|
|
299
|
+
COALESCE(sum(CASE WHEN c.kind = 'credit' THEN c.credits ELSE 0 END), 0) AS credits_granted
|
|
300
|
+
FROM ${redemption} r JOIN ${coupon} c ON c.id = r.coupon_id
|
|
301
|
+
WHERE r.partner_id = $1
|
|
302
|
+
AND ((r.status IN ('confirmed', 'revoked') AND NOT r.use_restored) OR (r.status = 'reserved' AND r.reserved_until > $2))
|
|
303
|
+
AND ($3::timestamptz IS NULL OR COALESCE(r.confirmed_at, r.created_at) >= $3) AND ($4::timestamptz IS NULL OR COALESCE(r.confirmed_at, r.created_at) < $4)
|
|
304
|
+
GROUP BY c.id, c.code, r.currency ORDER BY c.code, r.currency NULLS FIRST`,
|
|
305
|
+
[partnerId, now, period.from ?? null, period.to ?? null]
|
|
306
|
+
);
|
|
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) }));
|
|
308
|
+
},
|
|
309
|
+
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);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/kernel.ts
|
|
316
|
+
var SYSTEM = { id: null, label: "system" };
|
|
317
|
+
function makeKernel(deps) {
|
|
318
|
+
const storeFor = deps.storeFor ?? ((query) => dbCouponStore(query, deps.schema ?? "coupons"));
|
|
319
|
+
return {
|
|
320
|
+
deps,
|
|
321
|
+
now: () => deps.clock.now(),
|
|
322
|
+
settings: async () => checkSettings(typeof deps.settings === "function" ? await deps.settings() : deps.settings),
|
|
323
|
+
store: storeFor,
|
|
324
|
+
newId: deps.newId ?? randomUUID,
|
|
325
|
+
record: (tx, actor, action, target, detail = {}) => deps.audit.record(tx, { actorId: actor.id, actorLabel: actor.label, action, target, detail }),
|
|
326
|
+
async announce(tx, type, seed, payload) {
|
|
327
|
+
await deps.outbox?.append(tx, { topic: COUPONS_TOPIC, type, payload, id: uuidOf(seed) });
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/admin.ts
|
|
333
|
+
var bad = (message) => new CouponError(COUPON_ERROR.badInput, message);
|
|
334
|
+
var PAGE = 50;
|
|
335
|
+
var MAX_PAGE = 200;
|
|
336
|
+
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) });
|
|
339
|
+
function makeAdmin(k) {
|
|
340
|
+
const must = async (found, code, what) => {
|
|
341
|
+
const value = await found;
|
|
342
|
+
if (!value) throw new CouponError(code, what);
|
|
343
|
+
return value;
|
|
344
|
+
};
|
|
345
|
+
const partnerOk = async (tx, partnerId) => {
|
|
346
|
+
if (partnerId) await must(k.store(tx).partnerById(partnerId), COUPON_ERROR.partnerNotFound, `No partner ${partnerId}`);
|
|
347
|
+
};
|
|
348
|
+
async function generateFreeCode(query) {
|
|
349
|
+
const length = (await k.settings()).generatedCodeLength;
|
|
350
|
+
for (let i = 0; i < CODE_TRIES; i += 1) {
|
|
351
|
+
const code = generateCode(length);
|
|
352
|
+
if (!await k.store(query).couponByKey(code)) return code;
|
|
353
|
+
}
|
|
354
|
+
throw bad("Could not find a free code, try again");
|
|
355
|
+
}
|
|
356
|
+
async function create(tx, actor, input) {
|
|
357
|
+
const settings = await k.settings();
|
|
358
|
+
checkCoupon(input, settings);
|
|
359
|
+
const codeKey = normalizeCode(input.code);
|
|
360
|
+
const store = k.store(tx);
|
|
361
|
+
if (await store.couponByKey(codeKey)) throw new CouponError(COUPON_ERROR.codeTaken, "This code is already used");
|
|
362
|
+
await partnerOk(tx, input.partnerId);
|
|
363
|
+
const now = k.now();
|
|
364
|
+
const coupon = {
|
|
365
|
+
id: k.newId(),
|
|
366
|
+
code: input.code.trim(),
|
|
367
|
+
codeKey,
|
|
368
|
+
kind: input.kind,
|
|
369
|
+
purpose: input.purpose,
|
|
370
|
+
partnerId: input.partnerId ?? null,
|
|
371
|
+
status: COUPON_STATUS.active,
|
|
372
|
+
startsAt: input.startsAt,
|
|
373
|
+
endsAt: input.endsAt ?? null,
|
|
374
|
+
maxRedemptions: input.maxRedemptions ?? null,
|
|
375
|
+
maxPerAccount: input.maxPerAccount ?? 1,
|
|
376
|
+
newCustomersOnly: input.newCustomersOnly ?? false,
|
|
377
|
+
plans: input.plans ?? [],
|
|
378
|
+
percentOffBp: input.percentOffBp ?? null,
|
|
379
|
+
amountOffMinor: input.amountOffMinor ?? null,
|
|
380
|
+
currency: input.currency ?? null,
|
|
381
|
+
duration: input.duration ?? null,
|
|
382
|
+
durationMonths: input.durationMonths ?? null,
|
|
383
|
+
minimumMinor: input.minimumMinor ?? null,
|
|
384
|
+
credits: input.credits ?? null,
|
|
385
|
+
capPerDay: input.capPerDay ?? null,
|
|
386
|
+
capPerMonth: input.capPerMonth ?? null,
|
|
387
|
+
creditsValidDays: input.creditsValidDays ?? null,
|
|
388
|
+
onExhausted: input.onExhausted ?? null,
|
|
389
|
+
termsVersion: 1,
|
|
390
|
+
createdAt: now,
|
|
391
|
+
createdBy: actor.id,
|
|
392
|
+
updatedAt: now
|
|
393
|
+
};
|
|
394
|
+
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 });
|
|
396
|
+
return coupon;
|
|
397
|
+
}
|
|
398
|
+
async function update(tx, actor, id, patch) {
|
|
399
|
+
const store = k.store(tx);
|
|
400
|
+
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]));
|
|
402
|
+
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");
|
|
404
|
+
const next = { ...before, ...patch };
|
|
405
|
+
checkCoupon(next, await k.settings());
|
|
406
|
+
await partnerOk(tx, patch.partnerId);
|
|
407
|
+
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]])) });
|
|
409
|
+
return after;
|
|
410
|
+
}
|
|
411
|
+
async function setStatus(tx, actor, id, status, action) {
|
|
412
|
+
const store = k.store(tx);
|
|
413
|
+
const before = await must(store.couponById(id, true), COUPON_ERROR.notFound, `No coupon ${id}`);
|
|
414
|
+
if (before.status === status) return before;
|
|
415
|
+
if (before.status === COUPON_STATUS.archived) throw bad("An archived coupon stays archived");
|
|
416
|
+
const after = await store.updateCoupon(id, { status }, k.now());
|
|
417
|
+
await k.record(tx, actor, action, { type: "coupon", id }, { from: before.status, to: status });
|
|
418
|
+
return after;
|
|
419
|
+
}
|
|
420
|
+
async function checkPrices(tx, prices) {
|
|
421
|
+
const { maxPercentOff } = await k.settings();
|
|
422
|
+
const paused = [];
|
|
423
|
+
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)));
|
|
425
|
+
if (!applicable.some((p) => c.amountOffMinor * 100 > p.amountMinor * maxPercentOff)) continue;
|
|
426
|
+
await k.store(tx).updateCoupon(c.id, { status: COUPON_STATUS.paused }, k.now());
|
|
427
|
+
await k.record(tx, SYSTEM, "coupon.paused_by_price", { type: "coupon", id: c.id }, { maxPercentOff });
|
|
428
|
+
paused.push(c.id);
|
|
429
|
+
}
|
|
430
|
+
return paused;
|
|
431
|
+
}
|
|
432
|
+
async function createPartner(tx, actor, input) {
|
|
433
|
+
const name = input.displayName.trim();
|
|
434
|
+
const email = input.contactEmail?.trim().toLowerCase() || null;
|
|
435
|
+
if (name.length < 1 || name.length > 80) throw bad("A partner has a display name of 1 to 80 characters");
|
|
436
|
+
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 };
|
|
438
|
+
await k.store(tx).insertPartner(partner);
|
|
439
|
+
await k.record(tx, actor, "partner.created", { type: "partner", id: partner.id }, {});
|
|
440
|
+
return partner;
|
|
441
|
+
}
|
|
442
|
+
async function setPartnerStatus(tx, actor, id, status) {
|
|
443
|
+
await must(k.store(tx).partnerById(id), COUPON_ERROR.partnerNotFound, `No partner ${id}`);
|
|
444
|
+
const after = await k.store(tx).updatePartner(id, { status });
|
|
445
|
+
await k.record(tx, actor, `partner.${status === "active" ? "restored" : "archived"}`, { type: "partner", id }, {});
|
|
446
|
+
return after;
|
|
447
|
+
}
|
|
448
|
+
async function erasePartner(tx, actor, id) {
|
|
449
|
+
const partner = await must(k.store(tx).partnerById(id), COUPON_ERROR.partnerNotFound, `No partner ${id}`);
|
|
450
|
+
if (partner.erasedAt) return partner;
|
|
451
|
+
const after = await k.store(tx).updatePartner(id, { displayName: null, contactEmail: null, status: "archived", erasedAt: k.now() });
|
|
452
|
+
await k.record(tx, actor, "partner.erased", { type: "partner", id }, {});
|
|
453
|
+
return after;
|
|
454
|
+
}
|
|
455
|
+
async function exportPartner(query, id) {
|
|
456
|
+
const partner = await must(k.store(query).partnerById(id), COUPON_ERROR.partnerNotFound, `No partner ${id}`);
|
|
457
|
+
return { partner, rows: await k.store(query).partnerReport(id, {}, k.now()) };
|
|
458
|
+
}
|
|
459
|
+
async function partnerReport(tx, actor, id, period = {}) {
|
|
460
|
+
await must(k.store(tx).partnerById(id), COUPON_ERROR.partnerNotFound, `No partner ${id}`);
|
|
461
|
+
await k.record(tx, actor, "partner.report_read", { type: "partner", id }, {});
|
|
462
|
+
return k.store(tx).partnerReport(id, period, k.now());
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
generateCode: generateFreeCode,
|
|
466
|
+
create,
|
|
467
|
+
update,
|
|
468
|
+
pause: (tx, actor, id) => setStatus(tx, actor, id, COUPON_STATUS.paused, "coupon.paused"),
|
|
469
|
+
resume: (tx, actor, id) => setStatus(tx, actor, id, COUPON_STATUS.active, "coupon.resumed"),
|
|
470
|
+
archive: (tx, actor, id) => setStatus(tx, actor, id, COUPON_STATUS.archived, "coupon.archived"),
|
|
471
|
+
get: (query, id) => k.store(query).couponById(id),
|
|
472
|
+
list: (query, filter = {}) => k.store(query).listCoupons({ ...filter, ...page(filter.limit, filter.offset) }),
|
|
473
|
+
redemptions: (query, filter = {}) => k.store(query).listRedemptions({ ...filter, ...page(filter.limit, filter.offset) }),
|
|
474
|
+
checkPrices,
|
|
475
|
+
partners: {
|
|
476
|
+
create: createPartner,
|
|
477
|
+
archive: (tx, actor, id) => setPartnerStatus(tx, actor, id, "archived"),
|
|
478
|
+
restore: (tx, actor, id) => setPartnerStatus(tx, actor, id, "active"),
|
|
479
|
+
erase: erasePartner,
|
|
480
|
+
get: (query, id) => k.store(query).partnerById(id),
|
|
481
|
+
list: (query, options = {}) => k.store(query).listPartners({ includeArchived: options.includeArchived ?? false, ...page(options.limit, options.offset) }),
|
|
482
|
+
export: exportPartner,
|
|
483
|
+
report: partnerReport
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// src/flows.ts
|
|
489
|
+
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 });
|
|
491
|
+
var REFUSALS = Object.values(COUPON_CODE);
|
|
492
|
+
var isRefusal = (code) => REFUSALS.includes(code);
|
|
493
|
+
var refuse = (code, retryAfterSeconds) => new CouponError(code, code, retryAfterSeconds);
|
|
494
|
+
var ATTACH_GRACE_MINUTES = 5;
|
|
495
|
+
var SWEEP_BATCH = 500;
|
|
496
|
+
function makeFlows(k) {
|
|
497
|
+
async function attempt(ctx) {
|
|
498
|
+
const r = await k.deps.rateLimit.check(COUPON_ATTEMPT_ACTION, { ip: ctx.ip, subject: ctx.accountId });
|
|
499
|
+
if (!r.ok) throw refuse(COUPON_CODE.rateLimited, r.retryAfterSeconds);
|
|
500
|
+
}
|
|
501
|
+
async function lookup(tx, code, forUpdate) {
|
|
502
|
+
const key = normalizeCode(code);
|
|
503
|
+
const coupon = key ? await k.store(tx).couponByKey(key, forUpdate) : null;
|
|
504
|
+
if (!coupon) throw refuse(COUPON_CODE.unknown);
|
|
505
|
+
return coupon;
|
|
506
|
+
}
|
|
507
|
+
async function check(tx, coupon, ctx) {
|
|
508
|
+
const settings = await k.settings();
|
|
509
|
+
const now = k.now();
|
|
510
|
+
const usage = await k.store(tx).usage(coupon.id, ctx.accountId, now);
|
|
511
|
+
const refusal = refusalOf(coupon, ctx, usage, now, settings);
|
|
512
|
+
if (refusal) throw refuse(refusal);
|
|
513
|
+
}
|
|
514
|
+
const mustBeEnabled = async () => {
|
|
515
|
+
if (!(await k.settings()).enabled) throw refuse(COUPON_CODE.disabled);
|
|
516
|
+
};
|
|
517
|
+
const needsAmount = (ctx) => {
|
|
518
|
+
if (ctx.mode === "checkout" && (ctx.plan === void 0 || ctx.amountMinor === void 0 || ctx.currency === void 0)) {
|
|
519
|
+
throw new CouponError(COUPON_ERROR.badInput, "A checkout names its plan, amount and currency");
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
async function validate(query, code, ctx) {
|
|
523
|
+
try {
|
|
524
|
+
await mustBeEnabled();
|
|
525
|
+
needsAmount(ctx);
|
|
526
|
+
await attempt(ctx);
|
|
527
|
+
const coupon = await lookup(query, code, false);
|
|
528
|
+
await check(query, coupon, ctx);
|
|
529
|
+
return { ok: true, coupon: view(coupon), effect: effectOf(coupon, ctx.amountMinor ?? 0) };
|
|
530
|
+
} catch (error) {
|
|
531
|
+
if (error instanceof CouponError && isRefusal(error.code)) return { ok: false, code: error.code, ...error.retryAfterSeconds === void 0 ? {} : { retryAfterSeconds: error.retryAfterSeconds } };
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
async function apply(tx, input) {
|
|
536
|
+
const ctx = { ...input, mode: "checkout" };
|
|
537
|
+
await mustBeEnabled();
|
|
538
|
+
needsAmount(ctx);
|
|
539
|
+
await attempt(ctx);
|
|
540
|
+
const coupon = await lookup(tx, input.code, true);
|
|
541
|
+
const store = k.store(tx);
|
|
542
|
+
const known = await store.redemptionByKey(input.idempotencyKey);
|
|
543
|
+
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 };
|
|
546
|
+
}
|
|
547
|
+
await check(tx, coupon, ctx);
|
|
548
|
+
const now = k.now();
|
|
549
|
+
const reservedUntil = addMinutes(now, (await k.settings()).reservationMinutes);
|
|
550
|
+
const redemption = {
|
|
551
|
+
id: k.newId(),
|
|
552
|
+
couponId: coupon.id,
|
|
553
|
+
accountId: input.accountId,
|
|
554
|
+
partnerId: coupon.partnerId,
|
|
555
|
+
status: REDEMPTION_STATUS.reserved,
|
|
556
|
+
idempotencyKey: input.idempotencyKey,
|
|
557
|
+
reservedUntil,
|
|
558
|
+
checkoutRef: null,
|
|
559
|
+
paymentRef: null,
|
|
560
|
+
amountPaidMinor: null,
|
|
561
|
+
amountDiscountMinor: null,
|
|
562
|
+
amountRefundedMinor: 0,
|
|
563
|
+
useRestored: false,
|
|
564
|
+
currency: input.currency ?? null,
|
|
565
|
+
creditGrantId: null,
|
|
566
|
+
overLimit: false,
|
|
567
|
+
createdAt: now,
|
|
568
|
+
confirmedAt: null,
|
|
569
|
+
endedAt: null
|
|
570
|
+
};
|
|
571
|
+
await store.insertRedemption(redemption);
|
|
572
|
+
return { redemptionId: redemption.id, discount: termsOf(coupon), effect: effectOf(coupon, input.amountMinor), reservedUntil, replayed: false };
|
|
573
|
+
}
|
|
574
|
+
async function attachCheckout(tx, redemptionId, checkoutRef, sessionExpiresAt) {
|
|
575
|
+
const store = k.store(tx);
|
|
576
|
+
const r = await store.redemptionById(redemptionId);
|
|
577
|
+
if (!r) throw new CouponError(COUPON_ERROR.redemptionNotFound, `No redemption ${redemptionId}`);
|
|
578
|
+
await store.updateRedemption(redemptionId, null, { checkoutRef });
|
|
579
|
+
if (r.status !== REDEMPTION_STATUS.reserved || !sessionExpiresAt) return;
|
|
580
|
+
const wanted = addMinutes(sessionExpiresAt, ATTACH_GRACE_MINUTES);
|
|
581
|
+
if (!r.reservedUntil || wanted > r.reservedUntil) await store.updateRedemption(redemptionId, [REDEMPTION_STATUS.reserved], { reservedUntil: wanted });
|
|
582
|
+
}
|
|
583
|
+
async function findFor(tx, ref) {
|
|
584
|
+
const store = k.store(tx);
|
|
585
|
+
return (ref.redemptionId ? await store.redemptionById(ref.redemptionId) : null) ?? (ref.checkoutRef ? await store.redemptionByCheckoutRef(ref.checkoutRef) : null);
|
|
586
|
+
}
|
|
587
|
+
async function redeem(tx, input) {
|
|
588
|
+
const store = k.store(tx);
|
|
589
|
+
const found = await findFor(tx, input);
|
|
590
|
+
if (!found) return "unknown";
|
|
591
|
+
const coupon = await store.couponById(found.couponId, true);
|
|
592
|
+
const r = await store.redemptionById(found.id);
|
|
593
|
+
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";
|
|
595
|
+
const now = k.now();
|
|
596
|
+
const others = (await store.usage(coupon.id, null, now)).total - (countsAsUse(r, now) ? 1 : 0);
|
|
597
|
+
const late = r.status === REDEMPTION_STATUS.released;
|
|
598
|
+
const overLimit = late || coupon.maxRedemptions !== null && others + 1 > coupon.maxRedemptions;
|
|
599
|
+
const done = await store.updateRedemption(r.id, [REDEMPTION_STATUS.reserved, REDEMPTION_STATUS.released], {
|
|
600
|
+
status: REDEMPTION_STATUS.confirmed,
|
|
601
|
+
paymentRef: input.paymentRef,
|
|
602
|
+
amountPaidMinor: input.amountPaidMinor,
|
|
603
|
+
amountDiscountMinor: input.amountDiscountMinor,
|
|
604
|
+
currency: input.currency ?? r.currency,
|
|
605
|
+
reservedUntil: null,
|
|
606
|
+
confirmedAt: now,
|
|
607
|
+
endedAt: null,
|
|
608
|
+
overLimit
|
|
609
|
+
});
|
|
610
|
+
if (!done) return "already";
|
|
611
|
+
await confirmed(tx, coupon, done, others + 1);
|
|
612
|
+
return "confirmed";
|
|
613
|
+
}
|
|
614
|
+
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 });
|
|
616
|
+
await k.announce(tx, COUPON_EVENT.redeemed, `redeemed:${r.id}`, {
|
|
617
|
+
redemptionId: r.id,
|
|
618
|
+
couponId: coupon.id,
|
|
619
|
+
kind: coupon.kind,
|
|
620
|
+
purpose: coupon.purpose,
|
|
621
|
+
partnerId: r.partnerId,
|
|
622
|
+
amountPaidMinor: r.amountPaidMinor,
|
|
623
|
+
amountDiscountMinor: r.amountDiscountMinor,
|
|
624
|
+
currency: r.currency,
|
|
625
|
+
credits: coupon.kind === KIND.credit ? coupon.credits : null,
|
|
626
|
+
overLimit: r.overLimit
|
|
627
|
+
});
|
|
628
|
+
if (coupon.maxRedemptions !== null && usesNow === coupon.maxRedemptions) await k.announce(tx, COUPON_EVENT.exhausted, `exhausted:${coupon.id}`, { couponId: coupon.id, partnerId: coupon.partnerId });
|
|
629
|
+
}
|
|
630
|
+
async function release(tx, ref) {
|
|
631
|
+
const found = await findFor(tx, ref);
|
|
632
|
+
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() });
|
|
634
|
+
if (!done) return false;
|
|
635
|
+
await k.announce(tx, COUPON_EVENT.released, `released:${done.id}`, { redemptionId: done.id, couponId: done.couponId });
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
async function releaseExpired(tx) {
|
|
639
|
+
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 });
|
|
641
|
+
return released.length;
|
|
642
|
+
}
|
|
643
|
+
async function refund(tx, input) {
|
|
644
|
+
const store = k.store(tx);
|
|
645
|
+
const r = (input.paymentRef ? await store.redemptionByPaymentRef(input.paymentRef) : null) ?? (input.invoiceRef ? await store.redemptionByPaymentRef(input.invoiceRef) : null);
|
|
646
|
+
if (!r) return "unknown";
|
|
647
|
+
const total = Math.max(r.amountRefundedMinor, input.amountRefunded);
|
|
648
|
+
if (total === r.amountRefundedMinor) return "already";
|
|
649
|
+
const full = r.amountPaidMinor !== null && r.amountPaidMinor > 0 && total >= r.amountPaidMinor;
|
|
650
|
+
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 });
|
|
654
|
+
return "refunded";
|
|
655
|
+
}
|
|
656
|
+
async function redeemCredit(tx, input) {
|
|
657
|
+
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");
|
|
659
|
+
await mustBeEnabled();
|
|
660
|
+
await attempt(input);
|
|
661
|
+
const coupon = await lookup(tx, input.code, true);
|
|
662
|
+
const store = k.store(tx);
|
|
663
|
+
const known = await store.redemptionByKey(input.idempotencyKey);
|
|
664
|
+
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 } });
|
|
666
|
+
const now = k.now();
|
|
667
|
+
const expiresAt = addHours(now, coupon.creditsValidDays * 24);
|
|
668
|
+
const redemption = {
|
|
669
|
+
id: k.newId(),
|
|
670
|
+
couponId: coupon.id,
|
|
671
|
+
accountId: input.accountId,
|
|
672
|
+
partnerId: coupon.partnerId,
|
|
673
|
+
status: REDEMPTION_STATUS.confirmed,
|
|
674
|
+
idempotencyKey: input.idempotencyKey,
|
|
675
|
+
reservedUntil: null,
|
|
676
|
+
checkoutRef: null,
|
|
677
|
+
paymentRef: null,
|
|
678
|
+
amountPaidMinor: null,
|
|
679
|
+
amountDiscountMinor: null,
|
|
680
|
+
amountRefundedMinor: 0,
|
|
681
|
+
useRestored: false,
|
|
682
|
+
currency: null,
|
|
683
|
+
creditGrantId: null,
|
|
684
|
+
overLimit: false,
|
|
685
|
+
createdAt: now,
|
|
686
|
+
confirmedAt: now,
|
|
687
|
+
endedAt: null
|
|
688
|
+
};
|
|
689
|
+
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}` });
|
|
691
|
+
const done = await store.updateRedemption(redemption.id, null, { creditGrantId: grantId });
|
|
692
|
+
await confirmed(tx, coupon, done, (await store.usage(coupon.id, null, now)).total);
|
|
693
|
+
return { redemptionId: done.id, grantId, credits: coupon.credits, expiresAt, replayed: false };
|
|
694
|
+
}
|
|
695
|
+
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 };
|
|
698
|
+
}
|
|
699
|
+
async function revoke(tx, actor, redemptionId) {
|
|
700
|
+
const store = k.store(tx);
|
|
701
|
+
const r = await store.redemptionById(redemptionId);
|
|
702
|
+
if (!r) throw new CouponError(COUPON_ERROR.redemptionNotFound, `No redemption ${redemptionId}`);
|
|
703
|
+
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");
|
|
705
|
+
await store.couponById(r.couponId, true);
|
|
706
|
+
const done = await store.updateRedemption(r.id, [REDEMPTION_STATUS.confirmed], { status: REDEMPTION_STATUS.revoked, endedAt: k.now() });
|
|
707
|
+
if (!done) return;
|
|
708
|
+
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 });
|
|
710
|
+
}
|
|
711
|
+
return { validate, apply, attachCheckout, redeem, release, releaseExpired, refund, redeemCredit, revoke };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// src/coupons.ts
|
|
715
|
+
function createCoupons(deps) {
|
|
716
|
+
const kernel = makeKernel(deps);
|
|
717
|
+
return { ...makeFlows(kernel), admin: makeAdmin(kernel) };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// src/memory.ts
|
|
721
|
+
var PARTNER_ACTIVE = "active";
|
|
722
|
+
var clone = (v) => ({ ...v });
|
|
723
|
+
function memoryCouponStore() {
|
|
724
|
+
const coupons = /* @__PURE__ */ new Map();
|
|
725
|
+
const partners = /* @__PURE__ */ new Map();
|
|
726
|
+
const redemptions = /* @__PURE__ */ new Map();
|
|
727
|
+
const unique = (field, value, except) => {
|
|
728
|
+
if (value !== null && [...redemptions.values()].some((r) => r.id !== except && r[field] === value)) throw new Error(`duplicate ${field}`);
|
|
729
|
+
};
|
|
730
|
+
const page2 = (rows, limit, offset) => rows.slice(offset, offset + limit);
|
|
731
|
+
const newestFirst = (rows) => rows.sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf() || a.id.localeCompare(b.id));
|
|
732
|
+
return {
|
|
733
|
+
async insertCoupon(c) {
|
|
734
|
+
if ([...coupons.values()].some((x) => x.codeKey === c.codeKey)) throw Object.assign(new Error("duplicate code_key"), { code: "23505" });
|
|
735
|
+
coupons.set(c.id, clone(c));
|
|
736
|
+
},
|
|
737
|
+
async couponById(id) {
|
|
738
|
+
const c = coupons.get(id);
|
|
739
|
+
return c ? clone(c) : null;
|
|
740
|
+
},
|
|
741
|
+
async couponByKey(codeKey) {
|
|
742
|
+
const c = [...coupons.values()].find((x) => x.codeKey === codeKey);
|
|
743
|
+
return c ? clone(c) : null;
|
|
744
|
+
},
|
|
745
|
+
async updateCoupon(id, patch, at) {
|
|
746
|
+
const c = coupons.get(id);
|
|
747
|
+
if (!c) return null;
|
|
748
|
+
for (const [key, value] of Object.entries(patch)) if (value !== void 0) c[key] = value;
|
|
749
|
+
c.updatedAt = at;
|
|
750
|
+
return clone(c);
|
|
751
|
+
},
|
|
752
|
+
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));
|
|
754
|
+
return page2(newestFirst(rows), f.limit, f.offset).map(clone);
|
|
755
|
+
},
|
|
756
|
+
async hasRedemptions(couponId) {
|
|
757
|
+
return [...redemptions.values()].some((r) => r.couponId === couponId);
|
|
758
|
+
},
|
|
759
|
+
async insertPartner(p) {
|
|
760
|
+
partners.set(p.id, clone(p));
|
|
761
|
+
},
|
|
762
|
+
async partnerById(id) {
|
|
763
|
+
const p = partners.get(id);
|
|
764
|
+
return p ? clone(p) : null;
|
|
765
|
+
},
|
|
766
|
+
async updatePartner(id, patch) {
|
|
767
|
+
const p = partners.get(id);
|
|
768
|
+
if (!p) return null;
|
|
769
|
+
for (const [key, value] of Object.entries(patch)) if (value !== void 0) p[key] = value;
|
|
770
|
+
return clone(p);
|
|
771
|
+
},
|
|
772
|
+
async listPartners(o) {
|
|
773
|
+
return page2(newestFirst([...partners.values()].filter((p) => o.includeArchived || p.status === PARTNER_ACTIVE)), o.limit, o.offset).map(clone);
|
|
774
|
+
},
|
|
775
|
+
async insertRedemption(r) {
|
|
776
|
+
unique("idempotencyKey", r.idempotencyKey, r.id);
|
|
777
|
+
unique("checkoutRef", r.checkoutRef, r.id);
|
|
778
|
+
unique("paymentRef", r.paymentRef, r.id);
|
|
779
|
+
redemptions.set(r.id, clone(r));
|
|
780
|
+
},
|
|
781
|
+
async redemptionById(id) {
|
|
782
|
+
const r = redemptions.get(id);
|
|
783
|
+
return r ? clone(r) : null;
|
|
784
|
+
},
|
|
785
|
+
async redemptionByKey(key) {
|
|
786
|
+
const r = [...redemptions.values()].find((x) => x.idempotencyKey === key);
|
|
787
|
+
return r ? clone(r) : null;
|
|
788
|
+
},
|
|
789
|
+
async redemptionByCheckoutRef(ref) {
|
|
790
|
+
const r = [...redemptions.values()].find((x) => x.checkoutRef === ref);
|
|
791
|
+
return r ? clone(r) : null;
|
|
792
|
+
},
|
|
793
|
+
async redemptionByPaymentRef(ref) {
|
|
794
|
+
const r = [...redemptions.values()].find((x) => x.paymentRef === ref);
|
|
795
|
+
return r ? clone(r) : null;
|
|
796
|
+
},
|
|
797
|
+
async updateRedemption(id, from, patch) {
|
|
798
|
+
const r = redemptions.get(id);
|
|
799
|
+
if (!r || from && !from.includes(r.status)) return null;
|
|
800
|
+
unique("checkoutRef", patch.checkoutRef ?? null, id);
|
|
801
|
+
unique("paymentRef", patch.paymentRef ?? null, id);
|
|
802
|
+
for (const [key, value] of Object.entries(patch)) if (value !== void 0) r[key] = value;
|
|
803
|
+
return clone(r);
|
|
804
|
+
},
|
|
805
|
+
async usage(couponId, accountId, now) {
|
|
806
|
+
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 };
|
|
808
|
+
},
|
|
809
|
+
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));
|
|
811
|
+
return page2(newestFirst(rows), f.limit, f.offset).map(clone);
|
|
812
|
+
},
|
|
813
|
+
async releaseExpired(now, limit) {
|
|
814
|
+
const due = [...redemptions.values()].filter((r) => r.status === REDEMPTION_STATUS.reserved && r.reservedUntil !== null && r.reservedUntil <= now).sort((a, b) => a.reservedUntil.valueOf() - b.reservedUntil.valueOf() || a.id.localeCompare(b.id)).slice(0, limit);
|
|
815
|
+
for (const r of due) Object.assign(r, { status: REDEMPTION_STATUS.released, reservedUntil: null, endedAt: now });
|
|
816
|
+
return due.map(clone);
|
|
817
|
+
},
|
|
818
|
+
async partnerReport(partnerId, period, now) {
|
|
819
|
+
const rows = /* @__PURE__ */ new Map();
|
|
820
|
+
for (const r of redemptions.values()) {
|
|
821
|
+
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;
|
|
823
|
+
const c = coupons.get(r.couponId);
|
|
824
|
+
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 };
|
|
826
|
+
row.redemptions += 1;
|
|
827
|
+
row.discountMinor += r.amountDiscountMinor ?? 0;
|
|
828
|
+
row.revenueMinor += (r.amountPaidMinor ?? 0) - r.amountRefundedMinor;
|
|
829
|
+
row.creditsGranted += c.kind === KIND.credit ? c.credits ?? 0 : 0;
|
|
830
|
+
rows.set(key, row);
|
|
831
|
+
}
|
|
832
|
+
return [...rows.values()].sort((a, b) => a.code.localeCompare(b.code) || (a.currency ?? "").localeCompare(b.currency ?? ""));
|
|
833
|
+
},
|
|
834
|
+
async activeAmountCoupons() {
|
|
835
|
+
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);
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/schema.ts
|
|
841
|
+
import { defineModule } from "@krizaka/db";
|
|
842
|
+
var couponsSchema = defineModule({
|
|
843
|
+
module: "coupons",
|
|
844
|
+
tables: ["owner"],
|
|
845
|
+
migrations: [
|
|
846
|
+
{
|
|
847
|
+
version: 1,
|
|
848
|
+
name: "partners, coupons and redemptions",
|
|
849
|
+
sql: (c) => `
|
|
850
|
+
CREATE TABLE ${c.schema}.partner (
|
|
851
|
+
id uuid PRIMARY KEY,
|
|
852
|
+
display_name text CHECK (display_name IS NULL OR char_length(display_name) BETWEEN 1 AND 80),
|
|
853
|
+
contact_email text CHECK (contact_email IS NULL OR contact_email = lower(contact_email)),
|
|
854
|
+
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
|
|
855
|
+
created_at timestamptz NOT NULL,
|
|
856
|
+
created_by uuid REFERENCES ${c.tables.owner}(id) ON DELETE SET NULL,
|
|
857
|
+
erased_at timestamptz,
|
|
858
|
+
CHECK (erased_at IS NOT NULL OR display_name IS NOT NULL),
|
|
859
|
+
CHECK (erased_at IS NULL OR (display_name IS NULL AND contact_email IS NULL))
|
|
860
|
+
);
|
|
861
|
+
CREATE TABLE ${c.schema}.coupon (
|
|
862
|
+
id uuid PRIMARY KEY,
|
|
863
|
+
code text NOT NULL CHECK (char_length(code) BETWEEN 4 AND 60),
|
|
864
|
+
code_key text NOT NULL UNIQUE CHECK (code_key = upper(code_key) AND char_length(code_key) BETWEEN 4 AND 40),
|
|
865
|
+
kind text NOT NULL CHECK (kind IN ('discount', 'credit')),
|
|
866
|
+
purpose text NOT NULL CHECK (purpose IN ('partner', 'promo', 'demo', 'support')),
|
|
867
|
+
partner_id uuid REFERENCES ${c.schema}.partner(id) ON DELETE RESTRICT,
|
|
868
|
+
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'archived')),
|
|
869
|
+
starts_at timestamptz NOT NULL,
|
|
870
|
+
ends_at timestamptz,
|
|
871
|
+
max_redemptions integer CHECK (max_redemptions > 0),
|
|
872
|
+
max_per_account integer NOT NULL DEFAULT 1 CHECK (max_per_account > 0),
|
|
873
|
+
new_customers_only boolean NOT NULL DEFAULT false,
|
|
874
|
+
plans text[] NOT NULL DEFAULT '{}',
|
|
875
|
+
percent_off_bp integer CHECK (percent_off_bp BETWEEN 1 AND 9999),
|
|
876
|
+
amount_off_minor integer CHECK (amount_off_minor > 0),
|
|
877
|
+
currency text CHECK (currency ~ '^[a-z]{3}$'),
|
|
878
|
+
duration text CHECK (duration IN ('once', 'repeating', 'forever')),
|
|
879
|
+
duration_months integer CHECK (duration_months BETWEEN 1 AND 36),
|
|
880
|
+
minimum_minor integer CHECK (minimum_minor >= 0),
|
|
881
|
+
credits integer CHECK (credits > 0),
|
|
882
|
+
cap_per_day integer CHECK (cap_per_day > 0),
|
|
883
|
+
cap_per_month integer CHECK (cap_per_month > 0),
|
|
884
|
+
credits_valid_days integer CHECK (credits_valid_days > 0),
|
|
885
|
+
on_exhausted text CHECK (on_exhausted IN ('stop', 'fallback_paid')),
|
|
886
|
+
terms_version integer NOT NULL DEFAULT 1,
|
|
887
|
+
created_at timestamptz NOT NULL,
|
|
888
|
+
created_by uuid REFERENCES ${c.tables.owner}(id) ON DELETE SET NULL,
|
|
889
|
+
updated_at timestamptz NOT NULL,
|
|
890
|
+
CHECK (ends_at IS NULL OR ends_at > starts_at),
|
|
891
|
+
CHECK ((purpose = 'partner') = (partner_id IS NOT NULL)),
|
|
892
|
+
CHECK (kind <> 'discount' OR (credits IS NULL AND num_nonnulls(percent_off_bp, amount_off_minor) = 1
|
|
893
|
+
AND (amount_off_minor IS NULL OR currency IS NOT NULL) AND duration IS NOT NULL
|
|
894
|
+
AND ((duration = 'repeating') = (duration_months IS NOT NULL))
|
|
895
|
+
AND (amount_off_minor IS NULL OR duration <> 'forever'))),
|
|
896
|
+
CHECK (kind <> 'credit' OR (credits IS NOT NULL AND on_exhausted IS NOT NULL
|
|
897
|
+
AND credits_valid_days IS NOT NULL
|
|
898
|
+
AND num_nulls(percent_off_bp, amount_off_minor, duration) = 3))
|
|
899
|
+
);
|
|
900
|
+
CREATE INDEX coupon_partner ON ${c.schema}.coupon (partner_id) WHERE partner_id IS NOT NULL;
|
|
901
|
+
CREATE TABLE ${c.schema}.redemption (
|
|
902
|
+
id uuid PRIMARY KEY,
|
|
903
|
+
coupon_id uuid NOT NULL REFERENCES ${c.schema}.coupon(id) ON DELETE RESTRICT,
|
|
904
|
+
account_id uuid REFERENCES ${c.tables.owner}(id) ON DELETE SET NULL,
|
|
905
|
+
partner_id uuid REFERENCES ${c.schema}.partner(id) ON DELETE SET NULL,
|
|
906
|
+
status text NOT NULL CHECK (status IN ('reserved', 'confirmed', 'released', 'revoked')),
|
|
907
|
+
idempotency_key text NOT NULL UNIQUE,
|
|
908
|
+
reserved_until timestamptz,
|
|
909
|
+
checkout_ref text UNIQUE,
|
|
910
|
+
payment_ref text UNIQUE,
|
|
911
|
+
amount_paid_minor bigint,
|
|
912
|
+
amount_discount_minor bigint,
|
|
913
|
+
amount_refunded_minor bigint NOT NULL DEFAULT 0 CHECK (amount_refunded_minor >= 0),
|
|
914
|
+
use_restored boolean NOT NULL DEFAULT false,
|
|
915
|
+
currency text,
|
|
916
|
+
credit_grant_id uuid,
|
|
917
|
+
over_limit boolean NOT NULL DEFAULT false,
|
|
918
|
+
created_at timestamptz NOT NULL,
|
|
919
|
+
confirmed_at timestamptz,
|
|
920
|
+
ended_at timestamptz
|
|
921
|
+
);
|
|
922
|
+
CREATE INDEX redemption_uses ON ${c.schema}.redemption (coupon_id, account_id) WHERE status <> 'released' AND NOT use_restored;
|
|
923
|
+
CREATE INDEX redemption_reserved ON ${c.schema}.redemption (reserved_until) WHERE status = 'reserved';
|
|
924
|
+
CREATE INDEX redemption_partner ON ${c.schema}.redemption (partner_id, confirmed_at) WHERE partner_id IS NOT NULL;`
|
|
925
|
+
}
|
|
926
|
+
]
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
export {
|
|
930
|
+
COUPONS_TOPIC,
|
|
931
|
+
COUPON_EVENT,
|
|
932
|
+
uuidOf,
|
|
933
|
+
COUPON_EVENT_SCHEMAS,
|
|
934
|
+
dbCouponStore,
|
|
935
|
+
SYSTEM,
|
|
936
|
+
createCoupons,
|
|
937
|
+
memoryCouponStore,
|
|
938
|
+
couponsSchema
|
|
939
|
+
};
|
|
940
|
+
//# sourceMappingURL=chunk-ECCGFGSV.js.map
|