@blamejs/blamejs-shop 0.4.23 → 0.4.24

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.
@@ -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: enqueueMessage,
864
- inboxForOperator: inboxForOperator,
865
- markRead: markRead,
866
- markUnread: markUnread,
867
- archiveMessage: archiveMessage,
868
- bulkArchive: bulkArchive,
869
- unreadCount: unreadCount,
870
- cleanupOlderThan: cleanupOlderThan,
871
- metricsForKind: metricsForKind,
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 = await giftCards.reverseRedemption(orderId);
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
- await _settleGiftCards(orderId);
589
- }
590
- // Loyalty earn-reversal fan-out — fire-and-forget, same discipline
591
- // as the earn-on-purchase block below. On a cancel / refund edge for
592
- // an order carrying a customer_id, the points awarded when the order
593
- // went paid are clawed back off the balance (floored at zero), or a
594
- // buy-then-refund mints free rewards. reverseForEvent claims the
595
- // earn-log rows with an unreversed predicate, so it is idempotent (a
596
- // re-delivered cancel webhook or the reaper racing a refund reverses
597
- // exactly once) and a natural no-op for an order that never earned
598
- // (a guest order, or one that never reached paid — the never-awarded
599
- // earn-log is empty). The award is detached so a loyalty failure
600
- // lives in the loyalty ledger's own audit trail, never as an
601
- // unhandledRejection and never on the transition's latency.
602
- if (loyaltyEarnRules && typeof loyaltyEarnRules.reverseForEvent === "function"
603
- && (result.to === "cancelled" || result.to === "refunded")
604
- && refreshed && refreshed.customer_id) {
605
- var _revCustomer = refreshed.customer_id;
606
- var _revOrderId = refreshed.id;
607
- Promise.resolve().then(function () {
608
- return loyaltyEarnRules.reverseForEvent({
609
- customer_id: _revCustomer,
610
- trigger_event_ref: "order:" + _revOrderId,
611
- });
612
- }).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
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