@fayz-ai/shop 0.9.0-next.1 → 0.10.0

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.
Files changed (144) hide show
  1. package/README.md +18 -1
  2. package/dist/admin.d.ts +2 -0
  3. package/dist/admin.d.ts.map +1 -0
  4. package/dist/catalog.d.ts +1 -3
  5. package/dist/catalog.d.ts.map +1 -1
  6. package/dist/catalog.js +2 -2
  7. package/dist/chunk-3GERSY5F.js +526 -0
  8. package/dist/chunk-3GERSY5F.js.map +1 -0
  9. package/dist/chunk-FQPGE3YN.js +3 -0
  10. package/dist/{chunk-JR25TISO.js.map → chunk-FQPGE3YN.js.map} +1 -1
  11. package/dist/{chunk-CTKZ6O2J.js → chunk-IKNGCRUM.js} +41 -15
  12. package/dist/chunk-IKNGCRUM.js.map +1 -0
  13. package/dist/{chunk-IYQHKMLB.js → chunk-IL6GA4ER.js} +4 -4
  14. package/dist/chunk-IL6GA4ER.js.map +1 -0
  15. package/dist/chunk-KLDS7IPU.js +17 -0
  16. package/dist/chunk-KLDS7IPU.js.map +1 -0
  17. package/dist/chunk-RGTIJQJL.js +93 -0
  18. package/dist/chunk-RGTIJQJL.js.map +1 -0
  19. package/dist/{chunk-SVTCJ6HP.js → chunk-U3O2I3RM.js} +395 -27
  20. package/dist/chunk-U3O2I3RM.js.map +1 -0
  21. package/dist/chunk-XERLK5LD.js +1415 -0
  22. package/dist/chunk-XERLK5LD.js.map +1 -0
  23. package/dist/chunk-XOP6SHPB.js +142 -0
  24. package/dist/chunk-XOP6SHPB.js.map +1 -0
  25. package/dist/client.d.ts +2 -0
  26. package/dist/client.d.ts.map +1 -0
  27. package/dist/{mock-catalog.d.ts → data/mock-catalog.d.ts} +21 -1
  28. package/dist/data/mock-catalog.d.ts.map +1 -0
  29. package/dist/data/mock-provider.d.ts +133 -0
  30. package/dist/data/mock-provider.d.ts.map +1 -0
  31. package/dist/data/mock.d.ts +3 -0
  32. package/dist/data/mock.d.ts.map +1 -0
  33. package/dist/data/provider.d.ts +142 -0
  34. package/dist/data/provider.d.ts.map +1 -0
  35. package/dist/data/supabase-provider.d.ts +100 -0
  36. package/dist/data/supabase-provider.d.ts.map +1 -0
  37. package/dist/data/tables.d.ts +35 -0
  38. package/dist/data/tables.d.ts.map +1 -0
  39. package/dist/data/tenant.d.ts.map +1 -0
  40. package/dist/entities/entity-reconcile.d.ts +32 -0
  41. package/dist/entities/entity-reconcile.d.ts.map +1 -0
  42. package/dist/entities/entity-sql.d.ts +50 -0
  43. package/dist/entities/entity-sql.d.ts.map +1 -0
  44. package/dist/entities/entity-store.d.ts +58 -0
  45. package/dist/entities/entity-store.d.ts.map +1 -0
  46. package/dist/entities/index.d.ts +11 -0
  47. package/dist/entities/index.d.ts.map +1 -0
  48. package/dist/entities/mock-entity-store.d.ts +17 -0
  49. package/dist/entities/mock-entity-store.d.ts.map +1 -0
  50. package/dist/entities/sidecar.d.ts +9 -0
  51. package/dist/entities/sidecar.d.ts.map +1 -0
  52. package/dist/entities/supabase-entity-store.d.ts +8 -0
  53. package/dist/entities/supabase-entity-store.d.ts.map +1 -0
  54. package/dist/entities.js +159 -0
  55. package/dist/entities.js.map +1 -0
  56. package/dist/index.d.ts +17 -10
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +728 -41
  59. package/dist/index.js.map +1 -1
  60. package/dist/inventory/allocation.d.ts +98 -0
  61. package/dist/inventory/allocation.d.ts.map +1 -0
  62. package/dist/inventory/index.d.ts +13 -0
  63. package/dist/inventory/index.d.ts.map +1 -0
  64. package/dist/inventory/mock-stock-store.d.ts +89 -0
  65. package/dist/inventory/mock-stock-store.d.ts.map +1 -0
  66. package/dist/inventory/postgrest-double.d.ts +65 -0
  67. package/dist/inventory/postgrest-double.d.ts.map +1 -0
  68. package/dist/inventory/shopify-mapping.d.ts +67 -0
  69. package/dist/inventory/shopify-mapping.d.ts.map +1 -0
  70. package/dist/inventory/stock-contract.d.ts +46 -0
  71. package/dist/inventory/stock-contract.d.ts.map +1 -0
  72. package/dist/inventory/stock-store.d.ts +96 -0
  73. package/dist/inventory/stock-store.d.ts.map +1 -0
  74. package/dist/inventory/stock-types.d.ts +292 -0
  75. package/dist/inventory/stock-types.d.ts.map +1 -0
  76. package/dist/inventory/supabase-stock-store.d.ts +59 -0
  77. package/dist/inventory/supabase-stock-store.d.ts.map +1 -0
  78. package/dist/inventory/vocabulary.d.ts +86 -0
  79. package/dist/inventory/vocabulary.d.ts.map +1 -0
  80. package/dist/inventory.js +4 -0
  81. package/dist/inventory.js.map +1 -0
  82. package/dist/lib/catalog.d.ts +4 -0
  83. package/dist/lib/catalog.d.ts.map +1 -0
  84. package/dist/{commerce.d.ts → lib/commerce.d.ts} +2 -2
  85. package/dist/lib/commerce.d.ts.map +1 -0
  86. package/dist/lib/emails.d.ts +50 -0
  87. package/dist/lib/emails.d.ts.map +1 -0
  88. package/dist/lib/message-events.d.ts +4 -0
  89. package/dist/lib/message-events.d.ts.map +1 -0
  90. package/dist/lib/runtime.d.ts +5 -0
  91. package/dist/lib/runtime.d.ts.map +1 -0
  92. package/dist/mock.d.ts +1 -2
  93. package/dist/mock.d.ts.map +1 -1
  94. package/dist/mock.js +4 -2
  95. package/dist/provider.d.ts +1 -88
  96. package/dist/provider.d.ts.map +1 -1
  97. package/dist/rules/adapter.d.ts +36 -0
  98. package/dist/rules/adapter.d.ts.map +1 -0
  99. package/dist/rules/attributes.d.ts +64 -0
  100. package/dist/rules/attributes.d.ts.map +1 -0
  101. package/dist/rules/context.d.ts +43 -0
  102. package/dist/rules/context.d.ts.map +1 -0
  103. package/dist/rules/evaluator.d.ts +37 -0
  104. package/dist/rules/evaluator.d.ts.map +1 -0
  105. package/dist/rules/index.d.ts +13 -0
  106. package/dist/rules/index.d.ts.map +1 -0
  107. package/dist/rules/prices.d.ts +25 -0
  108. package/dist/rules/prices.d.ts.map +1 -0
  109. package/dist/rules/rule-types.d.ts +160 -0
  110. package/dist/rules/rule-types.d.ts.map +1 -0
  111. package/dist/rules.js +22 -0
  112. package/dist/rules.js.map +1 -0
  113. package/dist/runtime.d.ts +1 -4
  114. package/dist/runtime.d.ts.map +1 -1
  115. package/dist/runtime.js +5 -3
  116. package/dist/types.d.ts +231 -0
  117. package/dist/types.d.ts.map +1 -1
  118. package/package.json +26 -20
  119. package/dist/chunk-CTKZ6O2J.js.map +0 -1
  120. package/dist/chunk-IYQHKMLB.js.map +0 -1
  121. package/dist/chunk-JR25TISO.js +0 -3
  122. package/dist/chunk-SVTCJ6HP.js.map +0 -1
  123. package/dist/commerce.d.ts.map +0 -1
  124. package/dist/mock-catalog.d.ts.map +0 -1
  125. package/dist/mock-provider.d.ts +0 -60
  126. package/dist/mock-provider.d.ts.map +0 -1
  127. package/dist/supabase-provider.d.ts +0 -56
  128. package/dist/supabase-provider.d.ts.map +0 -1
  129. package/dist/tables.d.ts +0 -12
  130. package/dist/tables.d.ts.map +0 -1
  131. package/dist/tenant.d.ts.map +0 -1
  132. package/src/catalog.ts +0 -3
  133. package/src/commerce.ts +0 -120
  134. package/src/index.ts +0 -40
  135. package/src/mock-catalog.ts +0 -266
  136. package/src/mock-provider.ts +0 -480
  137. package/src/mock.ts +0 -2
  138. package/src/provider.ts +0 -117
  139. package/src/runtime.ts +0 -20
  140. package/src/supabase-provider.ts +0 -754
  141. package/src/tables.ts +0 -17
  142. package/src/tenant.ts +0 -21
  143. package/src/types.ts +0 -623
  144. /package/dist/{tenant.d.ts → data/tenant.d.ts} +0 -0
