@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.
- package/CHANGELOG.md +4 -0
- package/README.md +3 -2
- package/lib/admin.js +990 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/catalog.js +51 -1
- package/lib/email.js +51 -0
- package/lib/inventory-locations.js +103 -66
- package/lib/quotes.js +306 -82
- package/lib/storefront.js +416 -0
- package/package.json +1 -1
package/lib/asset-manifest.json
CHANGED
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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 (
|
|
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
|
-
"
|
|
575
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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:
|
|
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
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
)
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
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
|
|
652
|
-
|
|
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:
|
|
668
|
-
to_after:
|
|
704
|
+
from_after: fromAfterRow ? fromAfterRow.quantity : 0,
|
|
705
|
+
to_after: toAfterRow ? toAfterRow.quantity : 0,
|
|
669
706
|
};
|
|
670
707
|
},
|
|
671
708
|
|