@blamejs/blamejs-shop 0.3.67 → 0.3.69

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/lib/cart.js CHANGED
@@ -35,6 +35,12 @@ var DEFAULT_TTL_MS = C.TIME.days(30);
35
35
  var MAX_QTY = 99999;
36
36
  var SESSION_ID_RE = /^[A-Za-z0-9_-]{16,64}$/; // shape-only; sealed-cookie origin
37
37
  var CURRENCY_RE = /^[A-Z]{3}$/;
38
+ // Discount-code shape — the string a shopper types on the cart page. Same
39
+ // alnum + dot + hyphen + underscore family the autoDiscount unlock_code +
40
+ // couponStacking primitives accept, so the three surfaces agree on what a
41
+ // "code" is. Refuses whitespace + control bytes; caps length.
42
+ var DISCOUNT_CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
43
+ var MAX_CODES_PER_CART = 16;
38
44
 
39
45
  // ---- validators ---------------------------------------------------------
40
46
 
@@ -62,6 +68,12 @@ function _status(s) {
62
68
  throw new TypeError("cart: status must be one of " + CART_STATUSES.join(", "));
63
69
  }
64
70
  }
71
+ function _discountCode(s) {
72
+ if (typeof s !== "string" || !DISCOUNT_CODE_RE.test(s)) {
73
+ throw new TypeError("cart: discount code must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ 64 chars)");
74
+ }
75
+ return s;
76
+ }
65
77
 
66
78
  function _now() { return Date.now(); }
67
79
 
@@ -255,6 +267,27 @@ function create(opts) {
255
267
  );
256
268
  }
257
269
  }
270
+ // Carry the anonymous cart's applied discount codes onto the
271
+ // surviving cart so a code typed before sign-in isn't silently
272
+ // dropped on login. A code already on the destination cart wins
273
+ // (INSERT OR IGNORE against the UNIQUE (cart_id, code_lower)).
274
+ // Best-effort: the cart_discount_codes table may not be migrated on a
275
+ // given deploy (the coupon feature is additive), so a missing table
276
+ // degrades to "codes not carried" rather than failing the whole login
277
+ // merge — the line merge is the load-bearing part.
278
+ try {
279
+ var fromCodes = (await query(
280
+ "SELECT * FROM cart_discount_codes WHERE cart_id = ?1", [fromCartId],
281
+ )).rows;
282
+ for (var ci = 0; ci < fromCodes.length; ci += 1) {
283
+ var fc = fromCodes[ci];
284
+ await query(
285
+ "INSERT OR IGNORE INTO cart_discount_codes (id, cart_id, code, code_lower, rule_slug, applied_at) " +
286
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
287
+ [b.uuid.v7(), toCartId, fc.code, fc.code_lower, fc.rule_slug, ts],
288
+ );
289
+ }
290
+ } catch (_e) { /* cart_discount_codes unmigrated — codes simply don't carry */ }
258
291
  await query("UPDATE carts SET status = 'abandoned', updated_at = ?1 WHERE id = ?2", [ts, fromCartId]);
259
292
  await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, toCartId]);
260
293
  return (await query("SELECT * FROM carts WHERE id = ?1", [toCartId])).rows[0];
@@ -283,6 +316,77 @@ function create(opts) {
283
316
  if (r.rowCount === 0) return null;
284
317
  return (await query("SELECT * FROM carts WHERE id = ?1", [cartId])).rows[0];
285
318
  },