@@ -0,0 +1,1415 @@
1
+ import { getShopTenantId } from './chunk-KLDS7IPU.js';
2
+ import { Registry, getSupabaseClientOptional } from '@fayz-ai/core';
3
+
4
+ // src/inventory/stock-store.ts
5
+ var StockMovementError = class extends Error {
6
+ constructor(message, input) {
7
+ super(message);
8
+ this.input = input;
9
+ this.name = "StockMovementError";
10
+ }
11
+ };
12
+ function stockBalanceKey(variantId, locationId, lotId) {
13
+ return `${variantId}::${locationId}::${lotId ?? "-"}`;
14
+ }
15
+ function replayStockLedger(movements, reservations = []) {
16
+ const balances = /* @__PURE__ */ new Map();
17
+ for (const movement of movements) {
18
+ if (!movement.variantId) continue;
19
+ const key = stockBalanceKey(movement.variantId, movement.locationId, movement.lotId);
20
+ const row = balances.get(key) ?? {
21
+ tenantId: movement.tenantId,
22
+ variantId: movement.variantId,
23
+ locationId: movement.locationId,
24
+ lotId: movement.lotId,
25
+ onHand: 0,
26
+ reserved: 0
27
+ };
28
+ row.onHand += movement.quantity;
29
+ balances.set(key, row);
30
+ }
31
+ for (const reservation of reservations) {
32
+ if (reservation.status !== "held") continue;
33
+ const key = stockBalanceKey(reservation.variantId, reservation.locationId, reservation.lotId);
34
+ const row = balances.get(key);
35
+ if (row) {
36
+ row.reserved += reservation.quantity;
37
+ continue;
38
+ }
39
+ balances.set(key, {
40
+ tenantId: "",
41
+ variantId: reservation.variantId,
42
+ locationId: reservation.locationId,
43
+ lotId: reservation.lotId,
44
+ onHand: 0,
45
+ reserved: reservation.quantity
46
+ });
47
+ }
48
+ return balances;
49
+ }
50
+ function mirroredInventoryCount(balances, sellableLocationIds) {
51
+ let total = 0;
52
+ for (const balance of balances) {
53
+ if (!sellableLocationIds.has(balance.locationId)) continue;
54
+ total += Math.max(balance.onHand - balance.reserved, 0);
55
+ }
56
+ return Math.max(total, 0);
57
+ }
58
+ var storeOverrides = /* @__PURE__ */ new Map();
59
+ function registerStockStore(tenantId, store) {
60
+ storeOverrides.set(tenantId, store);
61
+ }
62
+ function getRegisteredStockStore(tenantId) {
63
+ return storeOverrides.get(tenantId);
64
+ }
65
+ function resolveStockStore(tenantId, fallback) {
66
+ return storeOverrides.get(tenantId) ?? fallback();
67
+ }
68
+ function clearStockStores() {
69
+ storeOverrides.clear();
70
+ }
71
+
72
+ // src/inventory/stock-types.ts
73
+ var STOCK_TRACKING_MODES = ["none", "quantity", "lot", "serial"];
74
+ var DEFAULT_STOCK_TRACKING = "quantity";
75
+ var STOCK_LOCATION_KINDS = ["store", "warehouse", "transit", "virtual"];
76
+ var STOCK_RESERVATION_STATUSES = ["held", "consumed", "released"];
77
+ var STOCK_LOT_STATUSES = ["active", "quarantined", "expired", "consumed"];
78
+ var STOCK_MOVEMENT_DIRECTIONS = ["in", "out", "either"];
79
+ var CORE_STOCK_MOVEMENT_KINDS = [
80
+ { key: "receipt", label: "Entrada", direction: "in" },
81
+ { key: "sale", label: "Venda", direction: "out" },
82
+ { key: "return", label: "Devolu\xE7\xE3o", direction: "in" },
83
+ { key: "adjustment", label: "Ajuste", direction: "either" },
84
+ { key: "transfer_in", label: "Transfer\xEAncia (entrada)", direction: "in" },
85
+ { key: "transfer_out", label: "Transfer\xEAncia (sa\xEDda)", direction: "out" },
86
+ { key: "loss", label: "Perda", direction: "out" },
87
+ { key: "count", label: "Contagem", direction: "either" }
88
+ ];
89
+ var CORE_STOCK_REASON_CODES = [
90
+ // --- 0034 -----------------------------------------------------------------
91
+ { key: "opening_balance", label: "Saldo inicial", movementKind: "receipt" },
92
+ { key: "purchase", label: "Compra", movementKind: "receipt" },
93
+ { key: "production", label: "Produ\xE7\xE3o", movementKind: "receipt" },
94
+ { key: "customer_order", label: "Pedido do cliente", movementKind: "sale" },
95
+ { key: "customer_return", label: "Devolu\xE7\xE3o do cliente", movementKind: "return" },
96
+ { key: "cycle_count", label: "Contagem de estoque", movementKind: "count" },
97
+ { key: "breakage", label: "Quebra", movementKind: "loss" },
98
+ { key: "expiry", label: "Vencimento", movementKind: "loss" },
99
+ { key: "theft", label: "Furto", movementKind: "loss" },
100
+ { key: "correction", label: "Corre\xE7\xE3o", movementKind: "adjustment" },
101
+ { key: "legacy_write", label: "Escrita direta em inventory_count", movementKind: "adjustment" },
102
+ { key: "transfer", label: "Transfer\xEAncia entre locais", movementKind: null },
103
+ // --- 0038, for Shopify parity ---------------------------------------------
104
+ // `received` is wider than our `purchase`: stock can arrive from a transfer,
105
+ // a production run or a supplier, and Shopify does not distinguish.
106
+ { key: "received", label: "Recebimento", movementKind: "receipt" },
107
+ // Distinct from our `breakage`: Shopify's `damaged` covers arriving damaged,
108
+ // not only breaking in the shop. Both are kept — a merchant who already uses
109
+ // `breakage` is not renamed.
110
+ { key: "damaged", label: "Avariado", movementKind: "loss" },
111
+ { key: "quality_control", label: "Controle de qualidade", movementKind: "adjustment" },
112
+ { key: "promotion", label: "Promo\xE7\xE3o / brinde", movementKind: "loss" },
113
+ { key: "other", label: "Outro", movementKind: null }
114
+ ];
115
+ var OPENING_BALANCE_REASON = "opening_balance";
116
+ var LEGACY_WRITE_REASON = "legacy_write";
117
+ var stockMovementKindRegistry = new Registry("stockMovementKind");
118
+ var stockReasonCodeRegistry = new Registry("stockReasonCode");
119
+ var coreRegistered = false;
120
+ function registerCoreStockVocabulary() {
121
+ if (coreRegistered && stockMovementKindRegistry.has("receipt")) return;
122
+ for (const kind of CORE_STOCK_MOVEMENT_KINDS) {
123
+ stockMovementKindRegistry.register(kind.key, kind, {
124
+ title: kind.label,
125
+ description: `Stock movement kind (${kind.direction})`,
126
+ source: "sdk"
127
+ });
128
+ }
129
+ for (const reason of CORE_STOCK_REASON_CODES) {
130
+ stockReasonCodeRegistry.register(reason.key, reason, {
131
+ title: reason.label,
132
+ description: reason.movementKind ? `Stock reason code for "${reason.movementKind}" movements` : "Stock reason code (any movement kind)",
133
+ source: "sdk"
134
+ });
135
+ }
136
+ coreRegistered = true;
137
+ }
138
+ function registerStockMovementKind(kind, options = {}) {
139
+ registerCoreStockVocabulary();
140
+ if (!kind.key) throw new Error("registerStockMovementKind: kind.key is required");
141
+ if (!["in", "out", "either"].includes(kind.direction)) {
142
+ throw new Error(
143
+ `registerStockMovementKind: "${kind.key}" has unknown direction "${kind.direction}". Valid directions: in, out, either`
144
+ );
145
+ }
146
+ if (stockMovementKindRegistry.has(kind.key) && !options.replace) {
147
+ throw new Error(
148
+ `registerStockMovementKind: kind "${kind.key}" is already registered. Registered kinds: ${listStockMovementKindKeys().join(", ")}`
149
+ );
150
+ }
151
+ stockMovementKindRegistry.register(kind.key, kind, {
152
+ title: kind.label,
153
+ description: `Stock movement kind (${kind.direction})`,
154
+ source: "app",
155
+ ...options.meta
156
+ });
157
+ }
158
+ function registerStockReasonCode(reason, options = {}) {
159
+ registerCoreStockVocabulary();
160
+ if (!reason.key) throw new Error("registerStockReasonCode: reason.key is required");
161
+ if (reason.movementKind && !stockMovementKindRegistry.has(reason.movementKind)) {
162
+ throw new Error(
163
+ `registerStockReasonCode: "${reason.key}" names unknown movement kind "${reason.movementKind}". Registered kinds: ${listStockMovementKindKeys().join(", ")}`
164
+ );
165
+ }
166
+ if (stockReasonCodeRegistry.has(reason.key) && !options.replace) {
167
+ throw new Error(
168
+ `registerStockReasonCode: reason "${reason.key}" is already registered. Registered reason codes: ${listStockReasonCodeKeys().join(", ")}`
169
+ );
170
+ }
171
+ stockReasonCodeRegistry.register(reason.key, reason, {
172
+ title: reason.label,
173
+ description: reason.movementKind ? `Stock reason code for "${reason.movementKind}" movements` : "Stock reason code (any movement kind)",
174
+ source: "app",
175
+ ...options.meta
176
+ });
177
+ }
178
+ function resolveStockMovementKind(key) {
179
+ registerCoreStockVocabulary();
180
+ const kind = stockMovementKindRegistry.get(key);
181
+ if (!kind) {
182
+ throw new Error(
183
+ `Unknown stock movement kind "${key}". Registered kinds: ${listStockMovementKindKeys().join(", ")}`
184
+ );
185
+ }
186
+ return kind;
187
+ }
188
+ function resolveStockReasonCode(key) {
189
+ registerCoreStockVocabulary();
190
+ const reason = stockReasonCodeRegistry.get(key);
191
+ if (!reason) {
192
+ throw new Error(
193
+ `Unknown stock reason code "${key}". Registered reason codes: ${listStockReasonCodeKeys().join(", ")}`
194
+ );
195
+ }
196
+ return reason;
197
+ }
198
+ function listStockMovementKindKeys() {
199
+ registerCoreStockVocabulary();
200
+ return stockMovementKindRegistry.ids().sort();
201
+ }
202
+ function listStockReasonCodeKeys() {
203
+ registerCoreStockVocabulary();
204
+ return stockReasonCodeRegistry.ids().sort();
205
+ }
206
+ function listStockMovementKinds() {
207
+ registerCoreStockVocabulary();
208
+ return stockMovementKindRegistry.list().map((entry) => entry.value).sort((a, b) => a.key.localeCompare(b.key));
209
+ }
210
+ function listStockReasonCodes(movementKind) {
211
+ registerCoreStockVocabulary();
212
+ const all = stockReasonCodeRegistry.list().map((entry) => entry.value).sort((a, b) => a.key.localeCompare(b.key));
213
+ if (!movementKind) return all;
214
+ return all.filter((reason) => reason.movementKind === null || reason.movementKind === movementKind);
215
+ }
216
+ function clearStockVocabulary() {
217
+ stockMovementKindRegistry.clear();
218
+ stockReasonCodeRegistry.clear();
219
+ coreRegistered = false;
220
+ }
221
+ function validateStockMovement(input, facts) {
222
+ registerCoreStockVocabulary();
223
+ if (!input.variantId) {
224
+ throw new Error("stock movement: variantId is required (the ledger is keyed on the sellable unit)");
225
+ }
226
+ if (!Number.isInteger(input.quantity)) {
227
+ throw new Error(`stock movement: quantity must be a whole number (got ${input.quantity})`);
228
+ }
229
+ if (input.quantity === 0) {
230
+ throw new Error("stock movement: quantity must not be zero \u2014 a movement that moves nothing is not a movement");
231
+ }
232
+ const kind = resolveStockMovementKind(input.kind);
233
+ if (kind.direction === "in" && input.quantity < 0) {
234
+ throw new Error(
235
+ `stock movement: kind "${kind.key}" is an inbound movement and needs a positive quantity (got ${input.quantity})`
236
+ );
237
+ }
238
+ if (kind.direction === "out" && input.quantity > 0) {
239
+ throw new Error(
240
+ `stock movement: kind "${kind.key}" is an outbound movement and needs a negative quantity (got ${input.quantity})`
241
+ );
242
+ }
243
+ if (input.reasonCode) {
244
+ const reason = resolveStockReasonCode(input.reasonCode);
245
+ if (reason.movementKind !== null && reason.movementKind !== kind.key) {
246
+ throw new Error(
247
+ `stock movement: reason "${reason.key}" belongs to kind "${reason.movementKind}", not "${kind.key}"`
248
+ );
249
+ }
250
+ }
251
+ if (facts.tracking === "none") {
252
+ throw new Error(`stock movement: variant ${input.variantId} is tracking=none and holds no stock`);
253
+ }
254
+ if (facts.tracking === "quantity") {
255
+ if (input.lotId) {
256
+ throw new Error(
257
+ `stock movement: variant ${input.variantId} is tracking=quantity; it has one implicit lot, so lotId must be null`
258
+ );
259
+ }
260
+ return;
261
+ }
262
+ if (!input.lotId) {
263
+ throw new Error(`stock movement: variant ${input.variantId} is tracking=${facts.tracking}; lotId is required`);
264
+ }
265
+ const lot = facts.lot;
266
+ if (!lot) {
267
+ throw new Error(`stock movement: lot ${input.lotId} not found`);
268
+ }
269
+ if (lot.variantId !== input.variantId) {
270
+ throw new Error(
271
+ `stock movement: lot ${lot.id} belongs to variant ${lot.variantId}, not ${input.variantId}`
272
+ );
273
+ }
274
+ if (facts.tracking === "serial") {
275
+ if (!lot.serial) {
276
+ throw new Error(
277
+ `stock movement: variant ${input.variantId} is tracking=serial; lot ${lot.id} carries no serial`
278
+ );
279
+ }
280
+ if (Math.abs(input.quantity) !== 1) {
281
+ throw new Error(
282
+ `stock movement: variant ${input.variantId} is tracking=serial; quantity must be 1 or -1 (got ${input.quantity})`
283
+ );
284
+ }
285
+ if (facts.lotOnHand !== void 0) {
286
+ const next = facts.lotOnHand + input.quantity;
287
+ if (next > 1 || next < 0) {
288
+ throw new Error(
289
+ `stock movement: variant ${input.variantId} is tracking=serial; lot ${lot.id} would go to ${next} on hand (from ${facts.lotOnHand}). A serial is one unit: its balance may only be 0 or 1.`
290
+ );
291
+ }
292
+ }
293
+ }
294
+ }
295
+ var CORE_STOCK_ALLOCATION_STRATEGIES = [
296
+ {
297
+ key: "fefo",
298
+ label: "Vence primeiro (FEFO)",
299
+ expiresFirst: true,
300
+ receivedOrder: "asc",
301
+ isManual: false
302
+ },
303
+ {
304
+ key: "fifo",
305
+ label: "Entrou primeiro (FIFO)",
306
+ expiresFirst: false,
307
+ receivedOrder: "asc",
308
+ isManual: false
309
+ },
310
+ {
311
+ key: "lifo",
312
+ label: "Entrou por ultimo (LIFO)",
313
+ expiresFirst: false,
314
+ receivedOrder: "desc",
315
+ isManual: false
316
+ },
317
+ { key: "manual", label: "Manual", expiresFirst: false, receivedOrder: "asc", isManual: true }
318
+ ];
319
+ var stockAllocationStrategyRegistry = new Registry(
320
+ "stockAllocationStrategy"
321
+ );
322
+ var coreRegistered2 = false;
323
+ function registerCoreStockAllocationStrategies() {
324
+ if (coreRegistered2 && stockAllocationStrategyRegistry.has("fefo")) return;
325
+ for (const strategy of CORE_STOCK_ALLOCATION_STRATEGIES) {
326
+ stockAllocationStrategyRegistry.register(strategy.key, strategy, {
327
+ title: strategy.label,
328
+ description: describeStrategy(strategy),
329
+ source: "sdk"
330
+ });
331
+ }
332
+ coreRegistered2 = true;
333
+ }
334
+ function describeStrategy(strategy) {
335
+ if (strategy.isManual) return "Manual \u2014 the caller names the lot";
336
+ const parts = [strategy.expiresFirst ? "soonest expiry first" : "ignores expiry"];
337
+ parts.push(strategy.receivedOrder === "asc" ? "oldest receipt first" : "newest receipt first");
338
+ return parts.join(", ");
339
+ }
340
+ function registerStockAllocationStrategy(strategy, options = {}) {
341
+ registerCoreStockAllocationStrategies();
342
+ if (!strategy.key) throw new Error("registerStockAllocationStrategy: strategy.key is required");
343
+ if (strategy.receivedOrder !== "asc" && strategy.receivedOrder !== "desc") {
344
+ throw new Error(
345
+ `registerStockAllocationStrategy: "${strategy.key}" has unknown receivedOrder "${strategy.receivedOrder}". Valid values: asc, desc`
346
+ );
347
+ }
348
+ if (stockAllocationStrategyRegistry.has(strategy.key) && !options.replace) {
349
+ throw new Error(
350
+ `registerStockAllocationStrategy: strategy "${strategy.key}" is already registered. Registered strategies: ${listStockAllocationStrategyKeys().join(", ")}`
351
+ );
352
+ }
353
+ stockAllocationStrategyRegistry.register(strategy.key, strategy, {
354
+ title: strategy.label,
355
+ description: describeStrategy(strategy),
356
+ source: "app",
357
+ ...options.meta
358
+ });
359
+ }
360
+ function resolveStockAllocationStrategy(key) {
361
+ registerCoreStockAllocationStrategies();
362
+ const strategy = stockAllocationStrategyRegistry.get(key);
363
+ if (!strategy) {
364
+ throw new Error(
365
+ `shop_allocate_stock: unknown allocation strategy "${key}". Valid strategies: ${listStockAllocationStrategyKeys().join(", ")}`
366
+ );
367
+ }
368
+ return strategy;
369
+ }
370
+ function listStockAllocationStrategyKeys() {
371
+ registerCoreStockAllocationStrategies();
372
+ return stockAllocationStrategyRegistry.ids().sort();
373
+ }
374
+ function listStockAllocationStrategies() {
375
+ registerCoreStockAllocationStrategies();
376
+ return stockAllocationStrategyRegistry.list().map((entry) => entry.value).sort((a, b) => a.key.localeCompare(b.key));
377
+ }
378
+ function clearStockAllocationStrategies() {
379
+ stockAllocationStrategyRegistry.clear();
380
+ coreRegistered2 = false;
381
+ }
382
+ function resolveAllocationStrategyKey(input) {
383
+ const named = trimmed(input.explicit) ?? trimmed(input.variantDefault) ?? trimmed(input.tenantDefault);
384
+ if (named) {
385
+ resolveStockAllocationStrategy(named);
386
+ return named;
387
+ }
388
+ const hasExpiry = (input.candidates ?? []).some((candidate) => candidate.expiresAt != null);
389
+ return hasExpiry ? "fefo" : "fifo";
390
+ }
391
+ function trimmed(value) {
392
+ if (typeof value !== "string") return null;
393
+ const next = value.trim();
394
+ return next.length > 0 ? next : null;
395
+ }
396
+ function compareStockAllocationCandidates(strategy, a, b) {
397
+ if (strategy.expiresFirst) {
398
+ const aNull = a.expiresAt == null ? 1 : 0;
399
+ const bNull = b.expiresAt == null ? 1 : 0;
400
+ if (aNull !== bNull) return aNull - bNull;
401
+ if (a.expiresAt != null && b.expiresAt != null && a.expiresAt !== b.expiresAt) {
402
+ return a.expiresAt < b.expiresAt ? -1 : 1;
403
+ }
404
+ }
405
+ if (a.receivedAt !== b.receivedAt) {
406
+ const older = a.receivedAt < b.receivedAt ? -1 : 1;
407
+ return strategy.receivedOrder === "asc" ? older : -older;
408
+ }
409
+ const aDefault = a.isDefaultLocation ? 0 : 1;
410
+ const bDefault = b.isDefaultLocation ? 0 : 1;
411
+ if (aDefault !== bDefault) return aDefault - bDefault;
412
+ return String(a.lotId ?? "").localeCompare(String(b.lotId ?? ""));
413
+ }
414
+ var StockAllocationError = class extends Error {
415
+ constructor(message, requested, available) {
416
+ super(message);
417
+ this.requested = requested;
418
+ this.available = available;
419
+ this.name = "StockAllocationError";
420
+ }
421
+ };
422
+ function allocateStock(request, candidates) {
423
+ if (!Number.isInteger(request.quantity) || request.quantity <= 0) {
424
+ throw new Error(`shop_allocate_stock: quantity must be positive (got ${request.quantity})`);
425
+ }
426
+ if (request.tracking === "none") return [];
427
+ const strategy = resolveStockAllocationStrategy(
428
+ resolveAllocationStrategyKey({
429
+ explicit: request.strategy,
430
+ variantDefault: request.variantStrategy,
431
+ tenantDefault: request.tenantStrategy,
432
+ candidates
433
+ })
434
+ );
435
+ if (request.tracking === "quantity" && request.lotId) {
436
+ throw new Error(
437
+ `shop_allocate_stock: variant ${request.variantId} is tracking=quantity; it has one implicit lot, so lotId must be null`
438
+ );
439
+ }
440
+ if (request.tracking !== "quantity" && strategy.isManual && !request.lotId) {
441
+ throw new Error(
442
+ `shop_allocate_stock: variant ${request.variantId} uses the "manual" strategy; name the lot to allocate from (lotId)`
443
+ );
444
+ }
445
+ const eligible = candidates.filter((candidate) => candidate.available > 0).filter((candidate) => request.locationId ? candidate.locationId === request.locationId : true).filter((candidate) => request.lotId ? candidate.lotId === request.lotId : true).filter((candidate) => candidate.isSellableLocation !== false).filter(
446
+ (candidate) => request.tracking === "quantity" ? candidate.lotId == null : candidate.lotId != null
447
+ ).slice().sort((a, b) => compareStockAllocationCandidates(strategy, a, b));
448
+ const chosen = [];
449
+ let remaining = request.quantity;
450
+ for (const candidate of eligible) {
451
+ if (remaining <= 0) break;
452
+ const take = Math.min(remaining, candidate.available);
453
+ if (request.tracking === "serial" && take !== 1) {
454
+ throw new Error(
455
+ `shop_allocate_stock: variant ${request.variantId} is tracking=serial; lot ${candidate.lotId} offered ${candidate.available} units and a serial is one unit`
456
+ );
457
+ }
458
+ chosen.push({ lotId: candidate.lotId, locationId: candidate.locationId, quantity: take });
459
+ remaining -= take;
460
+ }
461
+ if (remaining > 0) {
462
+ const available = candidates.filter((candidate) => candidate.isSellableLocation !== false).reduce((sum, candidate) => sum + Math.max(candidate.available, 0), 0);
463
+ throw new StockAllocationError(
464
+ `shop_allocate_stock: variant ${request.variantId} cannot cover ${request.quantity} unit(s); ${available} available`,
465
+ request.quantity,
466
+ available
467
+ );
468
+ }
469
+ return chosen;
470
+ }
471
+ var CORE_RESERVATION_TTL_MINUTES = [
472
+ { paymentMethod: null, minutes: 30 },
473
+ { paymentMethod: "pix", minutes: 30 },
474
+ { paymentMethod: "credit_card", minutes: 30 },
475
+ { paymentMethod: "debit_card", minutes: 30 },
476
+ { paymentMethod: "cash", minutes: 30 },
477
+ { paymentMethod: "other", minutes: 30 },
478
+ { paymentMethod: "boleto", minutes: 4320 }
479
+ ];
480
+ function resolveReservationTtlMinutes(paymentMethod, overrides = []) {
481
+ const rows = [...overrides, ...CORE_RESERVATION_TTL_MINUTES];
482
+ const named = rows.find((row) => row.paymentMethod != null && row.paymentMethod === paymentMethod);
483
+ if (named) return named.minutes;
484
+ const fallback = rows.find((row) => row.paymentMethod == null);
485
+ return fallback?.minutes ?? 30;
486
+ }
487
+
488
+ // src/inventory/shopify-mapping.ts
489
+ var SHOPIFY_DEFAULT_OPTION_NAME = "Title";
490
+ var SHOPIFY_DEFAULT_VARIANT_TITLE = "Default Title";
491
+ function toShopifyVariantTitle(isDefaultVariant, optionsLabel) {
492
+ return isDefaultVariant || !optionsLabel ? SHOPIFY_DEFAULT_VARIANT_TITLE : optionsLabel;
493
+ }
494
+ function shopifyDefaultOptions() {
495
+ return [{ name: SHOPIFY_DEFAULT_OPTION_NAME, values: [SHOPIFY_DEFAULT_VARIANT_TITLE] }];
496
+ }
497
+ var SHOPIFY_QUANTITY_STATES = [
498
+ { shopify: "available", ours: "available", note: "identical (on_hand \u2212 reserved)" },
499
+ { shopify: "on_hand", ours: "on_hand", note: "identical" },
500
+ {
501
+ shopify: "committed",
502
+ ours: "reserved",
503
+ note: "same concept; plg_shop_stock_levels publishes both names"
504
+ },
505
+ {
506
+ shopify: "incoming",
507
+ ours: null,
508
+ note: 'modelled as a PLACE \u2014 a balance in a location with is_sellable = false (kind "transit") \u2014 which is strictly more expressive than one number'
509
+ },
510
+ {
511
+ shopify: "damaged",
512
+ ours: null,
513
+ note: 'modelled as a `loss` movement with reason_code "damaged": carries when, who and how many, where Shopify has a bucket'
514
+ },
515
+ {
516
+ shopify: "quality_control",
517
+ ours: null,
518
+ note: 'modelled as a lot with status "quarantined" \u2014 a BATCH under inspection, not a quantity'
519
+ },
520
+ {
521
+ shopify: "safety_stock",
522
+ ours: null,
523
+ note: "NOT MODELLED. Reorder point and low-stock threshold are forecasting, which inventory-model.md \xA78 puts out of scope on purpose"
524
+ }
525
+ ];
526
+ var SHOPIFY_ADJUSTMENT_REASONS = [
527
+ { shopify: "correction", reasonCode: "correction", movementKind: "adjustment" },
528
+ { shopify: "cycle_count_available", reasonCode: "cycle_count", movementKind: "count" },
529
+ { shopify: "received", reasonCode: "received", movementKind: "receipt" },
530
+ { shopify: "damaged", reasonCode: "damaged", movementKind: "loss" },
531
+ { shopify: "quality_control", reasonCode: "quality_control", movementKind: "adjustment" },
532
+ { shopify: "promotion", reasonCode: "promotion", movementKind: "loss" },
533
+ { shopify: "movement_created", reasonCode: "transfer", movementKind: "transfer_out" },
534
+ { shopify: "movement_received", reasonCode: "transfer", movementKind: "transfer_in" },
535
+ { shopify: "movement_updated", reasonCode: "transfer", movementKind: "transfer_in" },
536
+ { shopify: "movement_canceled", reasonCode: "correction", movementKind: "adjustment" },
537
+ { shopify: "other", reasonCode: "other", movementKind: null },
538
+ {
539
+ shopify: "reservation_created",
540
+ reasonCode: null,
541
+ movementKind: null,
542
+ note: "NOT a movement. A hold is a row in plg_shop_stock_reservations with a status and a TTL \u2014 a lifecycle, not an entry in an append-only ledger. Recording it as a movement is what forces Shopify to have a reason code for it at all."
543
+ }
544
+ ];
545
+ function shopifyReasonToStockReason(shopifyReason) {
546
+ const row = SHOPIFY_ADJUSTMENT_REASONS.find((entry) => entry.shopify === shopifyReason);
547
+ if (!row) {
548
+ throw new Error(
549
+ `shopifyReasonToStockReason: unknown Shopify reason "${shopifyReason}". Known reasons: ${SHOPIFY_ADJUSTMENT_REASONS.map((entry) => entry.shopify).join(", ")}`
550
+ );
551
+ }
552
+ return row.reasonCode;
553
+ }
554
+ var BEYOND_SHOPIFY = [
555
+ {
556
+ feature: "lot / batch tracking with expires_at",
557
+ why: "artorious sells smoked meat: FEFO picking and an answerable recall are regulatory, not optional. Shopify has no native lots."
558
+ },
559
+ {
560
+ feature: "FEFO / FIFO / LIFO / manual allocation as data",
561
+ why: "Shopify allocates per location only. Which BATCH leaves the shelf is the question a food store actually asks."
562
+ },
563
+ {
564
+ feature: "serial tracking bounded to {0,1} per lot",
565
+ why: "cristinarotondaro: every piece is one-of-one. Shopify has no native serials."
566
+ },
567
+ {
568
+ feature: "per-variant tracking policy (none | quantity | lot | serial)",
569
+ why: "One model has to express all six fleet stores without a code fork, and the default must cost a simple store nothing."
570
+ },
571
+ {
572
+ feature: "unit_cost on the lot and on the movement",
573
+ why: "Margin and CMV are derivable. Shopify keeps cost on the InventoryItem only, with no history."
574
+ }
575
+ ];
576
+ var TRACKING_MODES_BEYOND_SHOPIFY = ["lot", "serial"];
577
+
578
+ // src/data/tables.ts
579
+ var T = {
580
+ products: "plg_shop_products",
581
+ productImages: "plg_shop_product_images",
582
+ productOptions: "plg_shop_product_options",
583
+ productVariants: "plg_shop_product_variants",
584
+ categories: "plg_shop_categories",
585
+ orders: "plg_shop_orders",
586
+ orderItems: "plg_shop_order_items",
587
+ customers: "plg_shop_customers",
588
+ discounts: "plg_shop_discounts",
589
+ salesChannels: "plg_shop_channels",
590
+ // Collections, gift cards and customer groups/segments (migrations 0040-0043).
591
+ // None of them touch the stock ledger or variants.
592
+ collections: "plg_shop_collections",
593
+ collectionProducts: "plg_shop_collection_products",
594
+ giftCards: "plg_shop_gift_cards",
595
+ // Message customisations live in the automations table, not a shop-owned one:
596
+ // the store's emails are events in the same catalog every other module writes
597
+ // to. `plg_shop_email_templates` (0042) is the retired shape — plugin-automations
598
+ // migration 0001 copied its rows over and nothing writes to it anymore.
599
+ messageTemplates: "plg_automations_templates",
600
+ customerGroups: "plg_shop_customer_groups",
601
+ customerGroupMembers: "plg_shop_customer_group_members",
602
+ customerSegments: "plg_shop_customer_segments",
603
+ /** Public bucket the media library and product photos both live in. */
604
+ mediaBucket: "shop-images",
605
+ // The inventory ledger (migrations 0034-0036). stockBalances is DERIVED: it is
606
+ // read here and never written, because the only legitimate writers are 0034's
607
+ // SECURITY DEFINER triggers — which is why that table deliberately has no RLS
608
+ // write policy at all.
609
+ stockMovementKinds: "plg_shop_stock_movement_kinds",
610
+ stockReasonCodes: "plg_shop_stock_reason_codes",
611
+ stockLocations: "plg_shop_stock_locations",
612
+ stockLots: "plg_shop_stock_lots",
613
+ stockMovements: "plg_shop_stock_movements",
614
+ stockReservations: "plg_shop_stock_reservations",
615
+ stockBalances: "plg_shop_stock_balances",
616
+ // Allocation and the reservation lifecycle (0038). The strategies and TTLs are
617
+ // VOCABULARY — rows, not enums (I9) — so an agency widens either with an
618
+ // INSERT. stockLevels is a VIEW: our (variant, lot, location) balances
619
+ // projected to (variant, location), which is Shopify's InventoryLevel shape
620
+ // and what a connector syncs.
621
+ stockAllocationStrategies: "plg_shop_stock_allocation_strategies",
622
+ stockReservationTtls: "plg_shop_stock_reservation_ttls",
623
+ stockLevels: "plg_shop_stock_levels",
624
+ // Traceability (0039): which lot filled which order line. A join table, not a
625
+ // jsonb column, because the query that matters runs BACKWARDS.
626
+ orderItemLots: "plg_shop_order_item_lots",
627
+ // Tenant-level shop settings (0029). stock_ownership = 'external' means an
628
+ // ERP owns the numbers and our ledger is not authoritative for that store.
629
+ settings: "plg_shop_settings",
630
+ // Core, not plg_shop_*: a delivery zone is a fact about the business, and
631
+ // fulfilment and financial read it too. Same reasoning as addresses and
632
+ // orders — see migration 0021.
633
+ shippingZones: "shipping_zones"
634
+ };
635
+
636
+ // src/inventory/supabase-stock-store.ts
637
+ function getDb() {
638
+ const supabase = getSupabaseClientOptional();
639
+ if (!supabase) throw new Error("@fayz-ai/shop: Supabase client not initialized. Call setGlobalSupabaseClient() first.");
640
+ return supabase;
641
+ }
642
+ function rowToLocation(row) {
643
+ return {
644
+ id: row.id,
645
+ tenantId: row.tenant_id,
646
+ code: row.code,
647
+ name: row.name,
648
+ kind: row.kind ?? "store",
649
+ isSellable: row.is_sellable ?? true,
650
+ isDefault: row.is_default ?? false,
651
+ status: row.status ?? "active",
652
+ createdAt: row.created_at,
653
+ updatedAt: row.updated_at
654
+ };
655
+ }
656
+ function rowToLot(row) {
657
+ return {
658
+ id: row.id,
659
+ tenantId: row.tenant_id,
660
+ variantId: row.variant_id,
661
+ lotCode: row.lot_code,
662
+ serial: row.serial ?? null,
663
+ manufacturedAt: row.manufactured_at ?? null,
664
+ expiresAt: row.expires_at ?? null,
665
+ receivedAt: row.received_at,
666
+ // numeric(12,4) arrives as a string on the wire; Number() once, here, so no
667
+ // caller ever adds a cost to a string and gets concatenation.
668
+ unitCost: row.unit_cost == null ? null : Number(row.unit_cost),
669
+ supplierRef: row.supplier_ref ?? null,
670
+ status: row.status ?? "active",
671
+ createdAt: row.created_at,
672
+ updatedAt: row.updated_at
673
+ };
674
+ }
675
+ function rowToMovement(row) {
676
+ return {
677
+ id: row.id,
678
+ tenantId: row.tenant_id,
679
+ variantId: row.variant_id ?? null,
680
+ productId: row.product_id ?? null,
681
+ locationId: row.location_id,
682
+ lotId: row.lot_id ?? null,
683
+ kind: row.kind,
684
+ reasonCode: row.reason_code ?? null,
685
+ quantity: Number(row.quantity),
686
+ unitCost: row.unit_cost == null ? null : Number(row.unit_cost),
687
+ orderId: row.order_id ?? null,
688
+ reservationId: row.reservation_id ?? null,
689
+ idempotencyKey: row.idempotency_key ?? null,
690
+ occurredAt: row.occurred_at,
691
+ createdBy: row.created_by ?? null,
692
+ note: row.note ?? null,
693
+ createdAt: row.created_at
694
+ };
695
+ }
696
+ function rowToBalance(row) {
697
+ const onHand = Number(row.on_hand ?? 0);
698
+ const reserved = Number(row.reserved ?? 0);
699
+ return {
700
+ id: row.id,
701
+ tenantId: row.tenant_id,
702
+ variantId: row.variant_id,
703
+ locationId: row.location_id,
704
+ lotId: row.lot_id ?? null,
705
+ onHand,
706
+ reserved,
707
+ // `available` is a STORED generated column in 0034. It is recomputed here
708
+ // rather than trusted, so a projection that omitted the column cannot make
709
+ // `available` silently read 0 — the one number the storefront depends on.
710
+ available: row.available == null ? onHand - reserved : Number(row.available),
711
+ updatedAt: row.updated_at
712
+ };
713
+ }
714
+ function rowToReservation(row) {
715
+ return {
716
+ id: row.id,
717
+ tenantId: row.tenant_id,
718
+ variantId: row.variant_id,
719
+ locationId: row.location_id,
720
+ lotId: row.lot_id ?? null,
721
+ quantity: Number(row.quantity),
722
+ status: row.status ?? "held",
723
+ orderId: row.order_id ?? null,
724
+ cartToken: row.cart_token ?? null,
725
+ expiresAt: row.expires_at ?? null,
726
+ idempotencyKey: row.idempotency_key ?? null,
727
+ createdBy: row.created_by ?? null,
728
+ createdAt: row.created_at,
729
+ updatedAt: row.updated_at
730
+ };
731
+ }
732
+ var UNIQUE_VIOLATION = "23505";
733
+ var SupabaseStockStore = class {
734
+ /**
735
+ * An explicit tenant pins the store to one pool (what an ERP connector or a
736
+ * background job needs); omitted, it resolves per call through the same
737
+ * resolver every other plg_shop_* read uses.
738
+ */
739
+ constructor(explicitTenantId) {
740
+ this.explicitTenantId = explicitTenantId;
741
+ }
742
+ tenantId() {
743
+ return this.explicitTenantId ?? getShopTenantId();
744
+ }
745
+ requireTenantId(operation) {
746
+ const tenantId = this.tenantId();
747
+ if (!tenantId) {
748
+ throw new Error(
749
+ `@fayz-ai/shop: ${operation} requires a tenant id (configure the shop tenant resolver, or construct SupabaseStockStore with one).`
750
+ );
751
+ }
752
+ return tenantId;
753
+ }
754
+ // --- reads ------------------------------------------------------------------
755
+ async listLocations() {
756
+ const db = getDb();
757
+ let q = db.from(T.stockLocations).select("*");
758
+ const tenantId = this.tenantId();
759
+ if (tenantId) q = q.eq("tenant_id", tenantId);
760
+ q = q.order("is_default", { ascending: false }).order("code", { ascending: true });
761
+ const { data, error } = await q;
762
+ if (error) throw error;
763
+ return (data ?? []).map(rowToLocation);
764
+ }
765
+ async getDefaultLocation() {
766
+ const db = getDb();
767
+ let q = db.from(T.stockLocations).select("*").eq("is_default", true);
768
+ const tenantId = this.tenantId();
769
+ if (tenantId) q = q.eq("tenant_id", tenantId);
770
+ const { data, error } = await q.maybeSingle();
771
+ if (error) throw error;
772
+ return data ? rowToLocation(data) : null;
773
+ }
774
+ /**
775
+ * `sellableOnly` filters by an id set read from the locations table rather
776
+ * than by an embedded `!inner` join. Locations are a handful of rows per
777
+ * tenant, and the set is the same one mirroredInventoryCount() takes — so the
778
+ * mock, this store and 0036's mirror all narrow "sellable" through one
779
+ * definition instead of three query dialects.
780
+ */
781
+ async listBalances(options = {}) {
782
+ const db = getDb();
783
+ let q = db.from(T.stockBalances).select("*");
784
+ const tenantId = this.tenantId();
785
+ if (tenantId) q = q.eq("tenant_id", tenantId);
786
+ if (options.variantId) q = q.eq("variant_id", options.variantId);
787
+ if (options.variantIds) q = q.in("variant_id", options.variantIds);
788
+ if (options.locationId) q = q.eq("location_id", options.locationId);
789
+ if (options.lotId) q = q.eq("lot_id", options.lotId);
790
+ if (options.sellableOnly) {
791
+ const sellable = await this.sellableLocationIds();
792
+ if (sellable.size === 0) return [];
793
+ q = q.in("location_id", [...sellable]);
794
+ }
795
+ const { data, error } = await q;
796
+ if (error) throw error;
797
+ return (data ?? []).map(rowToBalance);
798
+ }
799
+ async getAvailable(variantId) {
800
+ const [balances, sellable] = await Promise.all([
801
+ this.listBalances({ variantId }),
802
+ this.sellableLocationIds()
803
+ ]);
804
+ return mirroredInventoryCount(balances, sellable);
805
+ }
806
+ async listLots(options = {}) {
807
+ const db = getDb();
808
+ let q = db.from(T.stockLots).select("*");
809
+ const tenantId = this.tenantId();
810
+ if (tenantId) q = q.eq("tenant_id", tenantId);
811
+ if (options.variantId) q = q.eq("variant_id", options.variantId);
812
+ if (options.status) q = q.eq("status", options.status);
813
+ if (options.expiresBefore) q = q.lte("expires_at", options.expiresBefore);
814
+ if (options.fefo !== false) {
815
+ q = q.order("expires_at", { ascending: true, nullsFirst: false }).order("received_at", { ascending: true });
816
+ }
817
+ if (options.limit != null) q = q.limit(options.limit);
818
+ const { data, error } = await q;
819
+ if (error) throw error;
820
+ return (data ?? []).map(rowToLot);
821
+ }
822
+ async listMovements(options = {}) {
823
+ const db = getDb();
824
+ let q = db.from(T.stockMovements).select("*");
825
+ const tenantId = this.tenantId();
826
+ if (tenantId) q = q.eq("tenant_id", tenantId);
827
+ if (options.variantId) q = q.eq("variant_id", options.variantId);
828
+ if (options.lotId) q = q.eq("lot_id", options.lotId);
829
+ if (options.orderId) q = q.eq("order_id", options.orderId);
830
+ if (options.kind) q = q.eq("kind", options.kind);
831
+ if (options.reasonCode) q = q.eq("reason_code", options.reasonCode);
832
+ const ascending = options.newestFirst === false;
833
+ q = q.order("occurred_at", { ascending }).order("created_at", { ascending });
834
+ if (options.limit != null) q = q.limit(options.limit);
835
+ const { data, error } = await q;
836
+ if (error) throw error;
837
+ return (data ?? []).map(rowToMovement);
838
+ }
839
+ async listReservations(variantId) {
840
+ const db = getDb();
841
+ let q = db.from(T.stockReservations).select("*");
842
+ const tenantId = this.tenantId();
843
+ if (tenantId) q = q.eq("tenant_id", tenantId);
844
+ if (variantId) q = q.eq("variant_id", variantId);
845
+ q = q.order("created_at", { ascending: true });
846
+ const { data, error } = await q;
847
+ if (error) throw error;
848
+ return (data ?? []).map(rowToReservation);
849
+ }
850
+ // --- append -----------------------------------------------------------------
851
+ /**
852
+ * Append to the ledger. Never an update — a mistake is corrected by an
853
+ * opposing movement.
854
+ *
855
+ * IDEMPOTENCY IS TWO MECHANISMS, not one, and the second is the real one:
856
+ *
857
+ * 1. A pre-read on (tenant_id, idempotency_key) returns the existing
858
+ * movement. This is the ordinary retry — same caller, seconds later —
859
+ * and it costs one cheap indexed SELECT.
860
+ * 2. A `23505` on the INSERT is caught and re-read. This is the race the
861
+ * pre-read cannot cover: two retries in flight at once both miss the
862
+ * SELECT and both attempt the INSERT. 0034's partial unique index
863
+ * plg_shop_stock_movements_idempotency_key is what turns the loser into
864
+ * an error we can recognise instead of a second decrement.
865
+ *
866
+ * Without (2) a retried place_order under load decrements twice, which is
867
+ * precisely the failure the idempotency key exists to prevent.
868
+ */
869
+ async recordMovement(input) {
870
+ const db = getDb();
871
+ const tenantId = this.requireTenantId("recordMovement");
872
+ if (input.idempotencyKey) {
873
+ const existing = await this.findByIdempotencyKey(input.idempotencyKey);
874
+ if (existing) return existing;
875
+ }
876
+ const variant = await this.readVariant(input.variantId);
877
+ if (!variant) {
878
+ throw new StockMovementError(`stock movement: variant ${input.variantId} not found`, input);
879
+ }
880
+ if (variant.tenantId !== tenantId) {
881
+ throw new StockMovementError(
882
+ `stock movement: variant ${input.variantId} belongs to another store`,
883
+ input
884
+ );
885
+ }
886
+ const lot = input.lotId ? await this.readLot(input.lotId) : null;
887
+ try {
888
+ validateStockMovement(input, {
889
+ tracking: variant.tracking,
890
+ lot,
891
+ // Summed over EVERY location: a serial is one physical unit in the whole
892
+ // store, so moving it between locations must not let a second copy
893
+ // appear. Same predicate as 0034's SELECT, and read only when it can
894
+ // matter, so the quantity-tracked common case costs nothing.
895
+ lotOnHand: lot && variant.tracking === "serial" ? await this.lotOnHand(lot.id) : void 0
896
+ });
897
+ } catch (error2) {
898
+ throw new StockMovementError(error2.message, input);
899
+ }
900
+ let locationId = input.locationId ?? null;
901
+ if (locationId) {
902
+ const known = await this.listLocations();
903
+ if (!known.some((location) => location.id === locationId)) {
904
+ throw new StockMovementError(
905
+ `stock movement: location ${locationId} does not belong to store ${tenantId}`,
906
+ input
907
+ );
908
+ }
909
+ } else {
910
+ locationId = (await this.getDefaultLocation())?.id ?? null;
911
+ }
912
+ const payload = {
913
+ tenant_id: tenantId,
914
+ variant_id: input.variantId,
915
+ location_id: locationId,
916
+ lot_id: input.lotId ?? null,
917
+ kind: input.kind,
918
+ reason_code: input.reasonCode ?? null,
919
+ quantity: input.quantity,
920
+ unit_cost: input.unitCost ?? null,
921
+ order_id: input.orderId ?? null,
922
+ reservation_id: input.reservationId ?? null,
923
+ idempotency_key: input.idempotencyKey ?? null,
924
+ ...input.occurredAt !== void 0 ? { occurred_at: input.occurredAt } : {},
925
+ note: input.note ?? null
926
+ // product_id is denormalised by the validator trigger from the variant,
927
+ // so the ledger row still names its product after an ON DELETE SET NULL.
928
+ };
929
+ const { data, error } = await db.from(T.stockMovements).insert(payload).select().single();
930
+ if (error) {
931
+ if (error.code === UNIQUE_VIOLATION && input.idempotencyKey) {
932
+ const winner = await this.findByIdempotencyKey(input.idempotencyKey);
933
+ if (winner) return winner;
934
+ }
935
+ throw new StockMovementError(error.message ?? String(error), input);
936
+ }
937
+ return rowToMovement(data);
938
+ }
939
+ // --- allocation and the reservation lifecycle (§9 step 4) ---------------------
940
+ //
941
+ // All three go through an RPC rather than being computed here, and that is the
942
+ // whole design rather than a shortcut. §4: allocation runs IN POSTGRES, inside
943
+ // the SECURITY DEFINER trust boundary, because "stock that can be allocated
944
+ // from the client is stock that can be lied about". A TypeScript
945
+ // implementation that read balances and picked lots itself would be a second
946
+ // allocator racing the first — two answers to "which batch leaves the shelf",
947
+ // with no lock between them.
948
+ //
949
+ // The pure ordering in allocation.ts is the SPECIFICATION both sides share and
950
+ // what the mock executes; this class calls the authority.
951
+ async allocate(request) {
952
+ const db = getDb();
953
+ const tenantId = this.requireTenantId("allocate");
954
+ const { data, error } = await db.rpc("shop_allocate_stock", {
955
+ p_tenant_id: tenantId,
956
+ p_variant_id: request.variantId,
957
+ p_quantity: request.quantity,
958
+ p_location_id: request.locationId ?? null,
959
+ p_strategy: request.strategy ?? null,
960
+ p_lot_id: request.lotId ?? null
961
+ });
962
+ if (error) throw new Error(error.message ?? String(error));
963
+ return (data ?? []).map((row) => ({
964
+ lotId: row.lot_id ?? null,
965
+ locationId: row.location_id,
966
+ quantity: Number(row.quantity)
967
+ }));
968
+ }
969
+ async reserve(input) {
970
+ const db = getDb();
971
+ const tenantId = this.requireTenantId("reserve");
972
+ const { data, error } = await db.rpc("shop_reserve_stock", {
973
+ p_tenant_id: tenantId,
974
+ p_variant_id: input.variantId,
975
+ p_quantity: input.quantity,
976
+ p_location_id: input.locationId,
977
+ p_lot_id: input.lotId ?? null,
978
+ p_order_id: input.orderId ?? null,
979
+ p_cart_token: input.cartToken ?? null,
980
+ // Postgres parses an ISO-8601 interval literal; minutes keeps the port's
981
+ // surface a number rather than a Postgres dialect.
982
+ p_ttl: input.ttlMinutes == null ? null : `${input.ttlMinutes} minutes`,
983
+ p_idempotency_key: input.idempotencyKey ?? null
984
+ });
985
+ if (error) throw new Error(error.message ?? String(error));
986
+ const id = typeof data === "string" ? data : data?.[0];
987
+ const rows = await this.listReservations(input.variantId);
988
+ const created = rows.find((row) => row.id === id);
989
+ if (!created) throw new Error(`shop_reserve_stock: reservation ${id} could not be read back`);
990
+ return created;
991
+ }
992
+ async setReservationStatus(reservationId, status) {
993
+ const db = getDb();
994
+ let q = db.from(T.stockReservations).update({ status }).eq("id", reservationId);
995
+ const tenantId = this.tenantId();
996
+ if (tenantId) q = q.eq("tenant_id", tenantId);
997
+ const { error } = await q;
998
+ if (error) throw error;
999
+ }
1000
+ async sweepExpiredReservations(variantId) {
1001
+ const db = getDb();
1002
+ const tenantId = this.requireTenantId("sweepExpiredReservations");
1003
+ const { data, error } = await db.rpc("shop_sweep_stock_reservations", {
1004
+ p_tenant_id: tenantId,
1005
+ p_variant_id: variantId ?? null
1006
+ });
1007
+ if (error) throw new Error(error.message ?? String(error));
1008
+ return Number(Array.isArray(data) ? data[0] ?? 0 : data ?? 0);
1009
+ }
1010
+ // --- helpers ------------------------------------------------------------------
1011
+ async findByIdempotencyKey(key) {
1012
+ const db = getDb();
1013
+ let q = db.from(T.stockMovements).select("*").eq("idempotency_key", key);
1014
+ const tenantId = this.tenantId();
1015
+ if (tenantId) q = q.eq("tenant_id", tenantId);
1016
+ const { data, error } = await q.maybeSingle();
1017
+ if (error) throw error;
1018
+ return data ? rowToMovement(data) : null;
1019
+ }
1020
+ /** The variant facts the validator needs — tracking is 0034's new column. */
1021
+ async readVariant(variantId) {
1022
+ const { data, error } = await getDb().from(T.productVariants).select("id,tenant_id,product_id,tracking").eq("id", variantId).maybeSingle();
1023
+ if (error) throw error;
1024
+ if (!data) return null;
1025
+ return {
1026
+ id: data.id,
1027
+ tenantId: data.tenant_id,
1028
+ productId: data.product_id ?? null,
1029
+ tracking: data.tracking ?? "quantity"
1030
+ };
1031
+ }
1032
+ async readLot(lotId) {
1033
+ const { data, error } = await getDb().from(T.stockLots).select("id,variant_id,serial").eq("id", lotId).maybeSingle();
1034
+ if (error) throw error;
1035
+ if (!data) return null;
1036
+ return { id: data.id, variantId: data.variant_id, serial: data.serial ?? null };
1037
+ }
1038
+ /** on_hand for one lot across every location — the serial-bound predicate. */
1039
+ async lotOnHand(lotId) {
1040
+ const rows = await this.listBalances({ lotId });
1041
+ return rows.reduce((sum, row) => sum + row.onHand, 0);
1042
+ }
1043
+ async sellableLocationIds() {
1044
+ const locations = await this.listLocations();
1045
+ return new Set(
1046
+ locations.filter((location) => location.isSellable && location.status === "active").map((l) => l.id)
1047
+ );
1048
+ }
1049
+ };
1050
+ function createSupabaseStockStore(tenantId) {
1051
+ return new SupabaseStockStore(tenantId);
1052
+ }
1053
+
1054
+ // src/inventory/mock-stock-store.ts
1055
+ var TENANT = "mock-tenant";
1056
+ var counter = 0;
1057
+ function nextId(prefix) {
1058
+ counter += 1;
1059
+ return `${prefix}-${String(counter).padStart(4, "0")}`;
1060
+ }
1061
+ var ISO = "2026-01-01T00:00:00.000Z";
1062
+ var MockStockStore = class {
1063
+ constructor(seed = {}) {
1064
+ this.locations = [];
1065
+ this.lots = [];
1066
+ this.movements = [];
1067
+ this.reservations = [];
1068
+ this.variants = /* @__PURE__ */ new Map();
1069
+ this.tenantId = seed.tenantId ?? TENANT;
1070
+ const seededLocations = seed.locations ?? [];
1071
+ if (!seededLocations.some((l) => l.isDefault)) {
1072
+ this.locations.push(this.makeLocation({ code: "default", name: "Estoque principal", isDefault: true }));
1073
+ }
1074
+ for (const location of seededLocations) this.locations.push(this.makeLocation(location));
1075
+ for (const variant of seed.variants ?? []) {
1076
+ this.variants.set(variant.id, {
1077
+ id: variant.id,
1078
+ tenantId: variant.tenantId ?? this.tenantId,
1079
+ productId: variant.productId ?? null,
1080
+ tracking: variant.tracking ?? DEFAULT_STOCK_TRACKING,
1081
+ inventoryCount: variant.inventoryCount ?? 0
1082
+ });
1083
+ }
1084
+ for (const lot of seed.lots ?? []) {
1085
+ this.lots.push({
1086
+ id: lot.id ?? nextId("lot"),
1087
+ tenantId: lot.tenantId ?? this.tenantId,
1088
+ variantId: lot.variantId ?? "",
1089
+ lotCode: lot.lotCode ?? nextId("LOT"),
1090
+ serial: lot.serial ?? null,
1091
+ manufacturedAt: lot.manufacturedAt ?? null,
1092
+ expiresAt: lot.expiresAt ?? null,
1093
+ receivedAt: lot.receivedAt ?? ISO,
1094
+ unitCost: lot.unitCost ?? null,
1095
+ supplierRef: lot.supplierRef ?? null,
1096
+ status: lot.status ?? "active",
1097
+ createdAt: ISO,
1098
+ updatedAt: ISO
1099
+ });
1100
+ }
1101
+ }
1102
+ makeLocation(partial) {
1103
+ return {
1104
+ id: partial.id ?? nextId("loc"),
1105
+ tenantId: partial.tenantId ?? this.tenantId,
1106
+ code: partial.code ?? nextId("code"),
1107
+ name: partial.name ?? "Local",
1108
+ kind: partial.kind ?? "store",
1109
+ isSellable: partial.isSellable ?? true,
1110
+ isDefault: partial.isDefault ?? false,
1111
+ status: partial.status ?? "active",
1112
+ createdAt: ISO,
1113
+ updatedAt: ISO
1114
+ };
1115
+ }
1116
+ // --- reads ------------------------------------------------------------------
1117
+ async listLocations() {
1118
+ return [...this.locations].sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
1119
+ }
1120
+ async getDefaultLocation() {
1121
+ return this.locations.find((l) => l.isDefault) ?? null;
1122
+ }
1123
+ async listBalances(options = {}) {
1124
+ const replayed = replayStockLedger(this.movements, this.reservations);
1125
+ const sellable = new Set(this.locations.filter((l) => l.isSellable && l.status === "active").map((l) => l.id));
1126
+ const rows = [];
1127
+ for (const [key, row] of replayed) {
1128
+ if (options.variantId && row.variantId !== options.variantId) continue;
1129
+ if (options.variantIds && !options.variantIds.includes(row.variantId)) continue;
1130
+ if (options.locationId && row.locationId !== options.locationId) continue;
1131
+ if (options.lotId && row.lotId !== options.lotId) continue;
1132
+ if (options.sellableOnly && !sellable.has(row.locationId)) continue;
1133
+ rows.push({
1134
+ id: key,
1135
+ tenantId: row.tenantId || this.tenantId,
1136
+ variantId: row.variantId,
1137
+ locationId: row.locationId,
1138
+ lotId: row.lotId,
1139
+ onHand: row.onHand,
1140
+ reserved: row.reserved,
1141
+ available: row.onHand - row.reserved,
1142
+ updatedAt: ISO
1143
+ });
1144
+ }
1145
+ return rows.sort((a, b) => a.id.localeCompare(b.id));
1146
+ }
1147
+ async getAvailable(variantId) {
1148
+ const balances = await this.listBalances({ variantId });
1149
+ return mirroredInventoryCount(balances, this.sellableLocationIds());
1150
+ }
1151
+ async listLots(options = {}) {
1152
+ let rows = [...this.lots];
1153
+ if (options.variantId) rows = rows.filter((l) => l.variantId === options.variantId);
1154
+ if (options.status) rows = rows.filter((l) => l.status === options.status);
1155
+ if (options.expiresBefore) {
1156
+ rows = rows.filter((l) => l.expiresAt !== null && l.expiresAt <= options.expiresBefore);
1157
+ }
1158
+ if (options.fefo !== false) {
1159
+ rows.sort((a, b) => {
1160
+ if (a.expiresAt === b.expiresAt) return a.receivedAt.localeCompare(b.receivedAt);
1161
+ if (a.expiresAt === null) return 1;
1162
+ if (b.expiresAt === null) return -1;
1163
+ return a.expiresAt.localeCompare(b.expiresAt);
1164
+ });
1165
+ }
1166
+ return options.limit != null ? rows.slice(0, options.limit) : rows;
1167
+ }
1168
+ async listMovements(options = {}) {
1169
+ let rows = [...this.movements];
1170
+ if (options.variantId) rows = rows.filter((m) => m.variantId === options.variantId);
1171
+ if (options.lotId) rows = rows.filter((m) => m.lotId === options.lotId);
1172
+ if (options.orderId) rows = rows.filter((m) => m.orderId === options.orderId);
1173
+ if (options.kind) rows = rows.filter((m) => m.kind === options.kind);
1174
+ if (options.reasonCode) rows = rows.filter((m) => m.reasonCode === options.reasonCode);
1175
+ if (options.newestFirst !== false) rows.reverse();
1176
+ return options.limit != null ? rows.slice(0, options.limit) : rows;
1177
+ }
1178
+ async listReservations(variantId) {
1179
+ return variantId ? this.reservations.filter((r) => r.variantId === variantId) : [...this.reservations];
1180
+ }
1181
+ // --- append -----------------------------------------------------------------
1182
+ async recordMovement(input) {
1183
+ if (input.idempotencyKey) {
1184
+ const existing = this.movements.find((m) => m.idempotencyKey === input.idempotencyKey);
1185
+ if (existing) return existing;
1186
+ }
1187
+ const variant = this.variants.get(input.variantId);
1188
+ if (!variant) {
1189
+ throw new StockMovementError(`stock movement: variant ${input.variantId} not found`, input);
1190
+ }
1191
+ const lot = input.lotId ? this.lots.find((l) => l.id === input.lotId) ?? null : null;
1192
+ try {
1193
+ validateStockMovement(input, {
1194
+ tracking: variant.tracking,
1195
+ lot: lot ? { id: lot.id, variantId: lot.variantId, serial: lot.serial } : null,
1196
+ // Summed over EVERY location: a serial is one physical unit in the
1197
+ // whole store, so moving it between locations must not let a second
1198
+ // copy appear. Same predicate as the SQL validator's SELECT.
1199
+ lotOnHand: lot ? await this.lotOnHand(lot.id) : void 0
1200
+ });
1201
+ } catch (error) {
1202
+ throw new StockMovementError(error.message, input);
1203
+ }
1204
+ const locationId = input.locationId ?? (await this.getDefaultLocation()).id;
1205
+ if (!this.locations.some((l) => l.id === locationId)) {
1206
+ throw new StockMovementError(
1207
+ `stock movement: location ${locationId} does not belong to store ${this.tenantId}`,
1208
+ input
1209
+ );
1210
+ }
1211
+ const movement = {
1212
+ id: nextId("mov"),
1213
+ tenantId: variant.tenantId,
1214
+ variantId: variant.id,
1215
+ productId: variant.productId,
1216
+ locationId,
1217
+ lotId: input.lotId ?? null,
1218
+ kind: input.kind,
1219
+ reasonCode: input.reasonCode ?? null,
1220
+ quantity: input.quantity,
1221
+ unitCost: input.unitCost ?? null,
1222
+ orderId: input.orderId ?? null,
1223
+ reservationId: input.reservationId ?? null,
1224
+ idempotencyKey: input.idempotencyKey ?? null,
1225
+ occurredAt: input.occurredAt ?? ISO,
1226
+ createdBy: null,
1227
+ note: input.note ?? null,
1228
+ createdAt: ISO
1229
+ };
1230
+ this.movements.push(movement);
1231
+ await this.syncMirror(variant.id);
1232
+ return movement;
1233
+ }
1234
+ // --- allocation and the reservation lifecycle (§9 step 4) ---------------------
1235
+ /**
1236
+ * Runs the SHARED allocateStock() over the mock's own balances and lots, so
1237
+ * "fefo picked the right lot" is asserted against the same ordering the SQL
1238
+ * executes rather than against a convenient imitation.
1239
+ */
1240
+ async allocate(request) {
1241
+ const variant = this.variants.get(request.variantId);
1242
+ if (!variant) {
1243
+ throw new StockMovementError(`stock movement: variant ${request.variantId} not found`, {
1244
+ variantId: request.variantId,
1245
+ kind: "sale",
1246
+ quantity: -request.quantity
1247
+ });
1248
+ }
1249
+ await this.sweepExpiredReservations(request.variantId);
1250
+ return allocateStock(
1251
+ { ...request, tracking: variant.tracking },
1252
+ await this.allocationCandidates(request.variantId, variant.tracking)
1253
+ );
1254
+ }
1255
+ async allocationCandidates(variantId, tracking) {
1256
+ const balances = await this.listBalances({ variantId });
1257
+ const byId = new Map(this.locations.map((l) => [l.id, l]));
1258
+ return balances.map((balance) => {
1259
+ const lot = balance.lotId ? this.lots.find((l) => l.id === balance.lotId) : null;
1260
+ const location = byId.get(balance.locationId);
1261
+ return {
1262
+ lotId: balance.lotId,
1263
+ locationId: balance.locationId,
1264
+ available: balance.onHand - balance.reserved,
1265
+ expiresAt: lot?.expiresAt ?? null,
1266
+ // A quantity-tracked variant's implicit lot has no receipt date of its
1267
+ // own; the ledger's own order stands in, and with one position it never
1268
+ // ties anything.
1269
+ receivedAt: lot?.receivedAt ?? ISO,
1270
+ isSellableLocation: (location?.isSellable ?? true) && (location?.status ?? "active") === "active",
1271
+ isDefaultLocation: location?.isDefault ?? false
1272
+ };
1273
+ }).filter((candidate) => tracking === "quantity" ? candidate.lotId == null : true);
1274
+ }
1275
+ async reserve(input) {
1276
+ if (input.idempotencyKey) {
1277
+ const existing = this.reservations.find((r) => r.idempotencyKey === input.idempotencyKey);
1278
+ if (existing) return existing;
1279
+ }
1280
+ return this.addReservation({
1281
+ variantId: input.variantId,
1282
+ locationId: input.locationId,
1283
+ lotId: input.lotId ?? null,
1284
+ quantity: input.quantity,
1285
+ status: "held",
1286
+ orderId: input.orderId ?? null,
1287
+ cartToken: input.cartToken ?? null,
1288
+ expiresAt: input.ttlMinutes == null ? null : new Date(Date.now() + input.ttlMinutes * 6e4).toISOString(),
1289
+ idempotencyKey: input.idempotencyKey ?? null,
1290
+ createdBy: null
1291
+ });
1292
+ }
1293
+ async sweepExpiredReservations(variantId) {
1294
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1295
+ let swept = 0;
1296
+ for (const reservation of this.reservations) {
1297
+ if (reservation.status !== "held") continue;
1298
+ if (variantId && reservation.variantId !== variantId) continue;
1299
+ if (!reservation.expiresAt || reservation.expiresAt > now) continue;
1300
+ reservation.status = "released";
1301
+ swept += 1;
1302
+ await this.syncMirror(reservation.variantId);
1303
+ }
1304
+ return swept;
1305
+ }
1306
+ // --- the parts a database would do for us ------------------------------------
1307
+ /** on_hand for one lot across every location — the serial-bound predicate. */
1308
+ async lotOnHand(lotId) {
1309
+ const rows = await this.listBalances({ lotId });
1310
+ return rows.reduce((sum, row) => sum + row.onHand, 0);
1311
+ }
1312
+ sellableLocationIds() {
1313
+ return new Set(this.locations.filter((l) => l.isSellable && l.status === "active").map((l) => l.id));
1314
+ }
1315
+ /** The 0036 mirror, in memory. */
1316
+ async syncMirror(variantId) {
1317
+ const variant = this.variants.get(variantId);
1318
+ if (!variant) return;
1319
+ if (variant.tracking === "none") return;
1320
+ const balances = await this.listBalances({ variantId });
1321
+ if (balances.length === 0) return;
1322
+ variant.inventoryCount = mirroredInventoryCount(balances, this.sellableLocationIds());
1323
+ }
1324
+ /** The mirror column, for assertions. */
1325
+ getInventoryCount(variantId) {
1326
+ return this.variants.get(variantId)?.inventoryCount;
1327
+ }
1328
+ /**
1329
+ * The 0036 I10 shim: a direct write to `inventory_count` is allowed, logged
1330
+ * and TRANSLATED into an adjustment movement carrying the DELTA, so the
1331
+ * ledger never diverges from the column every current writer still uses.
1332
+ * Returns the warning the SQL trigger would have raised.
1333
+ */
1334
+ async writeInventoryCountDirectly(variantId, value) {
1335
+ const variant = this.variants.get(variantId);
1336
+ if (!variant) throw new Error(`variant ${variantId} not found`);
1337
+ const delta = value - variant.inventoryCount;
1338
+ if (delta === 0) return null;
1339
+ if (variant.tracking === "none") {
1340
+ variant.inventoryCount = value;
1341
+ return null;
1342
+ }
1343
+ if (variant.tracking === "lot" || variant.tracking === "serial") {
1344
+ throw new StockMovementError(
1345
+ `plg_shop_product_variants.inventory_count is a derived mirror: variant ${variantId} is ${variant.tracking}-tracked, so a direct write cannot say which lot it means.`,
1346
+ { variantId, kind: "adjustment", quantity: delta }
1347
+ );
1348
+ }
1349
+ const warning = `DEPRECATED (I10): direct write to plg_shop_product_variants.inventory_count on variant ${variantId} (${variant.inventoryCount} -> ${value}). Translated to an adjustment movement; write plg_shop_stock_movements instead.`;
1350
+ variant.inventoryCount = value;
1351
+ await this.recordMovement({
1352
+ variantId,
1353
+ kind: "adjustment",
1354
+ reasonCode: "legacy_write",
1355
+ quantity: delta
1356
+ });
1357
+ return warning;
1358
+ }
1359
+ /** Rebuild balances by replaying the ledger — proof the balance is a cache. */
1360
+ async rebuildBalances() {
1361
+ return this.listBalances();
1362
+ }
1363
+ /**
1364
+ * Seed a hold. The lifecycle itself is §9 step 4; this exists so
1365
+ * `available = onHand − reserved` is provable today.
1366
+ *
1367
+ * async, and the mirror is AWAITED rather than fired and forgotten: a
1368
+ * floating promise here meant `getInventoryCount()` read the pre-hold value
1369
+ * on the very next line, which is the assertion a reservation test makes.
1370
+ */
1371
+ async addReservation(reservation) {
1372
+ const row = {
1373
+ // Spread FIRST: spreading last let an explicit `{ id: undefined }` win
1374
+ // over the generated id and produced a reservation with no identity.
1375
+ ...reservation,
1376
+ id: reservation.id ?? nextId("res"),
1377
+ tenantId: this.tenantId,
1378
+ createdAt: ISO,
1379
+ updatedAt: ISO
1380
+ };
1381
+ this.reservations.push(row);
1382
+ await this.syncMirror(row.variantId);
1383
+ return row;
1384
+ }
1385
+ /** Move a hold through its lifecycle, re-projecting the mirror. */
1386
+ async setReservationStatus(id, status) {
1387
+ const row = this.reservations.find((r) => r.id === id);
1388
+ if (!row) throw new Error(`reservation ${id} not found`);
1389
+ row.status = status;
1390
+ await this.syncMirror(row.variantId);
1391
+ }
1392
+ /** Every ledger row, in insertion order. Nothing is ever removed from it. */
1393
+ get ledger() {
1394
+ return this.movements;
1395
+ }
1396
+ /** The stock the variant was created with, unchanged by the mock. */
1397
+ getTracking(variantId) {
1398
+ return this.variants.get(variantId)?.tracking;
1399
+ }
1400
+ /** Flip a variant's tracking policy (an admin action), for tests. */
1401
+ setTracking(variantId, tracking) {
1402
+ const variant = this.variants.get(variantId);
1403
+ if (!variant) throw new Error(`variant ${variantId} not found`);
1404
+ variant.tracking = tracking;
1405
+ }
1406
+ };
1407
+ /** The balance key helper, re-exported for assertions that group by it. */
1408
+ MockStockStore.key = stockBalanceKey;
1409
+ function createMockStockStore(seed = {}) {
1410
+ return new MockStockStore(seed);
1411
+ }
1412
+
1413
+ export { BEYOND_SHOPIFY, CORE_RESERVATION_TTL_MINUTES, CORE_STOCK_ALLOCATION_STRATEGIES, CORE_STOCK_MOVEMENT_KINDS, CORE_STOCK_REASON_CODES, DEFAULT_STOCK_TRACKING, LEGACY_WRITE_REASON, MockStockStore, OPENING_BALANCE_REASON, SHOPIFY_ADJUSTMENT_REASONS, SHOPIFY_DEFAULT_OPTION_NAME, SHOPIFY_DEFAULT_VARIANT_TITLE, SHOPIFY_QUANTITY_STATES, STOCK_LOCATION_KINDS, STOCK_LOT_STATUSES, STOCK_MOVEMENT_DIRECTIONS, STOCK_RESERVATION_STATUSES, STOCK_TRACKING_MODES, StockAllocationError, StockMovementError, SupabaseStockStore, T, TRACKING_MODES_BEYOND_SHOPIFY, allocateStock, clearStockAllocationStrategies, clearStockStores, clearStockVocabulary, compareStockAllocationCandidates, createMockStockStore, createSupabaseStockStore, getRegisteredStockStore, listStockAllocationStrategies, listStockAllocationStrategyKeys, listStockMovementKindKeys, listStockMovementKinds, listStockReasonCodeKeys, listStockReasonCodes, mirroredInventoryCount, registerCoreStockAllocationStrategies, registerCoreStockVocabulary, registerStockAllocationStrategy, registerStockMovementKind, registerStockReasonCode, registerStockStore, replayStockLedger, resolveAllocationStrategyKey, resolveReservationTtlMinutes, resolveStockAllocationStrategy, resolveStockMovementKind, resolveStockReasonCode, resolveStockStore, shopifyDefaultOptions, shopifyReasonToStockReason, stockAllocationStrategyRegistry, stockBalanceKey, stockMovementKindRegistry, stockReasonCodeRegistry, toShopifyVariantTitle, validateStockMovement };
1414
+ //# sourceMappingURL=chunk-XERLK5LD.js.map
1415
+ //# sourceMappingURL=chunk-XERLK5LD.js.map