@aglyn/plugins-commerce 1.0.0-beta.231 → 1.0.0-beta.232

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 (56) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/console/catalog-organization-card.component.js +1 -0
  3. package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
  4. package/src/lib/components/console/host-orders-card.component.js +4 -1
  5. package/src/lib/components/console/host-orders-card.component.js.map +1 -1
  6. package/src/lib/components/console/pos-page.component.js +56 -26
  7. package/src/lib/components/console/pos-page.component.js.map +1 -1
  8. package/src/lib/components/console/pos-zones.d.ts +23 -0
  9. package/src/lib/components/console/pos-zones.js +31 -0
  10. package/src/lib/components/console/pos-zones.js.map +1 -0
  11. package/src/lib/components/console/product-editor-dialog.component.js +10 -64
  12. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  13. package/src/lib/components/console/products-hub-card.component.js +11 -20
  14. package/src/lib/components/console/products-hub-card.component.js.map +1 -1
  15. package/src/lib/components/console/storefront-tax-summary-card.component.js +1 -0
  16. package/src/lib/components/console/storefront-tax-summary-card.component.js.map +1 -1
  17. package/src/lib/declarations.server.js +21 -0
  18. package/src/lib/declarations.server.js.map +1 -1
  19. package/src/lib/model/commerce-orders.d.ts +44 -1
  20. package/src/lib/model/commerce-orders.js +2 -1
  21. package/src/lib/model/commerce-orders.js.map +1 -1
  22. package/src/lib/model/commerce.d.ts +4 -1
  23. package/src/lib/model/commerce.js.map +1 -1
  24. package/src/lib/model/index.d.ts +1 -0
  25. package/src/lib/model/index.js +1 -0
  26. package/src/lib/model/index.js.map +1 -1
  27. package/src/lib/model/product-write.d.ts +186 -0
  28. package/src/lib/model/product-write.js +116 -0
  29. package/src/lib/model/product-write.js.map +1 -0
  30. package/src/lib/plugin.js +3 -0
  31. package/src/lib/plugin.js.map +1 -1
  32. package/src/lib/server/channel-order-updates.d.ts +50 -0
  33. package/src/lib/server/channel-order-updates.js +290 -0
  34. package/src/lib/server/channel-order-updates.js.map +1 -0
  35. package/src/lib/server/channel-orders.d.ts +85 -0
  36. package/src/lib/server/channel-orders.js +543 -0
  37. package/src/lib/server/channel-orders.js.map +1 -0
  38. package/src/lib/server/product-catalog.js +5 -1
  39. package/src/lib/server/product-catalog.js.map +1 -1
  40. package/src/lib/server/product-drafts.d.ts +5 -1
  41. package/src/lib/server/product-drafts.js +3 -3
  42. package/src/lib/server/product-drafts.js.map +1 -1
  43. package/src/lib/server/product-writer.d.ts +19 -0
  44. package/src/lib/server/product-writer.js +261 -0
  45. package/src/lib/server/product-writer.js.map +1 -0
  46. package/src/lib/server/products-write.d.ts +61 -0
  47. package/src/lib/server/products-write.js +375 -0
  48. package/src/lib/server/products-write.js.map +1 -0
  49. package/src/lib/server.js +5 -0
  50. package/src/lib/server.js.map +1 -1
  51. package/src/lib/testing/memory-firestore.d.ts +16 -3
  52. package/src/lib/testing/memory-firestore.js +11 -3
  53. package/src/lib/testing/memory-firestore.js.map +1 -1
  54. package/src/lib/transfer/products.server.d.ts +5 -0
  55. package/src/lib/transfer/products.server.js +2 -2
  56. package/src/lib/transfer/products.server.js.map +1 -1
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginChannelOrder, PluginChannelOrderCancelOutcome, PluginChannelOrderFee, PluginChannelOrderOutcome, PluginChannelOrders } from '@aglyn/aglyn/plugin-manager/plugin-channel-orders';
18
+ /**
19
+ * ORDERS SOLD ON ANOTHER CHANNEL (AGL-3638): commerce's implementation of
20
+ * core's `core.channel-orders`, which a marketplace plugin records each
21
+ * order it brings in through.
22
+ *
23
+ * ## One transaction, so two channels cannot sell the last unit twice
24
+ *
25
+ * The order, its number, every product's count, every count's ledger row and
26
+ * the `order.paid` event are ONE Firestore transaction. Each product is read
27
+ * with `transaction.get`, which serializes this write with every other sale
28
+ * of that product (the AGL-2320 reasoning): a storefront checkout landing at
29
+ * the same moment decrements from the count this write left, never from the
30
+ * one it read. The marketplace plugin then pushes the new count to every
31
+ * other channel.
32
+ *
33
+ * A marketplace order is already sold and paid when it arrives, so a shelf
34
+ * that cannot cover it does not refuse it: the units the shelf held are
35
+ * taken, the order is recorded whole, and the merchant is told which units
36
+ * are short — the same loud oversell `reserve-stock.ts` raises.
37
+ *
38
+ * ## Once per outside order
39
+ *
40
+ * The order's id is derived from the channel and its order id
41
+ * ({@link channelOrderDocId}), and the transaction refuses to write over an
42
+ * order that exists, so a retried import answers `already` and moves nothing.
43
+ *
44
+ * ## No money moves here
45
+ *
46
+ * The buyer paid the channel. `totals.feeCents` — Aglyn's own take — is 0,
47
+ * the channel's charges are recorded on `channelSource.fees` for the
48
+ * merchant's books, the tax is the channel's to remit, and refunds happen on
49
+ * the channel. No buyer email is sent: a marketplace forbids contacting its
50
+ * buyers outside it, and none of them hands over a real address.
51
+ */
52
+ type Firestore = FirebaseFirestore.Firestore;
53
+ /**
54
+ * The order document's id for one outside order: the channel, then a hash of
55
+ * the channel's own id. Deterministic, so a second import of the same order
56
+ * finds the first; hashed, because a channel's id may hold characters a
57
+ * document id may not.
58
+ */
59
+ export declare function channelOrderDocId(channelId: string, externalOrderId: string): string;
60
+ /** What a seller refuses before reading anything, in the merchant's words; `null` when the order is well formed. */
61
+ export declare function channelOrderProblem(order: PluginChannelOrder): string | null;
62
+ export interface ChannelOrderDeps {
63
+ firestore: () => Firestore;
64
+ now: () => number;
65
+ }
66
+ /** Records one outside order; see the module header. */
67
+ export declare function importChannelOrder(order: PluginChannelOrder, deps?: ChannelOrderDeps): Promise<PluginChannelOrderOutcome>;
68
+ /**
69
+ * The channel canceled an order it sent: the order is cancelled here, and
70
+ * the units its sale took go back on the shelf — bounded by its own ledger,
71
+ * exactly as the console's cancel bounds them. Refuses once anything shipped.
72
+ */
73
+ export declare function cancelChannelOrder(request: {
74
+ hostId: string;
75
+ recordId: string;
76
+ reason: string;
77
+ }, deps?: ChannelOrderDeps): Promise<PluginChannelOrderCancelOutcome>;
78
+ /** The channel's charges on a sale, recorded once it says; a later answer replaces an earlier one. */
79
+ export declare function recordChannelOrderFees(request: {
80
+ hostId: string;
81
+ recordId: string;
82
+ fees: PluginChannelOrderFee[];
83
+ }, deps?: ChannelOrderDeps): Promise<'recorded' | 'no_such_record'>;
84
+ export declare const commerceChannelOrders: PluginChannelOrders;
85
+ export {};
@@ -0,0 +1,543 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
3
+ /**
4
+ * @license
5
+ * Copyright 2026 Aglyn LLC
6
+ *
7
+ * Licensed under the Apache License, Version 2.0 (the "License");
8
+ * you may not use this file except in compliance with the License.
9
+ * You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ */ import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
19
+ import { notifyHostManagers } from "@aglyn/tenant-data-admin";
20
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
21
+ import { createHash } from "node:crypto";
22
+ import * as CommerceModel from "../model/index.js";
23
+ import { ORDER_CANCELLED_EVENT, ORDER_PAID_EVENT } from "../model/order-events.js";
24
+ import { alertLowStockCrossing } from "./low-stock.js";
25
+ import { stageOrderEvent } from "./order-events.js";
26
+ import { capRestockLines, resolveTrackedRestockLines, saleReleaseCaps } from "./restock-flag.js";
27
+ import { completeChannelOrder, recordChannelOrderRefund } from "./channel-order-updates.js";
28
+ const DOC_ID = /^[A-Za-z0-9_-]{1,200}$/;
29
+ const CHANNEL_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
30
+ const MAX_LINES = 250;
31
+ const MAX_UNITS = 10000;
32
+ const MAX_CENTS = 10000000000;
33
+ /**
34
+ * The order document's id for one outside order: the channel, then a hash of
35
+ * the channel's own id. Deterministic, so a second import of the same order
36
+ * finds the first; hashed, because a channel's id may hold characters a
37
+ * document id may not.
38
+ */ export function channelOrderDocId(channelId, externalOrderId) {
39
+ const digest = createHash('sha256').update(`${channelId}\u0000${externalOrderId}`).digest('hex').slice(0, 24);
40
+ return `${channelId}-${digest}`;
41
+ }
42
+ const whole = (value, max)=>{
43
+ const number = Number(value);
44
+ return Number.isInteger(number) && number >= 0 && number <= max ? number : null;
45
+ };
46
+ const clip = (value, max)=>String(value != null ? value : '').trim().slice(0, max);
47
+ /** What a seller refuses before reading anything, in the merchant's words; `null` when the order is well formed. */ export function channelOrderProblem(order) {
48
+ var _ref, _ref1, _order_currency;
49
+ var _order_channel;
50
+ if (!DOC_ID.test(String((_ref = order == null ? void 0 : order.hostId) != null ? _ref : '')) || /^__.*__$/.test(order.hostId)) return 'The order names no site.';
51
+ if (!CHANNEL_ID.test(String((_ref1 = (_order_channel = order.channel) == null ? void 0 : _order_channel.id) != null ? _ref1 : ''))) return 'The order names no channel.';
52
+ if (!clip(order.externalOrderId, 200)) return 'The order has no id on its channel.';
53
+ if (!/^[A-Za-z]{3}$/.test(String((_order_currency = order.currency) != null ? _order_currency : ''))) return 'The order names no currency.';
54
+ const lines = Array.isArray(order.lines) ? order.lines : [];
55
+ if (!lines.length) return 'The order has no items.';
56
+ if (lines.length > MAX_LINES) return `The order has more than ${MAX_LINES} items.`;
57
+ for (const line of lines){
58
+ if (whole(line == null ? void 0 : line.quantity, MAX_UNITS) === null || Number(line.quantity) < 1) return 'An item of the order has no quantity.';
59
+ if (whole(line == null ? void 0 : line.unitPriceCents, MAX_CENTS) === null) return 'An item of the order has no price.';
60
+ if (!clip(line == null ? void 0 : line.externalLineId, 200)) return 'An item of the order has no id on its channel.';
61
+ }
62
+ for (const amount of [
63
+ order.shippingCents,
64
+ order.taxCents,
65
+ order.discountCents,
66
+ order.totalCents
67
+ ]){
68
+ if (whole(amount, MAX_CENTS) === null) return 'The order’s totals are not whole amounts.';
69
+ }
70
+ return null;
71
+ }
72
+ const cleanFees = (fees)=>{
73
+ if (!Array.isArray(fees)) return {
74
+ fees: null,
75
+ totalCents: null
76
+ };
77
+ const kept = fees.map((fee)=>({
78
+ label: clip(fee == null ? void 0 : fee.label, 80) || 'Fee',
79
+ amountCents: whole(fee == null ? void 0 : fee.amountCents, MAX_CENTS)
80
+ })).filter((fee)=>fee.amountCents !== null).slice(0, 20);
81
+ return {
82
+ fees: kept,
83
+ totalCents: kept.reduce((sum, fee)=>sum + fee.amountCents, 0)
84
+ };
85
+ };
86
+ const address = (value)=>{
87
+ if (!value) return null;
88
+ const out = {};
89
+ for (const key of [
90
+ 'name',
91
+ 'line1',
92
+ 'line2',
93
+ 'city',
94
+ 'state',
95
+ 'postalCode',
96
+ 'country',
97
+ 'phone'
98
+ ]){
99
+ const text = clip(value[key], 200);
100
+ if (text) out[key] = key === 'country' ? text.toUpperCase() : text;
101
+ }
102
+ return Object.keys(out).length ? out : null;
103
+ };
104
+ /** The live variant whose SKU is `sku`, matched without regard to case; `null` when none or several products claim it. */ async function locateSku(firestore, hostId, sku) {
105
+ const wanted = sku.trim().toLowerCase();
106
+ if (!wanted) return null;
107
+ const snapshot = await firestore.collection('hosts').doc(hostId).collection('products').where('skus', 'array-contains', wanted).limit(5).get();
108
+ const found = [];
109
+ for (const doc of snapshot.docs){
110
+ if (doc.get('deletedAt')) continue;
111
+ const variants = Array.isArray(doc.get('variants')) ? doc.get('variants') : [];
112
+ const variant = variants.find((entry)=>{
113
+ var _ref;
114
+ return String((_ref = entry == null ? void 0 : entry['sku']) != null ? _ref : '').trim().toLowerCase() === wanted && (entry == null ? void 0 : entry['id']);
115
+ });
116
+ if (variant) found.push({
117
+ productId: doc.id,
118
+ variantId: String(variant['id'])
119
+ });
120
+ }
121
+ // Two products sharing a SKU is ambiguous; taking either's stock would be a guess.
122
+ return found.length === 1 ? found[0] : null;
123
+ }
124
+ const defaultDeps = ()=>({
125
+ firestore: ()=>firebaseAdmin.app().firestore(),
126
+ now: Date.now
127
+ });
128
+ /** Records one outside order; see the module header. */ export async function importChannelOrder(order, deps = defaultDeps()) {
129
+ var _existing_data;
130
+ const problem = channelOrderProblem(order);
131
+ if (problem) return {
132
+ outcome: 'refused',
133
+ reason: problem
134
+ };
135
+ const firestore = deps.firestore();
136
+ const hostRef = firestore.collection('hosts').doc(order.hostId);
137
+ const orderId = channelOrderDocId(order.channel.id, clip(order.externalOrderId, 200));
138
+ const orderRef = hostRef.collection('orders').doc(orderId);
139
+ const channelLabel = clip(order.channel.label, 60) || order.channel.id;
140
+ // Already recorded: answered before any product is looked up.
141
+ const existing = await orderRef.get();
142
+ if (existing.exists) return alreadyOutcome(orderId, (_existing_data = existing.data()) != null ? _existing_data : {});
143
+ const store = await hostRef.collection('settings').doc('store').get();
144
+ const storeCurrency = clip(store.get('currency'), 3).toUpperCase() || 'USD';
145
+ const currency = order.currency.toUpperCase();
146
+ if (currency !== storeCurrency) {
147
+ return {
148
+ outcome: 'refused',
149
+ reason: `This order is in ${currency}, and the store sells in ${storeCurrency}. Ship it from ${channelLabel}.`
150
+ };
151
+ }
152
+ // Each line's product, found before the transaction (a query cannot run
153
+ // inside one) and checked again inside it, where the stock is decided.
154
+ const located = [];
155
+ for (const line of order.lines){
156
+ const productId = clip(line.productId, 200);
157
+ const variantId = clip(line.variantId, 200);
158
+ if (productId && variantId && DOC_ID.test(productId) && !/^__.*__$/.test(productId)) {
159
+ located.push({
160
+ productId,
161
+ variantId
162
+ });
163
+ } else {
164
+ located.push(line.sku ? await locateSku(firestore, order.hostId, line.sku) : null);
165
+ }
166
+ }
167
+ const nowMs = deps.now();
168
+ const placedAtMs = Number.isFinite(order.placedAtMs) && order.placedAtMs > 0 ? Math.min(order.placedAtMs, nowMs) : nowMs;
169
+ const productIds = [
170
+ ...new Set(located.filter((entry)=>entry !== null).map((entry)=>entry.productId))
171
+ ];
172
+ const crossings = [];
173
+ const result = await firestore.runTransaction(async (transaction)=>{
174
+ var _again_data, _counter_get;
175
+ const counterRef = hostRef.collection('counters').doc('orders');
176
+ const [again, counter, ...productSnaps] = await Promise.all([
177
+ transaction.get(orderRef),
178
+ transaction.get(counterRef),
179
+ ...productIds.map((productId)=>transaction.get(hostRef.collection('products').doc(productId)))
180
+ ]);
181
+ if (again.exists) return {
182
+ kind: 'already',
183
+ data: (_again_data = again.data()) != null ? _again_data : {}
184
+ };
185
+ const products = new Map();
186
+ productSnaps.forEach((snapshot, index)=>{
187
+ if (!snapshot.exists || snapshot.get('deletedAt')) return;
188
+ const lifted = CommerceModel.liftLegacyProduct(snapshot.data());
189
+ products.set(productIds[index], {
190
+ before: lifted,
191
+ current: lifted
192
+ });
193
+ });
194
+ const lineItems = [];
195
+ const shortfalls = [];
196
+ const unmatched = [];
197
+ const ledger = [];
198
+ order.lines.forEach((line, lineIndex)=>{
199
+ var _variant_options, _product_type;
200
+ const quantity = Number(line.quantity);
201
+ const place = located[lineIndex];
202
+ const held = place ? products.get(place.productId) : undefined;
203
+ const variant = held == null ? void 0 : held.current.variants.find((entry)=>entry.id === (place == null ? void 0 : place.variantId));
204
+ if (!place || !held || !variant) {
205
+ unmatched.push(lineIndex);
206
+ lineItems.push(_extends({
207
+ productId: '',
208
+ name: clip(line.name, 200) || clip(line.sku, 120) || 'Item'
209
+ }, line.sku ? {
210
+ sku: clip(line.sku, 120)
211
+ } : {}, {
212
+ productType: 'physical',
213
+ quantity,
214
+ unitAmountCents: Number(line.unitPriceCents)
215
+ }));
216
+ return;
217
+ }
218
+ const product = held.current;
219
+ const label = Object.values((_variant_options = variant.options) != null ? _variant_options : {}).filter(Boolean).join(' / ');
220
+ lineItems.push(_extends({
221
+ productId: place.productId,
222
+ variantId: variant.id,
223
+ name: product.name
224
+ }, label ? {
225
+ variantLabel: label
226
+ } : {}, variant.sku ? {
227
+ sku: variant.sku
228
+ } : line.sku ? {
229
+ sku: clip(line.sku, 120)
230
+ } : {}, {
231
+ productType: (_product_type = product.type) != null ? _product_type : 'physical',
232
+ quantity,
233
+ unitAmountCents: Number(line.unitPriceCents)
234
+ }));
235
+ if (variant.inventory == null) return;
236
+ const requested = -quantity;
237
+ const applied = CommerceModel.appliedVariantInventoryDelta(product, variant.id, requested);
238
+ const variants = CommerceModel.adjustVariantInventory(product, variant.id, requested);
239
+ held.current = _extends({}, product, {
240
+ variants
241
+ });
242
+ ledger.push(_extends({
243
+ productId: place.productId,
244
+ variantId: variant.id,
245
+ delta: requested
246
+ }, applied !== requested ? {
247
+ appliedDelta: applied
248
+ } : {}, {
249
+ reason: 'sale',
250
+ orderId,
251
+ source: channelLabel,
252
+ atMs: nowMs
253
+ }));
254
+ const short = applied - requested;
255
+ if (short > 0 && product.oversellPolicy !== 'backorder') {
256
+ var _ref, _variant_sku;
257
+ shortfalls.push({
258
+ lineIndex,
259
+ sku: (_ref = (_variant_sku = variant.sku) != null ? _variant_sku : line.sku) != null ? _ref : null,
260
+ short
261
+ });
262
+ }
263
+ });
264
+ const number = Number((_counter_get = counter.get('next')) != null ? _counter_get : 1);
265
+ transaction.set(counterRef, {
266
+ next: number + 1
267
+ }, {
268
+ merge: true
269
+ });
270
+ for (const [productId, held] of products){
271
+ if (held.current === held.before) continue;
272
+ transaction.set(hostRef.collection('products').doc(productId), _extends({
273
+ variants: held.current.variants
274
+ }, CommerceModel.productStockFields(held.current), {
275
+ updatedAtMs: nowMs
276
+ }), {
277
+ merge: true
278
+ });
279
+ crossings.push({
280
+ before: held.before,
281
+ after: held.current
282
+ });
283
+ }
284
+ for (const row of ledger)transaction.set(hostRef.collection('inventoryAdjustments').doc(createResourceUid()), row);
285
+ const itemsCents = lineItems.reduce((sum, line)=>sum + line.unitAmountCents * line.quantity, 0);
286
+ const fees = cleanFees(order.fees);
287
+ const lines = order.lines.map((line, lineIndex)=>({
288
+ lineIndex,
289
+ externalLineId: clip(line.externalLineId, 200)
290
+ }));
291
+ const externalRef = clip(order.externalRef, 120) || clip(order.externalOrderId, 120);
292
+ const shipTo = address(order.shippingAddress);
293
+ const timeline = [
294
+ {
295
+ atMs: nowMs,
296
+ event: 'paid',
297
+ detail: `Sold on ${channelLabel}, order ${externalRef}`
298
+ },
299
+ ...unmatched.length ? [
300
+ {
301
+ atMs: nowMs,
302
+ event: 'line-unmatched',
303
+ detail: `${unmatched.length} ${unmatched.length === 1 ? 'item matches' : 'items match'} no product's SKU, ` + 'so no stock was taken for ' + `${unmatched.length === 1 ? 'it' : 'them'}.`
304
+ }
305
+ ] : [],
306
+ ...shortfalls.length ? [
307
+ {
308
+ atMs: nowMs,
309
+ event: 'oversold',
310
+ detail: `${shortfalls.reduce((sum, entry)=>sum + entry.short, 0)} sold beyond what was in stock.`
311
+ }
312
+ ] : []
313
+ ];
314
+ const data = CommerceModel.withOrderListFields(orderId, _extends({
315
+ number,
316
+ status: 'paid',
317
+ channel: 'marketplace',
318
+ channelSource: _extends({
319
+ channelId: order.channel.id,
320
+ channelLabel,
321
+ externalOrderId: clip(order.externalOrderId, 200),
322
+ externalRef,
323
+ lines,
324
+ currency,
325
+ taxRemittedByChannel: true,
326
+ fees: fees.fees,
327
+ feesTotalCents: fees.totalCents
328
+ }, order.handoff === 'courier' ? {
329
+ handoff: 'courier'
330
+ } : {}),
331
+ lineItems,
332
+ totals: {
333
+ itemsCents,
334
+ shippingCents: Number(order.shippingCents),
335
+ taxCents: Number(order.taxCents),
336
+ discountCents: Number(order.discountCents),
337
+ totalCents: Number(order.totalCents),
338
+ // Aglyn takes nothing from a sale another channel was paid for.
339
+ feeCents: 0
340
+ },
341
+ timeline,
342
+ livemode: order.testMode !== true,
343
+ customerName: clip(order.customerName, 200) || (shipTo == null ? void 0 : shipTo.name) || null,
344
+ customerEmail: null
345
+ }, shipTo ? {
346
+ shippingAddress: shipTo
347
+ } : {}, {
348
+ amountCents: Number(order.totalCents),
349
+ feeCents: 0,
350
+ createdAtMs: placedAtMs,
351
+ importedAtMs: nowMs,
352
+ createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
353
+ }));
354
+ transaction.set(orderRef, data);
355
+ // The event carries the order as stored, less the sentinel only Firestore can read.
356
+ const { createdAt: _sentinel } = data, eventOrder = _object_without_properties_loose(data, [
357
+ "createdAt"
358
+ ]);
359
+ stageOrderEvent(transaction, ORDER_PAID_EVENT, {
360
+ hostId: order.hostId,
361
+ orderId,
362
+ key: 'paid',
363
+ order: eventOrder
364
+ });
365
+ return {
366
+ kind: 'created',
367
+ number,
368
+ lines,
369
+ shortfalls,
370
+ unmatched
371
+ };
372
+ });
373
+ if (result.kind === 'already') return alreadyOutcome(orderId, result.data);
374
+ const displayRef = CommerceModel.formatOrderNumber({
375
+ number: result.number
376
+ }, orderId);
377
+ for (const crossing of crossings)alertLowStockCrossing(order.hostId, crossing.before, crossing.after);
378
+ const totalLabel = `${(Number(order.totalCents) / 100).toFixed(2)} ${currency}`;
379
+ const units = order.lines.reduce((sum, line)=>sum + Number(line.quantity), 0);
380
+ void notifyHostManagers(order.hostId, {
381
+ type: 'content.order',
382
+ title: `New ${channelLabel} order on {site} — ${totalLabel}`,
383
+ body: `Order ${displayRef} came in from ${channelLabel} (their order ${clip(order.externalRef, 120) || order.externalOrderId}): ` + `${units} item${units === 1 ? '' : 's'}, ${totalLabel}. ` + (order.handoff === 'courier' ? `Have it ready for the ${channelLabel} courier.` : `Ship it from the order here and the tracking goes back to ${channelLabel}.`),
384
+ link: `/${order.hostId}/products`
385
+ });
386
+ if (result.shortfalls.length) {
387
+ const short = result.shortfalls.reduce((sum, entry)=>sum + entry.short, 0);
388
+ void notifyHostManagers(order.hostId, {
389
+ type: 'content.lowStock',
390
+ title: `Oversold on ${channelLabel} — order ${displayRef}`,
391
+ body: `${channelLabel} sold ${short} unit${short === 1 ? '' : 's'} more than {site} had in stock ` + `(order ${displayRef}). Restock ${short === 1 ? 'it' : 'them'}, or cancel the items on ${channelLabel}.`,
392
+ link: `/${order.hostId}/products`
393
+ });
394
+ }
395
+ return {
396
+ outcome: 'created',
397
+ recordId: orderId,
398
+ displayRef,
399
+ lines: result.lines,
400
+ shortfalls: result.shortfalls,
401
+ unmatched: result.unmatched
402
+ };
403
+ }
404
+ function alreadyOutcome(recordId, data) {
405
+ const source = data['channelSource'];
406
+ return {
407
+ outcome: 'already',
408
+ recordId,
409
+ displayRef: CommerceModel.formatOrderNumber({
410
+ number: Number(data['number']) || undefined
411
+ }, recordId),
412
+ lines: Array.isArray(source == null ? void 0 : source.lines) ? source.lines : []
413
+ };
414
+ }
415
+ /**
416
+ * The channel canceled an order it sent: the order is cancelled here, and
417
+ * the units its sale took go back on the shelf — bounded by its own ledger,
418
+ * exactly as the console's cancel bounds them. Refuses once anything shipped.
419
+ */ export async function cancelChannelOrder(request, deps = defaultDeps()) {
420
+ var _ref, _ref1;
421
+ if (!DOC_ID.test(String((_ref = request == null ? void 0 : request.hostId) != null ? _ref : '')) || !DOC_ID.test(String((_ref1 = request == null ? void 0 : request.recordId) != null ? _ref1 : ''))) {
422
+ return {
423
+ outcome: 'no_such_record'
424
+ };
425
+ }
426
+ if (/^__.*__$/.test(request.hostId) || /^__.*__$/.test(request.recordId)) return {
427
+ outcome: 'no_such_record'
428
+ };
429
+ const firestore = deps.firestore();
430
+ const hostRef = firestore.collection('hosts').doc(request.hostId);
431
+ const orderRef = hostRef.collection('orders').doc(request.recordId);
432
+ const nowMs = deps.now();
433
+ const outcome = await firestore.runTransaction(async (transaction)=>{
434
+ var _snapshot_data, _order_fulfillments, _snapshot_data1;
435
+ const snapshot = await transaction.get(orderRef);
436
+ if (!snapshot.exists) return {
437
+ outcome: 'no_such_record'
438
+ };
439
+ const order = CommerceModel.liftLegacyOrder((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {});
440
+ if (order.channel !== 'marketplace') return {
441
+ outcome: 'not_cancellable',
442
+ status: order.status
443
+ };
444
+ if (order.status === 'cancelled') return {
445
+ outcome: 'already'
446
+ };
447
+ if (order.status !== 'paid' || ((_order_fulfillments = order.fulfillments) != null ? _order_fulfillments : []).length) {
448
+ return {
449
+ outcome: 'not_cancellable',
450
+ status: order.status
451
+ };
452
+ }
453
+ const rows = await transaction.get(hostRef.collection('inventoryAdjustments').where('orderId', '==', request.recordId));
454
+ const caps = saleReleaseCaps(rows.docs.filter((row)=>row.get('reason') === 'sale'));
455
+ const { lines, products } = await resolveTrackedRestockLines(hostRef, order, (ref)=>transaction.get(ref));
456
+ const byProduct = new Map();
457
+ for (const line of capRestockLines(lines, caps)){
458
+ var _byProduct_get;
459
+ byProduct.set(line.productId, [
460
+ ...(_byProduct_get = byProduct.get(line.productId)) != null ? _byProduct_get : [],
461
+ line
462
+ ]);
463
+ }
464
+ let units = 0;
465
+ for (const [productId, productLines] of byProduct){
466
+ const product = products.get(productId);
467
+ if (!product) continue;
468
+ let variants = product.variants;
469
+ for (const line of productLines){
470
+ variants = CommerceModel.adjustVariantInventory({
471
+ variants
472
+ }, line.variantId, line.quantity);
473
+ units += line.quantity;
474
+ }
475
+ transaction.update(hostRef.collection('products').doc(productId), _extends({
476
+ variants
477
+ }, CommerceModel.productStockFields(_extends({}, product, {
478
+ variants
479
+ })), {
480
+ updatedAtMs: nowMs
481
+ }));
482
+ for (const line of productLines){
483
+ transaction.set(hostRef.collection('inventoryAdjustments').doc(createResourceUid()), {
484
+ productId,
485
+ variantId: line.variantId,
486
+ delta: line.quantity,
487
+ reason: 'cancellation',
488
+ orderId: request.recordId,
489
+ atMs: nowMs
490
+ });
491
+ }
492
+ }
493
+ const reason = clip(request.reason, 200) || 'Canceled on its channel';
494
+ const patch = {
495
+ status: 'cancelled',
496
+ updatedAtMs: nowMs,
497
+ timeline: CommerceModel.appendOrderEvent(order, 'cancelled', units > 0 ? `${reason}. ${units} ${units === 1 ? 'unit' : 'units'} returned to stock.` : reason, nowMs)
498
+ };
499
+ transaction.update(orderRef, patch);
500
+ stageOrderEvent(transaction, ORDER_CANCELLED_EVENT, {
501
+ hostId: request.hostId,
502
+ orderId: request.recordId,
503
+ key: 'cancelled',
504
+ order: _extends({}, (_snapshot_data1 = snapshot.data()) != null ? _snapshot_data1 : {}, patch)
505
+ });
506
+ return {
507
+ outcome: 'cancelled',
508
+ restockedUnits: units
509
+ };
510
+ });
511
+ return outcome;
512
+ }
513
+ /** The channel's charges on a sale, recorded once it says; a later answer replaces an earlier one. */ export async function recordChannelOrderFees(request, deps = defaultDeps()) {
514
+ var _ref, _ref1;
515
+ if (!DOC_ID.test(String((_ref = request == null ? void 0 : request.hostId) != null ? _ref : '')) || !DOC_ID.test(String((_ref1 = request == null ? void 0 : request.recordId) != null ? _ref1 : ''))) return 'no_such_record';
516
+ if (/^__.*__$/.test(request.hostId) || /^__.*__$/.test(request.recordId)) return 'no_such_record';
517
+ const firestore = deps.firestore();
518
+ const orderRef = firestore.collection('hosts').doc(request.hostId).collection('orders').doc(request.recordId);
519
+ const fees = cleanFees(request.fees);
520
+ return firestore.runTransaction(async (transaction)=>{
521
+ const snapshot = await transaction.get(orderRef);
522
+ const source = snapshot.get('channelSource');
523
+ if (!snapshot.exists || snapshot.get('channel') !== 'marketplace' || !source) return 'no_such_record';
524
+ transaction.update(orderRef, {
525
+ channelSource: _extends({}, source, {
526
+ fees: fees.fees,
527
+ feesTotalCents: fees.totalCents
528
+ }),
529
+ updatedAtMs: deps.now()
530
+ });
531
+ return 'recorded';
532
+ });
533
+ }
534
+ export const commerceChannelOrders = {
535
+ importOrder: (order)=>importChannelOrder(order),
536
+ cancelOrder: (request)=>cancelChannelOrder(request),
537
+ recordFees: (request)=>recordChannelOrderFees(request),
538
+ // A courier's pickup and a channel's refund or adjustment (AGL-3644).
539
+ completeOrder: (request)=>completeChannelOrder(request),
540
+ recordRefund: (request)=>recordChannelOrderRefund(request)
541
+ };
542
+
543
+ //# sourceMappingURL=channel-orders.js.map