@blamejs/blamejs-shop 0.0.66 → 0.0.70

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/click-and-collect.js +711 -0
  5. package/lib/clickstream.js +713 -0
  6. package/lib/customer-activity.js +862 -0
  7. package/lib/customer-notes.js +712 -0
  8. package/lib/customer-risk-profile.js +593 -0
  9. package/lib/customer-surveys.js +1012 -0
  10. package/lib/damage-photos.js +473 -0
  11. package/lib/dropship-forwarding.js +645 -0
  12. package/lib/email-templates.js +817 -0
  13. package/lib/index.js +35 -0
  14. package/lib/inventory-allocations.js +559 -0
  15. package/lib/inventory-writeoffs.js +636 -0
  16. package/lib/knowledge-base.js +1104 -0
  17. package/lib/locale-router.js +1077 -0
  18. package/lib/operator-roles.js +768 -0
  19. package/lib/order-escalation.js +951 -0
  20. package/lib/order-ratings.js +495 -0
  21. package/lib/order-tags.js +944 -0
  22. package/lib/packing-slips.js +810 -0
  23. package/lib/pixel-events.js +995 -0
  24. package/lib/print-queue.js +681 -0
  25. package/lib/product-qa.js +749 -0
  26. package/lib/promo-bundles.js +835 -0
  27. package/lib/push-notifications.js +937 -0
  28. package/lib/refund-automation.js +853 -0
  29. package/lib/reorder-reminders.js +798 -0
  30. package/lib/robots-config.js +753 -0
  31. package/lib/seller-signup.js +1052 -0
  32. package/lib/sitemap-generator.js +717 -0
  33. package/lib/subscription-gifts.js +710 -0
  34. package/lib/tax-cert-renewals.js +632 -0
  35. package/lib/tier-benefits.js +776 -0
  36. package/lib/vendor/MANIFEST.json +2 -2
  37. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  38. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  39. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  40. package/lib/vendor/blamejs/package.json +1 -1
  41. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  42. package/lib/wishlist-alerts.js +842 -0
  43. package/lib/wishlist-sharing.js +718 -0
  44. package/package.json +1 -1
