@blamejs/blamejs-shop 0.4.23 → 0.4.25
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 +6 -1
- package/SECURITY.md +13 -0
- package/lib/admin.js +1328 -49
- package/lib/asset-manifest.json +5 -5
- package/lib/compliance-export.js +61 -7
- package/lib/customers.js +53 -0
- package/lib/cycle-counting.js +24 -4
- package/lib/gift-card-ledger.js +81 -10
- package/lib/giftcards.js +88 -0
- package/lib/inventory-allocations.js +33 -14
- package/lib/inventory-receive.js +116 -20
- package/lib/inventory-writeoffs.js +53 -64
- package/lib/loyalty-earn-rules.js +117 -0
- package/lib/loyalty.js +79 -0
- package/lib/newsletter.js +39 -2
- package/lib/operator-audit-log.js +20 -0
- package/lib/operator-inbox.js +202 -9
- package/lib/order.js +227 -27
- package/lib/payment.js +91 -18
- package/lib/quotes.js +107 -15
- package/lib/referrals.js +71 -0
- package/lib/security-middleware.js +33 -1
- package/lib/stock-transfers.js +185 -53
- package/lib/storefront.js +979 -126
- package/lib/translations.js +1 -0
- package/lib/webhook-receiver.js +15 -19
- package/lib/wishlist-alerts.js +37 -0
- package/package.json +1 -1
package/lib/operator-inbox.js
CHANGED
|
@@ -127,6 +127,25 @@
|
|
|
127
127
|
* - unreadCount({ operator_id, severity_min? })
|
|
128
128
|
* Cheap count for the navbar badge. Excludes archived.
|
|
129
129
|
*
|
|
130
|
+
* - inboxForRole({ role, severity_min?, unread_only?,
|
|
131
|
+
* include_archived?, limit?, cursor? })
|
|
132
|
+
* Read the messages broadcast to a single role, newest-first.
|
|
133
|
+
* Returns ONLY `role = ?` rows (never operator-id-addressed
|
|
134
|
+
* ones), so it composes with `inboxForOperator` rather than
|
|
135
|
+
* duplicating it — the read side for a console that addresses
|
|
136
|
+
* notifications to a role and has no per-operator session to
|
|
137
|
+
* fold role membership through.
|
|
138
|
+
*
|
|
139
|
+
* - unreadCountForRole({ role, severity_min? })
|
|
140
|
+
* Role-scoped navbar-badge count. Excludes archived + read.
|
|
141
|
+
*
|
|
142
|
+
* - markReadForRole({ id, role }) / archiveForRole({ id, role })
|
|
143
|
+
* Clear / retire a role-broadcast row by its role rather than
|
|
144
|
+
* by an owning operator. Each asserts the row carries the
|
|
145
|
+
* supplied role before mutating (a caller can't clear an
|
|
146
|
+
* operator-id-addressed row, or another role's row, by
|
|
147
|
+
* guessing its id). Idempotent.
|
|
148
|
+
*
|
|
130
149
|
* - cleanupOlderThan({ now?, age_ms })
|
|
131
150
|
* Delete rows whose `created_at < (now - age_ms)`. Used to
|
|
132
151
|
* keep the table bounded — the inbox is a feed, not an
|
|
@@ -771,6 +790,176 @@ function create(opts) {
|
|
|
771
790
|
return Number(row.c || row.COUNT || 0);
|
|
772
791
|
}
|
|
773
792
|
|
|
793
|
+
// ---- inboxForRole -----------------------------------------------------
|
|
794
|
+
//
|
|
795
|
+
// Read every message broadcast to a single role, newest-first. This is
|
|
796
|
+
// the read side for a console that addresses notifications to a role
|
|
797
|
+
// rather than to one owning operator — e.g. a single-credential admin
|
|
798
|
+
// where the "fulfillment" team is the audience but there's no per-
|
|
799
|
+
// operator session to fold role membership through. It returns ONLY
|
|
800
|
+
// `role = ?` rows (never operator-id-addressed rows), so it composes
|
|
801
|
+
// with `inboxForOperator` rather than duplicating it. Same severity_min
|
|
802
|
+
// / unread_only / include_archived / HMAC-cursor surface.
|
|
803
|
+
async function inboxForRole(input) {
|
|
804
|
+
if (!input || typeof input !== "object") {
|
|
805
|
+
throw new TypeError("operatorInbox.inboxForRole: input object required");
|
|
806
|
+
}
|
|
807
|
+
var role = _role(input.role, "role");
|
|
808
|
+
var severityMin = _severityMin(input.severity_min);
|
|
809
|
+
var unreadOnly = _bool(input.unread_only, "unread_only", false);
|
|
810
|
+
var includeArchived = _bool(input.include_archived, "include_archived", false);
|
|
811
|
+
var limit = _limit(input.limit);
|
|
812
|
+
var cursorState = _decodeCursor(input.cursor, "inboxForRole");
|
|
813
|
+
|
|
814
|
+
var params = [];
|
|
815
|
+
var idx = 1;
|
|
816
|
+
var pushP = function (v) { params.push(v); var k = idx; idx += 1; return "?" + k; };
|
|
817
|
+
|
|
818
|
+
var where = "role = " + pushP(role);
|
|
819
|
+
if (!includeArchived) where += " AND archived_at IS NULL";
|
|
820
|
+
if (unreadOnly) where += " AND read_at IS NULL";
|
|
821
|
+
|
|
822
|
+
if (severityMin) {
|
|
823
|
+
var minRank = SEVERITY_RANK[severityMin];
|
|
824
|
+
var allowed = [];
|
|
825
|
+
for (var si = 0; si < SEVERITIES.length; si += 1) {
|
|
826
|
+
if (SEVERITY_RANK[SEVERITIES[si]] >= minRank) {
|
|
827
|
+
allowed.push(pushP(SEVERITIES[si]));
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
where += " AND severity IN (" + allowed.join(", ") + ")";
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (cursorState) {
|
|
834
|
+
var caP = pushP(cursorState.created_at);
|
|
835
|
+
var idP = pushP(cursorState.id);
|
|
836
|
+
where += " AND (created_at < " + caP +
|
|
837
|
+
" OR (created_at = " + caP + " AND id < " + idP + "))";
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
var limitPlace = pushP(limit);
|
|
841
|
+
var sql = "SELECT * FROM operator_inbox_messages WHERE " + where +
|
|
842
|
+
" ORDER BY created_at DESC, id DESC LIMIT " + limitPlace;
|
|
843
|
+
var r = await query(sql, params);
|
|
844
|
+
var rows = [];
|
|
845
|
+
for (var ki = 0; ki < r.rows.length; ki += 1) rows.push(_decodeRow(r.rows[ki]));
|
|
846
|
+
var nextCursor = rows.length === limit
|
|
847
|
+
? b.pagination.encodeCursor({
|
|
848
|
+
orderKey: CURSOR_ORDER_KEY,
|
|
849
|
+
vals: [rows[rows.length - 1].created_at, rows[rows.length - 1].id],
|
|
850
|
+
forward: true,
|
|
851
|
+
}, cursorSecret)
|
|
852
|
+
: null;
|
|
853
|
+
return { rows: rows, next_cursor: nextCursor };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// ---- unreadCountForRole -----------------------------------------------
|
|
857
|
+
//
|
|
858
|
+
// Cheap navbar-badge count of unread, un-archived messages broadcast to
|
|
859
|
+
// one role. The role-scoped sibling of `unreadCount` — used by a
|
|
860
|
+
// role-addressed console where the badge audience is a role, not a
|
|
861
|
+
// single operator.
|
|
862
|
+
async function unreadCountForRole(input) {
|
|
863
|
+
if (!input || typeof input !== "object") {
|
|
864
|
+
throw new TypeError("operatorInbox.unreadCountForRole: input object required");
|
|
865
|
+
}
|
|
866
|
+
var role = _role(input.role, "role");
|
|
867
|
+
var severityMin = _severityMin(input.severity_min);
|
|
868
|
+
|
|
869
|
+
var params = [];
|
|
870
|
+
var idx = 1;
|
|
871
|
+
var pushP = function (v) { params.push(v); var k = idx; idx += 1; return "?" + k; };
|
|
872
|
+
|
|
873
|
+
var where = "role = " + pushP(role) +
|
|
874
|
+
" AND read_at IS NULL AND archived_at IS NULL";
|
|
875
|
+
|
|
876
|
+
if (severityMin) {
|
|
877
|
+
var minRank = SEVERITY_RANK[severityMin];
|
|
878
|
+
var allowed = [];
|
|
879
|
+
for (var si = 0; si < SEVERITIES.length; si += 1) {
|
|
880
|
+
if (SEVERITY_RANK[SEVERITIES[si]] >= minRank) {
|
|
881
|
+
allowed.push(pushP(SEVERITIES[si]));
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
where += " AND severity IN (" + allowed.join(", ") + ")";
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
var sql = "SELECT COUNT(*) AS c FROM operator_inbox_messages WHERE " + where;
|
|
888
|
+
var r = await query(sql, params);
|
|
889
|
+
var row = r.rows[0] || {};
|
|
890
|
+
return Number(row.c || row.COUNT || 0);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// ---- markReadForRole / archiveForRole ---------------------------------
|
|
894
|
+
//
|
|
895
|
+
// The role-scoped write side. A console addressing notifications to a
|
|
896
|
+
// role (rather than to one owning operator) needs to clear a role-
|
|
897
|
+
// broadcast row WITHOUT a per-operator session to gate against — the
|
|
898
|
+
// role itself is the audience. Both assert the row carries the supplied
|
|
899
|
+
// role before mutating, so a caller can't clear an operator-id-addressed
|
|
900
|
+
// row (or a different role's row) by guessing its id. Idempotent.
|
|
901
|
+
async function _roleAddressableRow(messageId, role) {
|
|
902
|
+
var r = await query("SELECT * FROM operator_inbox_messages WHERE id = ?1", [messageId]);
|
|
903
|
+
if (!r.rows.length) return { row: null, reason: "not_found" };
|
|
904
|
+
var row = r.rows[0];
|
|
905
|
+
if (row.role != null && row.role === role) return { row: row, reason: null };
|
|
906
|
+
return { row: row, reason: "not_addressable" };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async function markReadForRole(input) {
|
|
910
|
+
if (!input || typeof input !== "object") {
|
|
911
|
+
throw new TypeError("operatorInbox.markReadForRole: input object required");
|
|
912
|
+
}
|
|
913
|
+
var id = _messageId(input.id);
|
|
914
|
+
var role = _role(input.role, "role");
|
|
915
|
+
var gated = await _roleAddressableRow(id, role);
|
|
916
|
+
if (gated.reason === "not_found") {
|
|
917
|
+
var miss = new Error("operatorInbox.markReadForRole: message not found");
|
|
918
|
+
miss.code = "INBOX_MESSAGE_NOT_FOUND";
|
|
919
|
+
throw miss;
|
|
920
|
+
}
|
|
921
|
+
if (gated.reason === "not_addressable") {
|
|
922
|
+
var nope = new Error("operatorInbox.markReadForRole: message not addressed to this role");
|
|
923
|
+
nope.code = "INBOX_MESSAGE_NOT_ADDRESSABLE";
|
|
924
|
+
throw nope;
|
|
925
|
+
}
|
|
926
|
+
if (gated.row.read_at != null) return _decodeRow(gated.row); // idempotent
|
|
927
|
+
var ts = _now();
|
|
928
|
+
await query(
|
|
929
|
+
"UPDATE operator_inbox_messages SET read_at = ?1 WHERE id = ?2 AND read_at IS NULL",
|
|
930
|
+
[ts, id],
|
|
931
|
+
);
|
|
932
|
+
var fresh = await query("SELECT * FROM operator_inbox_messages WHERE id = ?1", [id]);
|
|
933
|
+
return _decodeRow(fresh.rows[0]);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async function archiveForRole(input) {
|
|
937
|
+
if (!input || typeof input !== "object") {
|
|
938
|
+
throw new TypeError("operatorInbox.archiveForRole: input object required");
|
|
939
|
+
}
|
|
940
|
+
var id = _messageId(input.id);
|
|
941
|
+
var role = _role(input.role, "role");
|
|
942
|
+
var gated = await _roleAddressableRow(id, role);
|
|
943
|
+
if (gated.reason === "not_found") {
|
|
944
|
+
var miss = new Error("operatorInbox.archiveForRole: message not found");
|
|
945
|
+
miss.code = "INBOX_MESSAGE_NOT_FOUND";
|
|
946
|
+
throw miss;
|
|
947
|
+
}
|
|
948
|
+
if (gated.reason === "not_addressable") {
|
|
949
|
+
var nope = new Error("operatorInbox.archiveForRole: message not addressed to this role");
|
|
950
|
+
nope.code = "INBOX_MESSAGE_NOT_ADDRESSABLE";
|
|
951
|
+
throw nope;
|
|
952
|
+
}
|
|
953
|
+
if (gated.row.archived_at != null) return _decodeRow(gated.row); // idempotent
|
|
954
|
+
var ts = _now();
|
|
955
|
+
await query(
|
|
956
|
+
"UPDATE operator_inbox_messages SET archived_at = ?1 WHERE id = ?2 AND archived_at IS NULL",
|
|
957
|
+
[ts, id],
|
|
958
|
+
);
|
|
959
|
+
var fresh = await query("SELECT * FROM operator_inbox_messages WHERE id = ?1", [id]);
|
|
960
|
+
return _decodeRow(fresh.rows[0]);
|
|
961
|
+
}
|
|
962
|
+
|
|
774
963
|
// ---- cleanupOlderThan -------------------------------------------------
|
|
775
964
|
|
|
776
965
|
async function cleanupOlderThan(input) {
|
|
@@ -860,15 +1049,19 @@ function create(opts) {
|
|
|
860
1049
|
MAX_LIMIT: MAX_LIMIT,
|
|
861
1050
|
MAX_BULK_IDS: MAX_BULK_IDS,
|
|
862
1051
|
|
|
863
|
-
enqueueMessage:
|
|
864
|
-
inboxForOperator:
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1052
|
+
enqueueMessage: enqueueMessage,
|
|
1053
|
+
inboxForOperator: inboxForOperator,
|
|
1054
|
+
inboxForRole: inboxForRole,
|
|
1055
|
+
markRead: markRead,
|
|
1056
|
+
markUnread: markUnread,
|
|
1057
|
+
markReadForRole: markReadForRole,
|
|
1058
|
+
archiveMessage: archiveMessage,
|
|
1059
|
+
archiveForRole: archiveForRole,
|
|
1060
|
+
bulkArchive: bulkArchive,
|
|
1061
|
+
unreadCount: unreadCount,
|
|
1062
|
+
unreadCountForRole: unreadCountForRole,
|
|
1063
|
+
cleanupOlderThan: cleanupOlderThan,
|
|
1064
|
+
metricsForKind: metricsForKind,
|
|
872
1065
|
};
|
|
873
1066
|
}
|
|
874
1067
|
|
package/lib/order.js
CHANGED
|
@@ -209,6 +209,22 @@ function create(opts) {
|
|
|
209
209
|
// fire-and-forget on the same detached-promise discipline as the
|
|
210
210
|
// loyalty fan-out: the transition has already persisted.
|
|
211
211
|
var referrals = opts.referrals || null;
|
|
212
|
+
// Optional new-order observer — a `function (order)` the FSM calls,
|
|
213
|
+
// fire-and-forget, the moment an order reaches `paid` (pending →
|
|
214
|
+
// paid). It's how the operator console learns a sale settled without
|
|
215
|
+
// polling: the application points the slot at an adapter that drops
|
|
216
|
+
// an operator-inbox entry (and a navbar badge ticks up). Late-bound
|
|
217
|
+
// via `setNewOrderObserver` because the operator-ping adapter is
|
|
218
|
+
// constructed AFTER the order primitive (it composes the same order
|
|
219
|
+
// handle to read the customer/total), so the slot is assigned once
|
|
220
|
+
// the wiring is complete. Opt-in like the other fan-outs; absent it
|
|
221
|
+
// the paid edge is a clean no-op. The call is detached on the same
|
|
222
|
+
// discipline as the loyalty / referral fan-outs — the transition has
|
|
223
|
+
// already persisted, so an observer read/write must never add latency
|
|
224
|
+
// to (or fail) the order transition.
|
|
225
|
+
var newOrderObserver = (typeof opts.newOrderObserver === "function")
|
|
226
|
+
? opts.newOrderObserver
|
|
227
|
+
: null;
|
|
212
228
|
// Optional inventory handle — when present, the order FSM converts the
|
|
213
229
|
// confirm-time stock holds into real shelf movements as the order
|
|
214
230
|
// changes state. On `mark_paid` (pending → paid) each shippable line's
|
|
@@ -258,6 +274,18 @@ function create(opts) {
|
|
|
258
274
|
// balance is authoritative; the ledger is the audit trail surfaced in the
|
|
259
275
|
// admin console).
|
|
260
276
|
var giftCardLedger = opts.giftCardLedger || null;
|
|
277
|
+
// Optional loyalty handle — when present, the FSM restores loyalty points
|
|
278
|
+
// a customer SPENT as a checkout tender when the order is refunded /
|
|
279
|
+
// cancelled, symmetric with the gift-card-spend restore above. The points
|
|
280
|
+
// were debited at checkout via loyalty.redeem against this order; a refund
|
|
281
|
+
// returns the buyer's money, so the points they tendered have to come back
|
|
282
|
+
// to their balance or the refund silently burns them (inconsistent with the
|
|
283
|
+
// gift-card spend, which IS restored). Distinct from `loyaltyEarnRules`
|
|
284
|
+
// (which reverses points EARNED on the purchase) — this restores points
|
|
285
|
+
// SPENT on it. Restore is proportional to the refunded amount + idempotent
|
|
286
|
+
// (restoreRedemption tracks cumulative restored per redeem row). Opt-in;
|
|
287
|
+
// absent it, a deploy without loyalty-as-tender runs unchanged.
|
|
288
|
+
var loyalty = opts.loyalty || null;
|
|
261
289
|
// Pagination cursors for listForCustomer are HMAC-tagged via
|
|
262
290
|
// b.pagination so an operator can't hand-craft one to skip past a
|
|
263
291
|
// hidden order or replay across deployments. The secret defaults
|
|
@@ -328,13 +356,29 @@ function create(opts) {
|
|
|
328
356
|
// so a reversal failure is caught, NOT re-thrown, and surfaced loudly (an
|
|
329
357
|
// `order.giftcard.reversal.error` audit event plus, when the error-log
|
|
330
358
|
// handle is wired, a durable /admin/errors row) for manual reconciliation.
|
|
331
|
-
async function _settleGiftCards(orderId) {
|
|
359
|
+
async function _settleGiftCards(orderId, refundedMinor, orderTotalMinor) {
|
|
332
360
|
if (!giftCards) return;
|
|
333
361
|
try {
|
|
334
|
-
var reversed
|
|
362
|
+
var reversed;
|
|
363
|
+
if (typeof giftCards.reverseRedemptionProRata === "function"
|
|
364
|
+
&& Number.isInteger(orderTotalMinor) && orderTotalMinor > 0) {
|
|
365
|
+
// Proportional reversal — re-mint only the share the refund covers.
|
|
366
|
+
// reversed_minor tracks the cumulative already credited per
|
|
367
|
+
// redemption, so a partial-then-final refund sequence converges
|
|
368
|
+
// exactly on the original spend and never over-credits.
|
|
369
|
+
reversed = await giftCards.reverseRedemptionProRata(orderId, {
|
|
370
|
+
refunded_minor: refundedMinor,
|
|
371
|
+
order_total_minor: orderTotalMinor,
|
|
372
|
+
});
|
|
373
|
+
} else {
|
|
374
|
+
// Fallback — a giftCards handle without the pro-rata method, or a
|
|
375
|
+
// zero-total order: all-or-nothing reversal of the full spend.
|
|
376
|
+
reversed = await giftCards.reverseRedemption(orderId);
|
|
377
|
+
}
|
|
335
378
|
if (giftCardLedger && typeof giftCardLedger.credit === "function") {
|
|
336
379
|
for (var i = 0; i < reversed.length; i += 1) {
|
|
337
380
|
var rev = reversed[i];
|
|
381
|
+
if (!rev || !(Number(rev.amount_minor) > 0)) continue;
|
|
338
382
|
try {
|
|
339
383
|
// allow:money-binding-currency-without-catalog-check — this is a REVERSAL credit of an existing redemption: it replays the exact amount already debited from an already-issued card (whose currency was ISO-4217-validated at giftcards.issue time). No currency is supplied or chosen here — the ledger credit carries no currency field; it inherits the card's. There is no new currency surface to catalog-check.
|
|
340
384
|
await giftCardLedger.credit({
|
|
@@ -584,32 +628,58 @@ function create(opts) {
|
|
|
584
628
|
// transition has already persisted and the webhook must return 2xx, so
|
|
585
629
|
// a reversal failure surfaces loudly for reconciliation rather than
|
|
586
630
|
// 500ing the request.
|
|
631
|
+
// Death-edge value reversal. A cancel (pending-abandon or post-paid)
|
|
632
|
+
// or a balance-clearing refund returns EVERY credit the order consumed
|
|
633
|
+
// or earned, in full: refundedMinor = the order's grand total. The
|
|
634
|
+
// pro-rata reversers track the cumulative already reversed (per
|
|
635
|
+
// redemption / award / redeem row), so when partial refunds preceded
|
|
636
|
+
// this edge they credit only the remaining delta — a partial-then-final
|
|
637
|
+
// sequence converges exactly on the order total, never over-crediting.
|
|
587
638
|
if (result.to === "cancelled" || result.to === "refunded") {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
639
|
+
var _refTotal = Number(refreshed && refreshed.grand_total_minor) || 0;
|
|
640
|
+
// Gift-card spend — SYNCHRONOUS (the customer's own money), idempotent,
|
|
641
|
+
// drop-silent-with-capture (see _settleGiftCards).
|
|
642
|
+
await _settleGiftCards(orderId, _refTotal, _refTotal);
|
|
643
|
+
if (refreshed && refreshed.customer_id && _refTotal > 0) {
|
|
644
|
+
var _revCustomer = refreshed.customer_id;
|
|
645
|
+
var _revOrderId = refreshed.id;
|
|
646
|
+
// Loyalty points SPENT as a checkout tender — restored to the
|
|
647
|
+
// balance. Detached + drop-silent (loyalty ledger holds its own
|
|
648
|
+
// audit trail); restoreRedemption claims each redeem row's slice so
|
|
649
|
+
// a re-delivered webhook can't double-restore.
|
|
650
|
+
if (loyalty && typeof loyalty.restoreRedemption === "function") {
|
|
651
|
+
Promise.resolve().then(function () {
|
|
652
|
+
return loyalty.restoreRedemption(_revOrderId, {
|
|
653
|
+
refunded_minor: _refTotal,
|
|
654
|
+
order_total_minor: _refTotal,
|
|
655
|
+
});
|
|
656
|
+
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
657
|
+
}
|
|
658
|
+
// Loyalty points EARNED on the purchase — clawed back proportionally
|
|
659
|
+
// (full, on a death edge). Detached + drop-silent; clawed_points
|
|
660
|
+
// makes the claw idempotent and convergent across partial slices.
|
|
661
|
+
if (loyaltyEarnRules && typeof loyaltyEarnRules.reverseForEventProRata === "function") {
|
|
662
|
+
Promise.resolve().then(function () {
|
|
663
|
+
return loyaltyEarnRules.reverseForEventProRata({
|
|
664
|
+
customer_id: _revCustomer,
|
|
665
|
+
trigger_event_ref: "order:" + _revOrderId,
|
|
666
|
+
refunded_minor: _refTotal,
|
|
667
|
+
order_total_minor: _refTotal,
|
|
668
|
+
});
|
|
669
|
+
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Referral funnel — the qualifying first order being voided rolls back
|
|
673
|
+
// the both-rewarded completion and decrements the referrer's count.
|
|
674
|
+
// Terminal edge only (a partial refund doesn't void the order).
|
|
675
|
+
// Detached + drop-silent; reverseForOrder claims the invitation with an
|
|
676
|
+
// unreversed predicate so a re-delivered webhook reverses exactly once.
|
|
677
|
+
if (referrals && typeof referrals.reverseForOrder === "function" && refreshed) {
|
|
678
|
+
var _refrOrderId = refreshed.id;
|
|
679
|
+
Promise.resolve().then(function () {
|
|
680
|
+
return referrals.reverseForOrder(_refrOrderId);
|
|
681
|
+
}).catch(function () { /* drop-silent — referral funnel holds its own audit trail */ });
|
|
682
|
+
}
|
|
613
683
|
}
|
|
614
684
|
// Fan-out to merchant webhook subscribers is fire-and-forget. The
|
|
615
685
|
// transition has already persisted; the request must not wait on
|
|
@@ -688,9 +758,139 @@ function create(opts) {
|
|
|
688
758
|
return referrals.trackPurchase({ customer_id: _refCustomer, order_id: _refOrderId });
|
|
689
759
|
}).catch(function () { /* drop-silent — referral funnel holds its own audit trail */ });
|
|
690
760
|
}
|
|
761
|
+
// New-order operator ping — fire-and-forget, same discipline as the
|
|
762
|
+
// loyalty / referral fan-outs above. Only on the paid transition
|
|
763
|
+
// (pending → paid is the edge that owns inventory debit, so this
|
|
764
|
+
// fires exactly once per real sale — a re-delivered mark_paid is
|
|
765
|
+
// collapsed to a no-op transition upstream). Guest orders fire too:
|
|
766
|
+
// the operator wants to know a sale settled regardless of whether
|
|
767
|
+
// the buyer has an account. The observer is detached so a slow /
|
|
768
|
+
// failing inbox write never adds latency to (or fails) the order
|
|
769
|
+
// transition; the inbox enqueue holds its own durable audit trail.
|
|
770
|
+
if (newOrderObserver && result.to === "paid" && refreshed) {
|
|
771
|
+
var _pingOrder = refreshed;
|
|
772
|
+
Promise.resolve().then(function () {
|
|
773
|
+
return newOrderObserver(_pingOrder);
|
|
774
|
+
}).catch(function () { /* drop-silent — the observer owns its own failure trail */ });
|
|
775
|
+
}
|
|
691
776
|
return refreshed;
|
|
692
777
|
},
|
|
693
778
|
|
|
779
|
+
// Late-bind the new-order observer (see the `newOrderObserver` slot
|
|
780
|
+
// in the factory). The operator-ping adapter is constructed AFTER
|
|
781
|
+
// this primitive (it composes the same order handle), so the wiring
|
|
782
|
+
// assigns the slot once both halves exist. A non-function is refused
|
|
783
|
+
// at the boundary so a typo surfaces at boot, not as a silent
|
|
784
|
+
// never-firing ping. Pass `null` to detach.
|
|
785
|
+
setNewOrderObserver: function (fn) {
|
|
786
|
+
if (fn != null && typeof fn !== "function") {
|
|
787
|
+
throw new TypeError("order.setNewOrderObserver: observer must be a function or null");
|
|
788
|
+
}
|
|
789
|
+
newOrderObserver = fn || null;
|
|
790
|
+
},
|
|
791
|
+
|
|
792
|
+
// Sum of every refund recorded against this order, in minor units.
|
|
793
|
+
// Both the FSM `refund` transition (full / balance-clearing refund →
|
|
794
|
+
// `refunded`) and a partial-refund record (recordPartialRefund — a
|
|
795
|
+
// same-state self-loop) write an `order_transitions` row whose
|
|
796
|
+
// metadata carries `amount_minor`, so the running refunded total is
|
|
797
|
+
// the SUM of `amount_minor` across every `on_event = 'refund'` row.
|
|
798
|
+
// Rows with no amount in metadata (a legacy full refund that recorded
|
|
799
|
+
// none) contribute 0 here — the partial-refund console always records
|
|
800
|
+
// the amount, so a mixed history still totals correctly going forward.
|
|
801
|
+
// Integer minor-unit arithmetic throughout (never a float): the values
|
|
802
|
+
// are exact minor-unit integers from the order totals + provider refund
|
|
803
|
+
// amounts, so the sum is exact.
|
|
804
|
+
refundedTotalMinor: async function (orderId) {
|
|
805
|
+
_uuid(orderId, "order id");
|
|
806
|
+
var rows = (await query(
|
|
807
|
+
"SELECT metadata_json FROM order_transitions " +
|
|
808
|
+
"WHERE order_id = ?1 AND on_event = 'refund'",
|
|
809
|
+
[orderId],
|
|
810
|
+
)).rows;
|
|
811
|
+
var total = 0;
|
|
812
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
813
|
+
var meta;
|
|
814
|
+
try { meta = JSON.parse(rows[i].metadata_json || "{}"); }
|
|
815
|
+
catch (_e) { meta = {}; }
|
|
816
|
+
var amt = meta && meta.amount_minor;
|
|
817
|
+
if (Number.isInteger(amt) && amt > 0) total += amt;
|
|
818
|
+
}
|
|
819
|
+
return total;
|
|
820
|
+
},
|
|
821
|
+
|
|
822
|
+
// Record a PARTIAL refund that does NOT clear the order's balance — the
|
|
823
|
+
// money has already moved at the payment provider; this appends the
|
|
824
|
+
// audit row WITHOUT changing the order's FSM state (a same-state
|
|
825
|
+
// self-loop `refund` transition). A partial refund leaves the order in
|
|
826
|
+
// its current lifecycle state (a paid / fulfilling / shipped order keeps
|
|
827
|
+
// moving) — only a balance-clearing refund drives the FSM `refund` edge
|
|
828
|
+
// to the terminal `refunded` state via `transition`. The caller (the
|
|
829
|
+
// admin refund console) is responsible for choosing partial-vs-final:
|
|
830
|
+
// it issues the provider refund first, then calls this for a partial or
|
|
831
|
+
// `transition('refund')` for the final slice. `amount_minor` is a
|
|
832
|
+
// positive integer in minor units (validated here as a config/entry
|
|
833
|
+
// contract — a bad amount throws so the caller surfaces a 4xx, never a
|
|
834
|
+
// silently-wrong ledger row). Returns the refreshed order.
|
|
835
|
+
recordPartialRefund: async function (orderId, opts2) {
|
|
836
|
+
_uuid(orderId, "order id");
|
|
837
|
+
opts2 = opts2 || {};
|
|
838
|
+
_positiveInt(opts2.amount_minor, "amount_minor");
|
|
839
|
+
var current = (await query("SELECT * FROM orders WHERE id = ?1", [orderId])).rows[0];
|
|
840
|
+
if (!current) throw new TypeError("order.recordPartialRefund: order " + orderId + " not found");
|
|
841
|
+
var meta = Object.assign({}, opts2.metadata || {});
|
|
842
|
+
meta.amount_minor = opts2.amount_minor;
|
|
843
|
+
meta.partial = true;
|
|
844
|
+
var ts = _now();
|
|
845
|
+
await query(
|
|
846
|
+
"INSERT INTO order_transitions (id, order_id, from_state, to_state, on_event, reason, metadata_json, occurred_at) " +
|
|
847
|
+
"VALUES (?1, ?2, ?3, ?3, 'refund', ?4, ?5, ?6)",
|
|
848
|
+
[
|
|
849
|
+
b.uuid.v7(), orderId, current.status,
|
|
850
|
+
opts2.reason || null,
|
|
851
|
+
JSON.stringify(meta),
|
|
852
|
+
ts,
|
|
853
|
+
],
|
|
854
|
+
);
|
|
855
|
+
// updated_at is bumped so the order surfaces at the top of the
|
|
856
|
+
// operator recent-orders list after a partial refund, matching how a
|
|
857
|
+
// real FSM transition touches it.
|
|
858
|
+
await query("UPDATE orders SET updated_at = ?1 WHERE id = ?2", [ts, orderId]);
|
|
859
|
+
// Return the credits this refund covers, in proportion to the refunded
|
|
860
|
+
// total SO FAR (this slice included). Gift-card spend is re-minted and
|
|
861
|
+
// loyalty (redeemed + earned) restored / clawed against the cumulative
|
|
862
|
+
// refunded amount, so a partial-then-final refund sequence converges
|
|
863
|
+
// exactly on the order total. Referral funnel reversal waits for the
|
|
864
|
+
// terminal refund edge — a partial refund doesn't void the order.
|
|
865
|
+
var _ptTotal = Number(current.grand_total_minor) || 0;
|
|
866
|
+
if (_ptTotal > 0) {
|
|
867
|
+
var _ptRefunded = await this.refundedTotalMinor(orderId);
|
|
868
|
+
await _settleGiftCards(orderId, _ptRefunded, _ptTotal);
|
|
869
|
+
if (current.customer_id) {
|
|
870
|
+
var _ptCust = current.customer_id;
|
|
871
|
+
if (loyalty && typeof loyalty.restoreRedemption === "function") {
|
|
872
|
+
Promise.resolve().then(function () {
|
|
873
|
+
return loyalty.restoreRedemption(orderId, {
|
|
874
|
+
refunded_minor: _ptRefunded,
|
|
875
|
+
order_total_minor: _ptTotal,
|
|
876
|
+
});
|
|
877
|
+
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
878
|
+
}
|
|
879
|
+
if (loyaltyEarnRules && typeof loyaltyEarnRules.reverseForEventProRata === "function") {
|
|
880
|
+
Promise.resolve().then(function () {
|
|
881
|
+
return loyaltyEarnRules.reverseForEventProRata({
|
|
882
|
+
customer_id: _ptCust,
|
|
883
|
+
trigger_event_ref: "order:" + orderId,
|
|
884
|
+
refunded_minor: _ptRefunded,
|
|
885
|
+
order_total_minor: _ptTotal,
|
|
886
|
+
});
|
|
887
|
+
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return await this.get(orderId);
|
|
892
|
+
},
|
|
893
|
+
|
|
694
894
|
// Paginated history for a single customer. Tuple cursor
|
|
695
895
|
// (updated_at, id) ordered DESC so the customer's most-recent
|
|
696
896
|
// activity surfaces first. Mirrors the cursor shape used by
|