@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.
- package/CHANGELOG.md +8 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +35 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/pixel-events.js +995 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- 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
|
+
};
|