@@ -0,0 +1,710 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.subscriptionGifts
4
+ * @title Subscription gifts — giver pays upfront for N cycles; the
5
+ * recipient claims the prepaid subscription with a redemption
6
+ * token. Also records owner-to-owner subscription transfers.
7
+ *
8
+ * @intro
9
+ * Two related flows live here:
10
+ *
11
+ * - GIFT: the giver buys a plan for someone else (a colleague,
12
+ * a friend, an anonymous recipient who'll receive a printed
13
+ * claim card). `purchaseGift` records the gift, generates a
14
+ * 32-byte URL-safe redemption token, and returns the plaintext
15
+ * exactly once — only the SHA3-512 hash persists. `redeemGift`
16
+ * presents the token, looks up the gift by hash, and creates a
17
+ * subscription bound to the recipient via the injected
18
+ * `subscriptions.create` handle. The number of prepaid cycles
19
+ * rides along as metadata so the subscriptions primitive (or
20
+ * its dunning gate) can suppress invoicing until the prepaid
21
+ * window elapses.
22
+ *
23
+ * - TRANSFER: the subscription's payer changes — a SaaS account
24
+ * passes between employees, a household subscription moves to
25
+ * a new credit-card holder. `transferOwnership` writes an
26
+ * append-only audit row capturing prior + new owner +
27
+ * reason + timestamp. The caller is responsible for updating
28
+ * the underlying `subscriptions.customer_id` (often via the
29
+ * payment provider) — this primitive owns only the ledger.
30
+ *
31
+ * Composition:
32
+ * var sg = bShop.subscriptionGifts.create({
33
+ * query: q,
34
+ * subscriptions: subs.subscriptions,
35
+ * });
36
+ * var purchased = await sg.purchaseGift({
37
+ * giver_customer_id: giverId,
38
+ * plan_id: planId,
39
+ * recipient_email: "alice@example.com",
40
+ * cycles_purchased: 12,
41
+ * message: "Happy birthday!",
42
+ * });
43
+ * // purchased.token plaintext — shown to the giver ONCE.
44
+ * // The recipient receives a link carrying the token and:
45
+ * var redeemed = await sg.redeemGift({
46
+ * token: purchased.token,
47
+ * recipient_customer_id: recipientId,
48
+ * });
49
+ * // redeemed.subscription_id — the newly minted subscription.
50
+ *
51
+ * Storage:
52
+ * - `subscription_gifts` — gift catalog + FSM (migration 0135).
53
+ * - `subscription_ownership_transfers` — append-only transfer
54
+ * audit (migration 0135).
55
+ *
56
+ * Composes only:
57
+ * - `b.guardUuid` — every UUID input validated.
58
+ * - `b.guardEmail` — recipient_email validated + normalised.
59
+ * - `b.uuid.v7` — gift / transfer row ids; lexicographic +
60
+ * monotonic so tied timestamps still sort.
61
+ * - `b.crypto.generateBytes` — 32-byte uniform draw for tokens.
62
+ * - `b.crypto.namespaceHash` — SHA3-512 of the token + recipient
63
+ * email under namespace constants so a row
64
+ * dump never carries plaintext.
65
+ *
66
+ * @primitive subscriptionGifts
67
+ * @related shop.subscriptions, b.crypto, b.guardEmail, b.guardUuid
68
+ */
69
+
70
+ var bShop;
71
+ function _b() {
72
+ if (!bShop) bShop = require("./index");
73
+ return bShop.framework;
74
+ }
75
+
76
+ // ---- constants ----------------------------------------------------------
77
+
78
+ var STATUSES = Object.freeze([
79
+ "pending", "delivered", "redeemed", "expired", "cancelled",
80
+ ]);
81
+
82
+ var TOKEN_NAMESPACE = "subscription-gift-token";
83
+ var EMAIL_NAMESPACE = "subscription-gift-recipient-email";
84
+
85
+ var TOKEN_BYTE_LEN = 32;
86
+ // 32 bytes -> 43 chars base64url (no padding).
87
+ var TOKEN_PLAINTEXT_RE = /^[A-Za-z0-9_-]{43}$/;
88
+
89
+ var MAX_MESSAGE_LEN = 4000;
90
+ var MAX_REASON_LEN = 280;
91
+ var MAX_CYCLES = 120; // 10y at monthly cadence — sanity cap
92
+ var MAX_AMOUNT_MINOR = 100000000000; // 1e11 — per-gift sanity cap
93
+ var MAX_LIST_LIMIT = 200;
94
+ var DEFAULT_LIST_LIMIT = 50;
95
+ // Default redemption window: 365 days. Operators wanting a different
96
+ // horizon pass `expires_at_ms` (absolute) at purchase time.
97
+ var DEFAULT_EXPIRY_MS = 365 * 86400 * 1000;
98
+
99
+ // ---- validators ---------------------------------------------------------
100
+
101
+ function _uuid(s, label) {
102
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
103
+ catch (e) { throw new TypeError("subscriptionGifts: " + label + " — " + (e && e.message || "invalid UUID")); }
104
+ }
105
+
106
+ function _planId(s) {
107
+ // Plan IDs in this codebase are UUIDs (see subscription_plans.id).
108
+ // A future migration that uses non-UUID plan ids would relax this;
109
+ // for now refuse anything non-UUID-shaped so a typo doesn't reach
110
+ // the subscriptions handle.
111
+ if (typeof s !== "string" || !s.length) {
112
+ throw new TypeError("subscriptionGifts: plan_id must be a non-empty string");
113
+ }
114
+ return s;
115
+ }
116
+
117
+ function _cycles(n) {
118
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_CYCLES) {
119
+ throw new TypeError("subscriptionGifts: cycles_purchased must be a positive integer <= " + MAX_CYCLES);
120
+ }
121
+ return n;
122
+ }
123
+
124
+ function _amountMinor(n) {
125
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_AMOUNT_MINOR) {
126
+ throw new TypeError("subscriptionGifts: total_charged_minor must be a positive integer <= " + MAX_AMOUNT_MINOR);
127
+ }
128
+ return n;
129
+ }
130
+
131
+ function _currency(c) {
132
+ if (typeof c !== "string" || c.length !== 3 || !/^[A-Z]{3}$/.test(c)) {
133
+ throw new TypeError("subscriptionGifts: currency must be a 3-letter ISO-4217 code (uppercase)");
134
+ }
135
+ return c;
136
+ }
137
+
138
+ function _optEpochMs(n, label) {
139
+ if (n == null) return null;
140
+ if (!Number.isInteger(n) || n < 0) {
141
+ throw new TypeError("subscriptionGifts: " + label + " must be a non-negative integer (ms epoch) or null");
142
+ }
143
+ return n;
144
+ }
145
+
146
+ function _message(s) {
147
+ if (s == null) return null;
148
+ if (typeof s !== "string") {
149
+ throw new TypeError("subscriptionGifts: message must be a string or null");
150
+ }
151
+ if (s.length > MAX_MESSAGE_LEN) {
152
+ throw new TypeError("subscriptionGifts: message must be <= " + MAX_MESSAGE_LEN + " chars");
153
+ }
154
+ // CR/LF allowed (multi-line gift cards); refuse NUL + other control
155
+ // bytes so a malicious message can't smuggle header-injection
156
+ // content into a downstream email-template renderer.
157
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
158
+ throw new TypeError("subscriptionGifts: message must not contain control bytes");
159
+ }
160
+ return s;
161
+ }
162
+
163
+ function _reason(s) {
164
+ if (typeof s !== "string" || !s.length) {
165
+ throw new TypeError("subscriptionGifts: reason must be a non-empty string");
166
+ }
167
+ if (s.length > MAX_REASON_LEN) {
168
+ throw new TypeError("subscriptionGifts: reason must be <= " + MAX_REASON_LEN + " chars");
169
+ }
170
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
171
+ throw new TypeError("subscriptionGifts: reason must not contain control bytes");
172
+ }
173
+ return s;
174
+ }
175
+
176
+ // Strict-monotonic clock: two same-millisecond writes produce
177
+ // distinct integers so `created_at` / `occurred_at` ordering is
178
+ // deterministic. Backdated operator writes are uncommon here — the
179
+ // purchase / redeem / transfer paths are all "now" — but the
180
+ // invariant matters for the test loop that purchases + queries in
181
+ // tight sequence.
182
+ var _lastTs = 0;
183
+ function _now() {
184
+ var t = Date.now();
185
+ if (t <= _lastTs) { t = _lastTs + 1; }
186
+ _lastTs = t;
187
+ return t;
188
+ }
189
+
190
+ // ---- email normalisation -----------------------------------------------
191
+
192
+ function _normaliseEmail(input) {
193
+ if (typeof input !== "string" || !input.length) {
194
+ throw new TypeError("subscriptionGifts: recipient_email must be a non-empty string");
195
+ }
196
+ var guardEmail = _b().guardEmail;
197
+ var report;
198
+ try {
199
+ report = guardEmail.validate(input, { profile: "strict" });
200
+ } catch (e) {
201
+ throw new TypeError("subscriptionGifts: recipient_email — " + (e && e.message || "invalid email"));
202
+ }
203
+ if (!report || report.ok === false) {
204
+ var first = (report && report.issues && report.issues[0]) || {};
205
+ throw new TypeError("subscriptionGifts: recipient_email — " + (first.snippet || first.ruleId || "refused at strict profile"));
206
+ }
207
+ var canonical;
208
+ try {
209
+ canonical = guardEmail.sanitize(input, { profile: "strict" });
210
+ } catch (e2) {
211
+ throw new TypeError("subscriptionGifts: recipient_email — " + (e2 && e2.message || "refused"));
212
+ }
213
+ // Fold the domain (RFC 5321) so the hash is stable across cosmetic
214
+ // casing. Local-part is left untouched — operators whose mailbox
215
+ // server is case-sensitive shouldn't have a lookup collapse two
216
+ // distinct recipients.
217
+ var at = canonical.lastIndexOf("@");
218
+ if (at !== -1) {
219
+ canonical = canonical.slice(0, at) + "@" + canonical.slice(at + 1).toLowerCase();
220
+ }
221
+ return canonical;
222
+ }
223
+
224
+ function _hashEmail(canonical) {
225
+ return _b().crypto.namespaceHash(EMAIL_NAMESPACE, canonical);
226
+ }
227
+
228
+ // ---- token generation + hashing ----------------------------------------
229
+
230
+ function _generateToken() {
231
+ var buf = _b().crypto.generateBytes(TOKEN_BYTE_LEN);
232
+ // base64url, no padding. Manual rewrite so the primitive doesn't
233
+ // depend on a Buffer-side flag rename across Node minors.
234
+ return buf.toString("base64")
235
+ .replace(/\+/g, "-")
236
+ .replace(/\//g, "_")
237
+ .replace(/=+$/, "");
238
+ }
239
+
240
+ function _canonicalToken(input) {
241
+ if (typeof input !== "string" || !input.length) {
242
+ throw new TypeError("subscriptionGifts: token must be a non-empty string");
243
+ }
244
+ if (!TOKEN_PLAINTEXT_RE.test(input)) {
245
+ throw new TypeError("subscriptionGifts: token must be 43 base64url characters");
246
+ }
247
+ return input;
248
+ }
249
+
250
+ function _hashToken(canonical) {
251
+ return _b().crypto.namespaceHash(TOKEN_NAMESPACE, canonical);
252
+ }
253
+
254
+ // ---- row hydration -----------------------------------------------------
255
+
256
+ function _hydrateGift(r) {
257
+ if (!r) return null;
258
+ return {
259
+ id: r.id,
260
+ giver_customer_id: r.giver_customer_id,
261
+ plan_id: r.plan_id,
262
+ recipient_email_hash: r.recipient_email_hash == null ? null : r.recipient_email_hash,
263
+ recipient_email_normalised: r.recipient_email_normalised == null ? null : r.recipient_email_normalised,
264
+ cycles_purchased: Number(r.cycles_purchased),
265
+ total_charged_minor: Number(r.total_charged_minor),
266
+ currency: r.currency,
267
+ token_hash: r.token_hash,
268
+ message: r.message == null ? null : r.message,
269
+ scheduled_delivery_at: r.scheduled_delivery_at == null ? null : Number(r.scheduled_delivery_at),
270
+ delivered_at: r.delivered_at == null ? null : Number(r.delivered_at),
271
+ status: r.status,
272
+ redeemed_at: r.redeemed_at == null ? null : Number(r.redeemed_at),
273
+ redeemed_subscription_id: r.redeemed_subscription_id == null ? null : r.redeemed_subscription_id,
274
+ cancelled_at: r.cancelled_at == null ? null : Number(r.cancelled_at),
275
+ cancel_reason: r.cancel_reason == null ? null : r.cancel_reason,
276
+ created_at: Number(r.created_at),
277
+ expires_at: Number(r.expires_at),
278
+ };
279
+ }
280
+
281
+ function _hydrateTransfer(r) {
282
+ if (!r) return null;
283
+ return {
284
+ id: r.id,
285
+ subscription_id: r.subscription_id,
286
+ from_customer_id: r.from_customer_id,
287
+ to_customer_id: r.to_customer_id,
288
+ reason: r.reason,
289
+ occurred_at: Number(r.occurred_at),
290
+ };
291
+ }
292
+
293
+ // ---- factory -----------------------------------------------------------
294
+
295
+ function create(opts) {
296
+ opts = opts || {};
297
+ var query = opts.query;
298
+ if (!query) {
299
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
300
+ }
301
+
302
+ // `subscriptions` handle is required for redeemGift — it composes
303
+ // `.create(...)` to mint the recipient subscription. `purchaseGift`
304
+ // + `cancelGift` + `transferOwnership` are storage-only and don't
305
+ // hit the handle, so the factory accepts a missing handle gracefully
306
+ // and refuses only when `redeemGift` is actually called. This keeps
307
+ // operator-side audit / cleanup flows usable without standing up
308
+ // the full Stripe surface in a smoke environment.
309
+ var subscriptionsHandle = opts.subscriptions || null;
310
+ if (subscriptionsHandle != null && typeof subscriptionsHandle.create !== "function") {
311
+ throw new TypeError("subscriptionGifts: opts.subscriptions handle must expose create(...)");
312
+ }
313
+
314
+ // Optional email handle — accepted (and validated at the factory)
315
+ // so a future delivery flow can wire it in without a breaking shape
316
+ // change. The v1 surface doesn't send mail itself (the operator
317
+ // wires their own delivery pipeline by reading `expiringGifts` /
318
+ // pending gifts), so a missing handle is fine.
319
+ var emailHandle = opts.email || null;
320
+ if (emailHandle != null && typeof emailHandle !== "object") {
321
+ throw new TypeError("subscriptionGifts: opts.email must be an object or null");
322
+ }
323
+
324
+ // ---- internal helpers -------------------------------------------------
325
+
326
+ async function _getRaw(id) {
327
+ var r = await query("SELECT * FROM subscription_gifts WHERE id = ?1", [id]);
328
+ return r.rows[0] || null;
329
+ }
330
+
331
+ async function _getRawByTokenHash(tokenHash) {
332
+ var r = await query("SELECT * FROM subscription_gifts WHERE token_hash = ?1", [tokenHash]);
333
+ return r.rows[0] || null;
334
+ }
335
+
336
+ // ---- purchaseGift ----------------------------------------------------
337
+
338
+ async function purchaseGift(input) {
339
+ if (!input || typeof input !== "object") {
340
+ throw new TypeError("subscriptionGifts.purchaseGift: input object required");
341
+ }
342
+ var giverId = _uuid(input.giver_customer_id, "giver_customer_id");
343
+ var planId = _planId(input.plan_id);
344
+ var cyclesPurchased = _cycles(input.cycles_purchased);
345
+ var totalCharged = _amountMinor(input.total_charged_minor);
346
+ var currency = _currency(input.currency);
347
+ var scheduledDelivery = _optEpochMs(input.scheduled_delivery_at, "scheduled_delivery_at");
348
+ var expiresAtInput = _optEpochMs(input.expires_at, "expires_at");
349
+ var message = _message(input.message);
350
+
351
+ var recipientEmail = null;
352
+ var recipientHash = null;
353
+ if (input.recipient_email != null) {
354
+ recipientEmail = _normaliseEmail(input.recipient_email);
355
+ recipientHash = _hashEmail(recipientEmail);
356
+ }
357
+
358
+ var ts = _now();
359
+ var expiresAt = expiresAtInput != null ? expiresAtInput : (ts + DEFAULT_EXPIRY_MS);
360
+ if (expiresAt <= ts) {
361
+ throw new TypeError("subscriptionGifts.purchaseGift: expires_at must be strictly after now");
362
+ }
363
+
364
+ // Generate the token + hash up front. Collision on the hash
365
+ // UNIQUE constraint is astronomically unlikely (SHA3-512 over
366
+ // 2^256 input space) but retry on the off chance an operator
367
+ // hand-inserts a row in the same boot.
368
+ var attempts = 0;
369
+ var giftId = _b().uuid.v7();
370
+ var lastErr;
371
+ var plaintext;
372
+ var tokenHash;
373
+ while (attempts < 5) {
374
+ attempts += 1;
375
+ plaintext = _generateToken();
376
+ tokenHash = _hashToken(plaintext);
377
+ try {
378
+ await query(
379
+ "INSERT INTO subscription_gifts " +
380
+ "(id, giver_customer_id, plan_id, recipient_email_hash, recipient_email_normalised, " +
381
+ " cycles_purchased, total_charged_minor, currency, token_hash, message, " +
382
+ " scheduled_delivery_at, delivered_at, status, redeemed_at, redeemed_subscription_id, " +
383
+ " cancelled_at, cancel_reason, created_at, expires_at) " +
384
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL, 'pending', NULL, NULL, NULL, NULL, ?12, ?13)",
385
+ [
386
+ giftId, giverId, planId, recipientHash, recipientEmail,
387
+ cyclesPurchased, totalCharged, currency, tokenHash, message,
388
+ scheduledDelivery, ts, expiresAt,
389
+ ],
390
+ );
391
+ lastErr = null;
392
+ break;
393
+ } catch (e) {
394
+ lastErr = e;
395
+ if (!e || !e.message || e.message.indexOf("UNIQUE") === -1) throw e;
396
+ }
397
+ }
398
+ if (lastErr) throw lastErr;
399
+
400
+ var hydrated = _hydrateGift(await _getRaw(giftId));
401
+ // Plaintext token is returned exactly once. It NEVER appears in
402
+ // any subsequent read path — only the hash persists.
403
+ hydrated.token = plaintext;
404
+ return hydrated;
405
+ }
406
+
407
+ // ---- redeemGift ------------------------------------------------------
408
+
409
+ async function redeemGift(input) {
410
+ if (!input || typeof input !== "object") {
411
+ throw new TypeError("subscriptionGifts.redeemGift: input object required");
412
+ }
413
+ if (!subscriptionsHandle) {
414
+ throw new TypeError("subscriptionGifts.redeemGift: opts.subscriptions handle is required to mint the recipient subscription");
415
+ }
416
+ var token = _canonicalToken(input.token);
417
+ var recipientId = _uuid(input.recipient_customer_id, "recipient_customer_id");
418
+
419
+ var tokenHash = _hashToken(token);
420
+ var existing = await _getRawByTokenHash(tokenHash);
421
+ if (!existing) {
422
+ var notFound = new Error("subscriptionGifts.redeemGift: token does not match any gift");
423
+ notFound.code = "SUBSCRIPTION_GIFT_TOKEN_INVALID";
424
+ throw notFound;
425
+ }
426
+ var status = existing.status;
427
+ if (status === "redeemed") {
428
+ var dup = new Error("subscriptionGifts.redeemGift: gift already redeemed");
429
+ dup.code = "SUBSCRIPTION_GIFT_ALREADY_REDEEMED";
430
+ throw dup;
431
+ }
432
+ if (status === "cancelled" || status === "expired") {
433
+ var dead = new Error("subscriptionGifts.redeemGift: gift status is '" + status + "', not redeemable");
434
+ dead.code = "SUBSCRIPTION_GIFT_NOT_REDEEMABLE";
435
+ throw dead;
436
+ }
437
+ // pending / delivered both proceed. Expired-by-clock (status
438
+ // still 'pending' or 'delivered' but `expires_at` past) is
439
+ // refused inline so a recipient who held the link past the
440
+ // window doesn't sneak through.
441
+ var now = _now();
442
+ if (now >= Number(existing.expires_at)) {
443
+ var stale = new Error("subscriptionGifts.redeemGift: gift expired");
444
+ stale.code = "SUBSCRIPTION_GIFT_NOT_REDEEMABLE";
445
+ throw stale;
446
+ }
447
+
448
+ // Compose subscriptions.create. The shape mirrors the
449
+ // existing subscriptions primitive's surface (customer_id +
450
+ // plan_id) and threads the prepaid-cycle window via metadata
451
+ // so a downstream dunning gate can suppress invoicing.
452
+ var createInput = {
453
+ customer_id: recipientId,
454
+ plan_id: existing.plan_id,
455
+ // No payment method — the gift is prepaid; the operator's
456
+ // subscriptions implementation MUST honor the metadata flag
457
+ // and skip the Stripe payment-method requirement when the
458
+ // prepaid_cycles count is positive.
459
+ metadata: {
460
+ subscription_gift_id: existing.id,
461
+ prepaid_cycles: Number(existing.cycles_purchased),
462
+ gifted_by: existing.giver_customer_id,
463
+ },
464
+ };
465
+ var created = await subscriptionsHandle.create(createInput);
466
+ if (!created || typeof created !== "object" || typeof created.id !== "string" || !created.id.length) {
467
+ throw new Error("subscriptionGifts.redeemGift: subscriptions.create must return an object with a string id");
468
+ }
469
+ var subscriptionId = created.id;
470
+
471
+ // FSM transition: pending|delivered -> redeemed. Guarded by the
472
+ // current status so a concurrent redeem (token leaked + presented
473
+ // twice within ms) refuses cleanly.
474
+ var r = await query(
475
+ "UPDATE subscription_gifts SET status = 'redeemed', redeemed_at = ?1, redeemed_subscription_id = ?2 " +
476
+ "WHERE id = ?3 AND status IN ('pending','delivered')",
477
+ [now, subscriptionId, existing.id],
478
+ );
479
+ if (Number(r.rowCount || 0) === 0) {
480
+ // Lost the race — re-read so we surface the actual post-race
481
+ // status to the caller.
482
+ var after = await _getRaw(existing.id);
483
+ var raceErr = new Error(
484
+ "subscriptionGifts.redeemGift: gift status changed to '" +
485
+ (after && after.status) + "' during redeem"
486
+ );
487
+ raceErr.code = "SUBSCRIPTION_GIFT_ALREADY_REDEEMED";
488
+ throw raceErr;
489
+ }
490
+ return {
491
+ gift: _hydrateGift(await _getRaw(existing.id)),
492
+ subscription_id: subscriptionId,
493
+ };
494
+ }
495
+
496
+ // ---- cancelGift ------------------------------------------------------
497
+
498
+ async function cancelGift(input) {
499
+ if (!input || typeof input !== "object") {
500
+ throw new TypeError("subscriptionGifts.cancelGift: input object required");
501
+ }
502
+ var giftId = _uuid(input.gift_id, "gift_id");
503
+ var reason = _reason(input.reason);
504
+
505
+ var existing = await _getRaw(giftId);
506
+ if (!existing) {
507
+ throw new TypeError("subscriptionGifts.cancelGift: gift " + JSON.stringify(giftId) + " not found");
508
+ }
509
+ if (existing.status === "redeemed") {
510
+ // Once the recipient has redeemed, the underlying subscription
511
+ // is live; the giver-side cancel surface no longer applies.
512
+ // Operators wanting to refund a redeemed gift cancel the
513
+ // subscription itself via subscriptions.cancel + issue the
514
+ // refund separately.
515
+ var dead = new Error("subscriptionGifts.cancelGift: gift already redeemed; cancel the subscription instead");
516
+ dead.code = "SUBSCRIPTION_GIFT_ALREADY_REDEEMED";
517
+ throw dead;
518
+ }
519
+ if (existing.status === "cancelled" || existing.status === "expired") {
520
+ var stale = new Error("subscriptionGifts.cancelGift: gift status is '" + existing.status + "', cannot cancel");
521
+ stale.code = "SUBSCRIPTION_GIFT_NOT_CANCELLABLE";
522
+ throw stale;
523
+ }
524
+
525
+ var ts = _now();
526
+ var r = await query(
527
+ "UPDATE subscription_gifts SET status = 'cancelled', cancelled_at = ?1, cancel_reason = ?2 " +
528
+ "WHERE id = ?3 AND status IN ('pending','delivered')",
529
+ [ts, reason, giftId],
530
+ );
531
+ if (Number(r.rowCount || 0) === 0) {
532
+ var after = await _getRaw(giftId);
533
+ var raceErr = new Error(
534
+ "subscriptionGifts.cancelGift: gift status changed to '" +
535
+ (after && after.status) + "' during cancel"
536
+ );
537
+ raceErr.code = "SUBSCRIPTION_GIFT_NOT_CANCELLABLE";
538
+ throw raceErr;
539
+ }
540
+ // Refund of the charged amount is the caller's responsibility —
541
+ // this primitive owns only the gift FSM. The operator's
542
+ // checkout / payment surface is the right place to issue the
543
+ // money-side reversal; that flow is already tested against
544
+ // its own primitive.
545
+ return _hydrateGift(await _getRaw(giftId));
546
+ }
547
+
548
+ // ---- transferOwnership -----------------------------------------------
549
+
550
+ async function transferOwnership(input) {
551
+ if (!input || typeof input !== "object") {
552
+ throw new TypeError("subscriptionGifts.transferOwnership: input object required");
553
+ }
554
+ var subscriptionId = _uuid(input.subscription_id, "subscription_id");
555
+ var newOwnerId = _uuid(input.new_owner_customer_id, "new_owner_customer_id");
556
+ var reason = _reason(input.reason);
557
+ // `from_customer_id` is optional — when omitted, the caller is
558
+ // recording the transfer without surfacing the prior owner
559
+ // (e.g. inherited account whose previous holder is unknown).
560
+ // When present, validate as a UUID for symmetry with `to`.
561
+ var fromOwnerId = input.from_customer_id == null
562
+ ? null
563
+ : _uuid(input.from_customer_id, "from_customer_id");
564
+ if (!fromOwnerId) {
565
+ throw new TypeError("subscriptionGifts.transferOwnership: from_customer_id required");
566
+ }
567
+ if (fromOwnerId === newOwnerId) {
568
+ throw new TypeError("subscriptionGifts.transferOwnership: from_customer_id and new_owner_customer_id must differ");
569
+ }
570
+
571
+ var id = _b().uuid.v7();
572
+ var ts = _now();
573
+ await query(
574
+ "INSERT INTO subscription_ownership_transfers " +
575
+ "(id, subscription_id, from_customer_id, to_customer_id, reason, occurred_at) " +
576
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
577
+ [id, subscriptionId, fromOwnerId, newOwnerId, reason, ts],
578
+ );
579
+ return _hydrateTransfer({
580
+ id: id,
581
+ subscription_id: subscriptionId,
582
+ from_customer_id: fromOwnerId,
583
+ to_customer_id: newOwnerId,
584
+ reason: reason,
585
+ occurred_at: ts,
586
+ });
587
+ }
588
+
589
+ // ---- getGift / giftsForGiver ----------------------------------------
590
+
591
+ async function getGift(giftId) {
592
+ _uuid(giftId, "gift_id");
593
+ return _hydrateGift(await _getRaw(giftId));
594
+ }
595
+
596
+ async function giftsForGiver(customerId, listOpts) {
597
+ _uuid(customerId, "customer_id");
598
+ listOpts = listOpts || {};
599
+ var limit = listOpts.limit == null ? DEFAULT_LIST_LIMIT : listOpts.limit;
600
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
601
+ throw new TypeError("subscriptionGifts.giftsForGiver: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
602
+ }
603
+ var sql = "SELECT * FROM subscription_gifts WHERE giver_customer_id = ?1";
604
+ var params = [customerId];
605
+ if (listOpts.cursor != null) {
606
+ if (!Number.isInteger(listOpts.cursor) || listOpts.cursor < 0) {
607
+ throw new TypeError("subscriptionGifts.giftsForGiver: cursor must be a non-negative integer epoch-ms");
608
+ }
609
+ sql += " AND created_at < ?2";
610
+ params.push(listOpts.cursor);
611
+ }
612
+ sql += " ORDER BY created_at DESC, id DESC LIMIT ?" + (params.length + 1);
613
+ params.push(limit);
614
+ var rows = (await query(sql, params)).rows;
615
+ var hydrated = [];
616
+ for (var i = 0; i < rows.length; i += 1) hydrated.push(_hydrateGift(rows[i]));
617
+ var nextCursor = rows.length === limit ? hydrated[hydrated.length - 1].created_at : null;
618
+ return { rows: hydrated, next_cursor: nextCursor };
619
+ }
620
+
621
+ // ---- transfersForSubscription ---------------------------------------
622
+
623
+ async function transfersForSubscription(subscriptionId, listOpts) {
624
+ _uuid(subscriptionId, "subscription_id");
625
+ listOpts = listOpts || {};
626
+ var limit = listOpts.limit == null ? DEFAULT_LIST_LIMIT : listOpts.limit;
627
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
628
+ throw new TypeError("subscriptionGifts.transfersForSubscription: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
629
+ }
630
+ var rows = (await query(
631
+ "SELECT * FROM subscription_ownership_transfers " +
632
+ "WHERE subscription_id = ?1 " +
633
+ "ORDER BY occurred_at DESC, id DESC LIMIT ?2",
634
+ [subscriptionId, limit],
635
+ )).rows;
636
+ var hydrated = [];
637
+ for (var i = 0; i < rows.length; i += 1) hydrated.push(_hydrateTransfer(rows[i]));
638
+ return hydrated;
639
+ }
640
+
641
+ // ---- expiringGifts ---------------------------------------------------
642
+
643
+ async function expiringGifts(input) {
644
+ if (!input || typeof input !== "object") {
645
+ throw new TypeError("subscriptionGifts.expiringGifts: input object required");
646
+ }
647
+ var before = _optEpochMs(input.before, "before");
648
+ if (before == null) {
649
+ throw new TypeError("subscriptionGifts.expiringGifts: before is required");
650
+ }
651
+ var limit = input.limit == null ? DEFAULT_LIST_LIMIT : input.limit;
652
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
653
+ throw new TypeError("subscriptionGifts.expiringGifts: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
654
+ }
655
+ // Surface only still-unredeemed gifts whose expires_at is at or
656
+ // before `before` — pending + delivered are the redeemable
657
+ // statuses that age out of the window. Already redeemed /
658
+ // cancelled / previously expired rows are excluded.
659
+ var rows = (await query(
660
+ "SELECT * FROM subscription_gifts " +
661
+ "WHERE status IN ('pending','delivered') AND expires_at <= ?1 " +
662
+ "ORDER BY expires_at ASC, id ASC LIMIT ?2",
663
+ [before, limit],
664
+ )).rows;
665
+ var hydrated = [];
666
+ for (var i = 0; i < rows.length; i += 1) hydrated.push(_hydrateGift(rows[i]));
667
+ return hydrated;
668
+ }
669
+
670
+ // ---- cleanupExpired --------------------------------------------------
671
+
672
+ async function cleanupExpired(input) {
673
+ input = input || {};
674
+ var now = input.now == null ? _now() : _optEpochMs(input.now, "now");
675
+ // Flip every still-redeemable gift whose absolute deadline has
676
+ // passed to the 'expired' status. The transition is one-way and
677
+ // idempotent — already-expired rows match the WHERE on
678
+ // redeemable statuses only. Returns the count of transitions
679
+ // for operator telemetry.
680
+ var r = await query(
681
+ "UPDATE subscription_gifts SET status = 'expired' " +
682
+ "WHERE status IN ('pending','delivered') AND expires_at <= ?1",
683
+ [now],
684
+ );
685
+ return { expired: Number(r.rowCount || 0) };
686
+ }
687
+
688
+ return {
689
+ STATUSES: STATUSES.slice(),
690
+ TOKEN_NAMESPACE: TOKEN_NAMESPACE,
691
+ EMAIL_NAMESPACE: EMAIL_NAMESPACE,
692
+
693
+ purchaseGift: purchaseGift,
694
+ redeemGift: redeemGift,
695
+ cancelGift: cancelGift,
696
+ transferOwnership: transferOwnership,
697
+ getGift: getGift,
698
+ giftsForGiver: giftsForGiver,
699
+ transfersForSubscription: transfersForSubscription,
700
+ expiringGifts: expiringGifts,
701
+ cleanupExpired: cleanupExpired,
702
+ };
703
+ }
704
+
705
+ module.exports = {
706
+ create: create,
707
+ STATUSES: STATUSES,
708
+ TOKEN_NAMESPACE: TOKEN_NAMESPACE,
709
+ EMAIL_NAMESPACE: EMAIL_NAMESPACE,
710
+ };