@blamejs/blamejs-shop 0.4.14 → 0.4.16

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.14",
2
+ "version": "0.4.16",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
package/lib/catalog.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * - `variants` — create, get, listForProduct, update, delete
12
12
  * - `prices` — set (versioned), current, history
13
13
  * - `inventory` — create, get, list, hold, decrement, release,
14
- * restock, setThreshold, checkLowStock
14
+ * restock, adjustOnHand, setThreshold, checkLowStock
15
15
  * - `media` — attach, get, listForProduct, listForVariant, delete,
16
16
  * reorder, setPrimary
17
17
  *
@@ -706,6 +706,56 @@ function _inventoryModule(query, opts) {
706
706
  return await this.get(sku);
707
707
  },
708
708
 
709
+ // Signed-delta adjustment of the storefront aggregate, fired by the
710
+ // inventory-ops back-office when a per-location move changes the
711
+ // total a single-bucket storefront sells against (a receive credits
712
+ // the aggregate, a write-off / shrinkage debits it). `restock` only
713
+ // grows on-hand and `decrement` only consumes a matching hold;
714
+ // neither expresses "the warehouse count just dropped by 5 because a
715
+ // pallet was damaged." A positive delta is an unconditional credit.
716
+ // A negative delta is an atomic conditional UPDATE — the
717
+ // `stock_on_hand - stock_held >= need` guard refuses (zero rows →
718
+ // `{ adjusted: false }`) when the debit would eat into stock that is
719
+ // already held for in-flight orders, so a write-off can never
720
+ // oversell a paid-but-unfulfilled line. Returns `{ adjusted, sku,
721
+ // delta }`; `null` when the SKU has no inventory row at all
722
+ // (un-tracked SKUs are unlimited everywhere in the storefront, so an
723
+ // aggregate adjustment simply doesn't apply). Fires the low-stock
724
+ // observer on success so a debit that crosses the threshold alerts.
725
+ adjustOnHand: async function (sku, delta) {
726
+ _sku(sku);
727
+ if (!Number.isInteger(delta) || delta === 0) {
728
+ throw new TypeError("catalog.inventory.adjustOnHand: delta must be a non-zero integer");
729
+ }
730
+ var ts = _now();
731
+ if (delta > 0) {
732
+ var rUp = await query(
733
+ "UPDATE inventory SET stock_on_hand = stock_on_hand + ?1, updated_at = ?2 WHERE sku = ?3",
734
+ [delta, ts, sku],
735
+ );
736
+ if (rUp.rowCount === 0) return null;
737
+ await _afterMutation(sku);
738
+ return { adjusted: true, sku: sku, delta: delta };
739
+ }
740
+ var need = -delta;
741
+ var rDown = await query(
742
+ "UPDATE inventory SET stock_on_hand = stock_on_hand - ?1, updated_at = ?2 " +
743
+ "WHERE sku = ?3 AND (stock_on_hand - stock_held) >= ?1",
744
+ [need, ts, sku],
745
+ );
746
+ if (rDown.rowCount === 1) {
747
+ await _afterMutation(sku);
748
+ return { adjusted: true, sku: sku, delta: delta };
749
+ }
750
+ // Zero rows: either no inventory row (un-tracked SKU) or the debit
751
+ // would eat into held stock. Distinguish so the caller can let an
752
+ // un-tracked SKU through while refusing a tracked-but-insufficient
753
+ // one.
754
+ var existing = await this.get(sku);
755
+ if (!existing) return null;
756
+ return { adjusted: false, sku: sku, delta: delta };
757
+ },
758
+
709
759
  release: async function (sku, qty) {
710
760
  _sku(sku);
711
761
  _positiveInt(qty, "qty");
package/lib/email.js CHANGED
@@ -143,6 +143,32 @@ var REFUND_TEXT =
143
143
  "We've issued a refund of {{amount_formatted}} against order {{order_id}}.\n" +
144
144
  "The funds will appear on your statement within 5-10 business days.\n";
145
145
 
146
+ // Quote responded — the operator priced a request-for-quote and the
147
+ // customer can now review + accept it. The view link carries the quote's
148
+ // capability token (built by the caller from the plaintext the quotes
149
+ // primitive minted), so the customer opens the quote page without signing
150
+ // in. The total is already formatted by the caller (pricing.format off the
151
+ // quoted total_minor) so the email never re-derives money math.
152
+ var QUOTE_RESPONDED_HTML =
153
+ "<!DOCTYPE html>\n" +
154
+ "<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Your quote is ready</title></head>" +
155
+ "<body style=\"margin:0;background:#ffffff;color:#0d0d0d;font-family:system-ui,sans-serif;\">\n" +
156
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px;\">\n" +
157
+ " <h1 style=\"color:#0d0d0d;margin:0 0 12px;\">Your quote is ready</h1>\n" +
158
+ " <p style=\"margin:0 0 16px;\">Hi {{customer_name}}, we've priced your request. Your quoted total is <strong style=\"color:#fa4f09;\">{{total_formatted}}</strong>.</p>\n" +
159
+ " <p style=\"margin:0 0 16px;\">This quote is valid until {{valid_until}}. Review the full breakdown and accept or decline on the quote page.</p>\n" +
160
+ " <p style=\"margin:24px 0;\"><a href=\"{{quote_url}}\" style=\"background:#fa4f09;color:#ffffff;padding:12px 20px;text-decoration:none;display:inline-block;font-weight:bold;\">Review your quote</a></p>\n" +
161
+ " <p style=\"margin:0;color:#0d0d0d;font-size:13px;\">If the button doesn't work, paste this link into your browser: {{quote_url}}</p>\n" +
162
+ "</div>\n" +
163
+ "</body></html>\n";
164
+
165
+ var QUOTE_RESPONDED_TEXT =
166
+ "Your quote is ready\n\n" +
167
+ "Hi {{customer_name}}, we've priced your request.\n" +
168
+ "Quoted total: {{total_formatted}}\n" +
169
+ "Valid until: {{valid_until}}\n\n" +
170
+ "Review and accept or decline your quote: {{quote_url}}\n";
171
+
146
172
  // Wishlist discount — a watched product just dropped in price.
147
173
  // Brand tokens: #0d0d0d ink, #fa4f09 accent, #ffffff paper.
148
174
 
@@ -488,6 +514,31 @@ function create(opts) {
488
514
  return await _send(input.customer.email, "Refund issued — " + input.order.id, html, text, input.replyTo);
489
515
  },
490
516
 
517
+ // Quote responded — the operator priced an RFQ; tell the customer it's
518
+ // ready to review. The caller passes the customer's deliverable email,
519
+ // an already-formatted `total_formatted` (pricing.format off the quoted
520
+ // total), a human `valid_until` string, and the absolute `quote_url`
521
+ // (built from the quote's capability token). Every value rides through
522
+ // the strict {{var}} renderer (HTML-escaped); no raw HTML flows in.
523
+ quoteResponded: async function (input) {
524
+ if (!input) throw new TypeError("email.quoteResponded: input object required");
525
+ if (typeof input.customer_email !== "string" || !input.customer_email) {
526
+ throw new TypeError("email.quoteResponded: customer_email required");
527
+ }
528
+ if (typeof input.quote_url !== "string" || !input.quote_url) {
529
+ throw new TypeError("email.quoteResponded: quote_url required");
530
+ }
531
+ var vars = {
532
+ customer_name: input.customer_name || "there",
533
+ total_formatted: input.total_formatted == null ? "—" : String(input.total_formatted),
534
+ valid_until: input.valid_until == null ? "—" : String(input.valid_until),
535
+ quote_url: input.quote_url,
536
+ };
537
+ var html = _render(QUOTE_RESPONDED_HTML, vars);
538
+ var text = _render(QUOTE_RESPONDED_TEXT, vars);
539
+ return await _send(input.customer_email, "Your quote is ready", html, text, input.replyTo);
540
+ },
541
+
491
542
  // Wishlist discount — a watched product just dropped in price.
492
543
  // Operator passes already-formatted `old_price` / `new_price`
493
544
  // strings; pricing rules vary (locale, multi-currency, promo
@@ -32,12 +32,20 @@
32
32
  * duplicate code, invalid type, etc).