319
+
320
+ // ---- applied discount codes ---------------------------------------
321
+ //
322
+ // A cart carries the discount codes a shopper applied on the cart page.
323
+ // The codes are stored as typed; `code_lower` is the case-insensitive
324
+ // key the UNIQUE (cart_id, code_lower) constraint + the rule lookup
325
+ // both use, so applying the same code twice is idempotent rather than
326
+ // stacking duplicate rows. These methods own ONLY the storage; the
327
+ // caller validates a code against the discount engine before applying
328
+ // (the cart never decides a code is valid — it persists what the
329
+ // storefront route accepted).
330
+
331
+ // Persist an accepted code on the cart. `rule_slug` snapshots which
332
+ // discount rule the code resolved to at apply time (audit convenience).
333
+ // Idempotent on (cart_id, code_lower): re-applying refreshes the
334
+ // snapshot + timestamp rather than erroring. Returns the stored row.
335
+ addDiscountCode: async function (cartId, code, ruleSlug) {
336
+ _uuid(cartId, "cart_id");
337
+ _discountCode(code);
338
+ var lower = code.toLowerCase();
339
+ var ts = _now();
340
+ var existing = (await query(
341
+ "SELECT id FROM cart_discount_codes WHERE cart_id = ?1 AND code_lower = ?2 LIMIT 1",
342
+ [cartId, lower],
343
+ )).rows[0];
344
+ if (existing) {
345
+ await query(
346
+ "UPDATE cart_discount_codes SET code = ?1, rule_slug = ?2, applied_at = ?3 WHERE id = ?4",
347
+ [code, ruleSlug == null ? null : String(ruleSlug), ts, existing.id],
348
+ );
349
+ return { id: existing.id, cart_id: cartId, code: code, code_lower: lower, rule_slug: ruleSlug == null ? null : String(ruleSlug), applied_at: ts };
350
+ }
351
+ // Cap the codes a single cart can carry so a scripted apply loop can't
352
+ // grow the row set unbounded.
353
+ var countRow = (await query(
354
+ "SELECT COUNT(*) AS n FROM cart_discount_codes WHERE cart_id = ?1",
355
+ [cartId],
356
+ )).rows[0] || {};
357
+ if ((Number(countRow.n) || 0) >= MAX_CODES_PER_CART) {
358
+ throw new TypeError("cart.addDiscountCode: cart already carries the maximum of " + MAX_CODES_PER_CART + " codes");
359
+ }
360
+ var id = b.uuid.v7();
361
+ await query(
362
+ "INSERT INTO cart_discount_codes (id, cart_id, code, code_lower, rule_slug, applied_at) " +
363
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
364
+ [id, cartId, code, lower, ruleSlug == null ? null : String(ruleSlug), ts],
365
+ );
366
+ return { id: id, cart_id: cartId, code: code, code_lower: lower, rule_slug: ruleSlug == null ? null : String(ruleSlug), applied_at: ts };
367
+ },
368
+
369
+ // The cart's applied codes, apply order. Returns the stored rows.
370
+ listDiscountCodes: async function (cartId) {
371
+ _uuid(cartId, "cart_id");
372
+ var r = await query(
373
+ "SELECT * FROM cart_discount_codes WHERE cart_id = ?1 ORDER BY applied_at ASC, id ASC",
374
+ [cartId],
375
+ );
376
+ return r.rows;
377
+ },
378
+
379
+ // Remove one applied code (case-insensitive). Returns true when a row
380
+ // was removed, false when the code wasn't on the cart.
381
+ removeDiscountCode: async function (cartId, code) {
382
+ _uuid(cartId, "cart_id");
383
+ _discountCode(code);
384
+ var r = await query(
385
+ "DELETE FROM cart_discount_codes WHERE cart_id = ?1 AND code_lower = ?2",
386
+ [cartId, code.toLowerCase()],
387
+ );
388
+ return Number(r.rowCount || 0) > 0;
389
+ },
286
390
  };
287
391
  }
288
392
 
package/lib/checkout.js CHANGED
@@ -284,7 +284,7 @@ function create(deps) {
284
284
  // the subtotal — the order total can therefore never go negative,
285
285
  // and a customer can never be charged less than zero or credited
286
286
  // more than the cart is worth.
287
- async function _resolveAutoDiscount(lines, subtotalMinor, customerId) {
287
+ async function _resolveAutoDiscount(lines, subtotalMinor, customerId, codes) {
288
288
  if (!autoDiscount || typeof autoDiscount.evaluate !== "function") {
289
289
  return { discount_minor: 0, applied: [] };
290
290
  }
@@ -304,6 +304,10 @@ function create(deps) {
304
304
  lines: evalLines,
305
305
  },
306
306
  customer_id: customerId || undefined,
307
+ // Shopper-presented coupon codes unlock code-gated rules. Absent /
308
+ // empty leaves only the pure-automatic rules in play, so a quote
309
+ // with no codes is byte-identical to the pre-code behaviour.
310
+ codes: Array.isArray(codes) && codes.length ? codes : undefined,
307
311
  });
308
312
  var appliedRaw = res && Array.isArray(res.applied) ? res.applied : [];
309
313
  var total = 0;
@@ -660,7 +664,7 @@ function create(deps) {
660
664
  // resolver clamps the result to [0, subtotal] and falls back to 0
661
665
  // on any failure, so the total math below is identical to the
662
666
  // un-wired flow whenever no rule applies.
663
- var autoDisc = await _resolveAutoDiscount(lines, sub.amount_minor, c.customer_id || null);
667
+ var autoDisc = await _resolveAutoDiscount(lines, sub.amount_minor, c.customer_id || null, input.codes);
664
668
 
665
669
  var totals = pricing.totals(c, lines, {
666
670
  tax_minor: taxRow.tax_minor,