33
33
  * listLocations / getLocation / updateLocation / deactivateLocation
34
34
  * — operator CRUD over the location set.
35
- * setStock — absolute set (overwrite).
36
- * adjustStock — relative delta with audit row.
37
- * transferStock atomic two-row write moving qty
38
- * from one location to another; if
39
- * the source doesn't have enough, the
40
- * whole transfer refuses (no money
35
+ * setStock — absolute set (overwrite) via an
36
+ * atomic upsert.
37
+ * adjustStock relative delta with audit row. Debits
38
+ * run as a single conditional UPDATE
39
+ * (`quantity >= need`); credits as an
40
+ * upsert. No read-then-write window — a
41
+ * debit that would drive the row below
42
+ * zero matches no rows and refuses.
43
+ * transferStock — moves qty from one location to
44
+ * another; the source debit is the same
45
+ * conditional UPDATE guard, so a transfer
46
+ * racing a checkout debit (or another
47
+ * transfer) for the last unit can't
48
+ * oversell — the loser refuses (no money
41
49
  * created, no row half-committed).
42
50
  * stockForSku — `{ total, by_location: [...] }`.
43
51
  * totalForSku — sum across every location.
@@ -521,20 +529,21 @@ function create(opts) {
521
529
  throw new TypeError("inventory-locations.setStock: location_code " +
522
530
  JSON.stringify(input.location_code) + " not found");
523
531
  }
532
+ // Absolute set is last-writer-wins by contract — the operator is
533
+ // declaring the authoritative count, not applying a relative
534
+ // change. The prior value is read only to record the signed
535
+ // delta on the audit row; the upsert itself is a single atomic
536
+ // statement so a first-touch insert and an overwrite never
537
+ // collide on the composite PK.
524
538
  var prev = await _getStockRow(input.sku, input.location_code);
525
539
  var prevQty = prev ? prev.quantity : 0;
526
540
  var ts = _now();
527
- if (prev) {
528
- await query(
529
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
530
- [input.quantity, ts, input.sku, input.location_code],
531
- );
532
- } else {
533
- await query(
534
- "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
535
- [input.sku, input.location_code, input.quantity, ts],
536
- );
537
- }
541
+ await query(
542
+ "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) " +
543
+ "VALUES (?1, ?2, ?3, ?4) " +
544
+ "ON CONFLICT(sku, location_code) DO UPDATE SET quantity = ?3, updated_at = ?4",
545
+ [input.sku, input.location_code, input.quantity, ts],
546
+ );
538
547
  var delta = input.quantity - prevQty;
539
548
  await _audit(input.sku, input.location_code, delta, _reason(input.reason));
540
549
  return { sku: input.sku, location_code: input.location_code, quantity: input.quantity, delta: delta };
@@ -545,6 +554,18 @@ function create(opts) {
545
554
  // row below zero refuses the whole operation. Inserts a row
546
555
  // at quantity 0 + delta if the (sku, location_code) pair has
547
556
  // never been touched before (and delta is positive).
557
+ //
558
+ // Concurrency: the negative-delta path is a single atomic
559
+ // conditional UPDATE — `SET quantity = quantity + delta WHERE
560
+ // quantity + delta >= 0` — mirroring the catalog hold/decrement
561
+ // guards. D1 evaluates the predicate and applies the write inside
562
+ // one statement, so two racing debits against the last unit can't
563
+ // both succeed: the second racer's predicate sees the first's
564
+ // decrement and matches zero rows. The positive-delta path is an
565
+ // upsert (ON CONFLICT DO UPDATE) so a credit and a fresh-row
566
+ // insert never collide. Both paths re-read the post-write quantity
567
+ // for the audit row + return value rather than trusting a stale
568
+ // pre-read.
548
569
  adjustStock: async function (input) {
549
570
  if (!input || typeof input !== "object") {
550
571
  throw new TypeError("inventory-locations.adjustStock: input object required");
@@ -560,28 +581,42 @@ function create(opts) {
560
581
  throw new TypeError("inventory-locations.adjustStock: location_code " +
561
582
  JSON.stringify(input.location_code) + " not found");
562
583
  }
563
- var prev = await _getStockRow(input.sku, input.location_code);
564
- var prevQty = prev ? prev.quantity : 0;
565
- var next = prevQty + input.delta;
566
- if (next < 0) {
567
- throw new TypeError("inventory-locations.adjustStock: delta " + input.delta +
568
- " would drive stock below zero (current=" + prevQty + ", sku=" + input.sku +
569
- ", location=" + input.location_code + ")");
570
- }
571
584
  var ts = _now();
572
- if (prev) {
585
+ if (input.delta > 0) {
586
+ // Credit: upsert in one atomic statement. A concurrent credit
587
+ // + first-touch insert serialize on the composite PK.
573
588
  await query(
574
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
575
- [next, ts, input.sku, input.location_code],
589
+ "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) " +
590
+ "VALUES (?1, ?2, ?3, ?4) " +
591
+ "ON CONFLICT(sku, location_code) DO UPDATE SET " +
592
+ "quantity = quantity + ?3, updated_at = ?4",
593
+ [input.sku, input.location_code, input.delta, ts],
576
594
  );
577
595
  } else {
578
- await query(
579
- "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
580
- [input.sku, input.location_code, next, ts],
596
+ // Debit: atomic conditional UPDATE. The WHERE predicate is the
597
+ // serialization point it refuses the write (zero rows) when
598
+ // the shelf lacks enough to cover the debit, so the row never
599
+ // goes negative even under concurrent debits. A SKU with no
600
+ // row at this location can't be debited (no negative
601
+ // first-touch); zero rows there is the insufficient case too.
602
+ var need = -input.delta;
603
+ var r = await query(
604
+ "UPDATE inventory_stock SET quantity = quantity + ?1, updated_at = ?2 " +
605
+ "WHERE sku = ?3 AND location_code = ?4 AND quantity >= ?5",
606
+ [input.delta, ts, input.sku, input.location_code, need],
581
607
  );
608
+ if (r.rowCount === 0) {
609
+ var cur = await _getStockRow(input.sku, input.location_code);
610
+ var have = cur ? cur.quantity : 0;
611
+ throw new TypeError("inventory-locations.adjustStock: delta " + input.delta +
612
+ " would drive stock below zero (current=" + have + ", sku=" + input.sku +
613
+ ", location=" + input.location_code + ")");
614
+ }
582
615
  }
616
+ var after = await _getStockRow(input.sku, input.location_code);
617
+ var nextQty = after ? after.quantity : 0;
583
618
  await _audit(input.sku, input.location_code, input.delta, _reason(input.reason));
584
- return { sku: input.sku, location_code: input.location_code, quantity: next, delta: input.delta };
619
+ return { sku: input.sku, location_code: input.location_code, quantity: nextQty, delta: input.delta };
585
620
  },
586
621
 
587
622
  // Atomic two-row move. Reads the source quantity; if
@@ -612,44 +647,44 @@ function create(opts) {
612
647
  JSON.stringify(input.to_location) + " not found");
613
648
  }
614
649
 
615
- var fromRow = await _getStockRow(input.sku, input.from_location);
616
- var fromQty = fromRow ? fromRow.quantity : 0;
617
- if (fromQty < input.quantity) {
618
- throw new TypeError("inventory-locations.transferStock: insufficient stock at " +
619
- input.from_location + " (have " + fromQty + ", need " + input.quantity + ")");
620
- }
621
- var toRow = await _getStockRow(input.sku, input.to_location);
622
- var toQty = toRow ? toRow.quantity : 0;
623
-
624
650
  var ts = _now();
625
- // Decrement source first. If the destination upsert below
626
- // fails for any reason, the rollback path re-increments the
627
- // source so the invariant (total quantity unchanged) holds.
628
- await query(
629
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
630
- [fromQty - input.quantity, ts, input.sku, input.from_location],
651
+ // Debit the source with a single atomic conditional UPDATE. The
652
+ // `quantity >= need` predicate is the serialization point: two
653
+ // racing transfers (or a transfer racing a checkout debit)
654
+ // against the last unit can't both succeed — the second sees the
655
+ // first's decrement and matches zero rows, which we surface as
656
+ // the insufficient-stock refusal. No read-then-write window.
657
+ var srcDebit = await query(
658
+ "UPDATE inventory_stock SET quantity = quantity - ?1, updated_at = ?2 " +
659
+ "WHERE sku = ?3 AND location_code = ?4 AND quantity >= ?1",
660
+ [input.quantity, ts, input.sku, input.from_location],
631
661
  );
662
+ if (srcDebit.rowCount === 0) {
663
+ var fromRow = await _getStockRow(input.sku, input.from_location);
664
+ var fromHave = fromRow ? fromRow.quantity : 0;
665
+ throw new TypeError("inventory-locations.transferStock: insufficient stock at " +
666
+ input.from_location + " (have " + fromHave + ", need " + input.quantity + ")");
667
+ }
632
668
  try {
633
- if (toRow) {
634
- await query(
635
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
636
- [toQty + input.quantity, ts, input.sku, input.to_location],
637
- );
638
- } else {
639
- await query(
640
- "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
641
- [input.sku, input.to_location, input.quantity, ts],
642
- );
643
- }
669
+ // Credit the destination via an atomic upsert. A concurrent
670
+ // credit + first-touch insert serialize on the composite PK.
671
+ await query(
672
+ "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) " +
673
+ "VALUES (?1, ?2, ?3, ?4) " +
674
+ "ON CONFLICT(sku, location_code) DO UPDATE SET " +
675
+ "quantity = quantity + ?3, updated_at = ?4",
676
+ [input.sku, input.to_location, input.quantity, ts],
677
+ );
644
678
  } catch (e) {
645
- // Best-effort compensating restore on the source so the
646
- // pair stays balanced. If THIS write also fails the
647
- // operator will see two adjacent audit rows with no
648
- // counterpart the caller can replay or reconcile.
679
+ // Best-effort compensating restore on the source so the pair
680
+ // stays balanced. The restore is itself an atomic increment
681
+ // (no read-then-write), so it can't clobber a concurrent
682
+ // mutation that landed between the debit and this catch.
649
683
  try {
650
684
  await query(
651
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
652
- [fromQty, _now(), input.sku, input.from_location],
685
+ "UPDATE inventory_stock SET quantity = quantity + ?1, updated_at = ?2 " +
686
+ "WHERE sku = ?3 AND location_code = ?4",
687
+ [input.quantity, _now(), input.sku, input.from_location],
653
688
  );
654
689
  } catch (_e2) { /* drop-silent — the original error is what the caller needs to fix */ }
655
690
  throw e;
@@ -659,13 +694,15 @@ function create(opts) {
659
694
  await _audit(input.sku, input.from_location, -input.quantity, reason);
660
695
  await _audit(input.sku, input.to_location, input.quantity, reason);
661
696
 
697
+ var fromAfterRow = await _getStockRow(input.sku, input.from_location);
698
+ var toAfterRow = await _getStockRow(input.sku, input.to_location);
662
699
  return {
663
700
  sku: input.sku,
664
701
  from_location: input.from_location,
665
702
  to_location: input.to_location,
666
703
  quantity: input.quantity,
667
- from_after: fromQty - input.quantity,
668
- to_after: toQty + input.quantity,
704
+ from_after: fromAfterRow ? fromAfterRow.quantity : 0,
705
+ to_after: toAfterRow ? toAfterRow.quantity : 0,
669
706
  };
670
707
  },
671
708