@aglyn/plugins-commerce 1.0.0-beta.261 → 1.0.0-beta.263

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 (48) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/console/order-detail-dialog.component.js +17 -45
  3. package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
  4. package/src/lib/components/console/order-fulfillment-panel.component.js +12 -25
  5. package/src/lib/components/console/order-fulfillment-panel.component.js.map +1 -1
  6. package/src/lib/components/console/order-invoice.component.js +2 -10
  7. package/src/lib/components/console/order-invoice.component.js.map +1 -1
  8. package/src/lib/components/console/order-shipping-actions.component.d.ts +1 -15
  9. package/src/lib/components/console/order-shipping-actions.component.js +3 -23
  10. package/src/lib/components/console/order-shipping-actions.component.js.map +1 -1
  11. package/src/lib/components/console/product-labels-dialog.component.d.ts +2 -5
  12. package/src/lib/components/console/product-labels-dialog.component.js +6 -24
  13. package/src/lib/components/console/product-labels-dialog.component.js.map +1 -1
  14. package/src/lib/components/console/products-hub-card.component.js +7 -20
  15. package/src/lib/components/console/products-hub-card.component.js.map +1 -1
  16. package/src/lib/model/commerce-shipping.d.ts +2 -0
  17. package/src/lib/model/commerce-shipping.js +1 -0
  18. package/src/lib/model/commerce-shipping.js.map +1 -1
  19. package/src/lib/model/order-dialog.d.ts +52 -0
  20. package/src/lib/model/order-dialog.js +85 -0
  21. package/src/lib/model/order-dialog.js.map +1 -0
  22. package/src/lib/model/product-write.d.ts +71 -0
  23. package/src/lib/model/product-write.js +28 -0
  24. package/src/lib/model/product-write.js.map +1 -1
  25. package/src/lib/printing/product-labels.d.ts +22 -0
  26. package/src/lib/printing/product-labels.js +39 -2
  27. package/src/lib/printing/product-labels.js.map +1 -1
  28. package/src/lib/server/billing-webhook.js +3 -3
  29. package/src/lib/server/billing-webhook.js.map +1 -1
  30. package/src/lib/server/order-figures.js +37 -0
  31. package/src/lib/server/order-figures.js.map +1 -1
  32. package/src/lib/server/order-print.d.ts +40 -0
  33. package/src/lib/server/order-print.js +113 -0
  34. package/src/lib/server/order-print.js.map +1 -0
  35. package/src/lib/server/products-write.d.ts +26 -0
  36. package/src/lib/server/products-write.js +184 -1
  37. package/src/lib/server/products-write.js.map +1 -1
  38. package/src/lib/server.js +8 -0
  39. package/src/lib/server.js.map +1 -1
  40. package/src/lib/transfer/shipping-presets.d.ts +16 -0
  41. package/src/lib/transfer/shipping-presets.js +34 -1
  42. package/src/lib/transfer/shipping-presets.js.map +1 -1
  43. package/src/lib/utils/order-invoice.d.ts +5 -0
  44. package/src/lib/utils/order-invoice.js +14 -0
  45. package/src/lib/utils/order-invoice.js.map +1 -1
  46. package/src/lib/utils/order-packing-slip.d.ts +31 -0
  47. package/src/lib/utils/order-packing-slip.js +46 -0
  48. package/src/lib/utils/order-packing-slip.js.map +1 -0
@@ -15,6 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { PLUGIN_FIGURE_MAX_ROWS, pluginFigureChange, pluginFigureWindows, registerPluginFigureReader } from "@aglyn/aglyn/plugin-manager/plugin-figures";
18
+ import { figureCount, figureHasColumn, figureRanked, figureStat } from "@aglyn/aglyn/app-utils/weekly-summary";
18
19
  import { BUNDLE_ID } from "../constants/bundle-common.js";
19
20
  import { orderWindowFigures, productSales } from "../model/order-figures.js";
20
21
  /** Orders one window reads; a store past it is told its figures read low. */ export const ORDER_FIGURES_WINDOW_CEILING = 1000;
@@ -73,6 +74,30 @@ export function orderFigureReaders(firestore) {
73
74
  scope: 'site',
74
75
  windows: WINDOWS,
75
76
  feature: 'commerceAnalytics',
77
+ weeklySummary: {
78
+ order: 70,
79
+ summarize: (table)=>{
80
+ const week = table.rows[0];
81
+ if (!week || !figureHasColumn(table, 'revenue')) return null;
82
+ if (!figureCount(week['orders']) && !figureCount(week['revenue'])) return null;
83
+ return {
84
+ title: 'Sales',
85
+ stats: [
86
+ figureStat(table, week, 'revenue', 'Revenue', {
87
+ change: 'revenueChange'
88
+ }),
89
+ figureStat(table, week, 'orders', 'Orders', {
90
+ change: 'ordersChange',
91
+ total: {
92
+ one: 'order',
93
+ many: 'orders'
94
+ }
95
+ }),
96
+ figureStat(table, week, 'averageOrder', 'Average order')
97
+ ]
98
+ };
99
+ }
100
+ },
76
101
  read: async (request)=>{
77
102
  const refused = refusal(request);
78
103
  if (refused) return refused;
@@ -159,6 +184,18 @@ export function orderFigureReaders(firestore) {
159
184
  scope: 'site',
160
185
  windows: WINDOWS,
161
186
  feature: 'commerceAnalytics',
187
+ weeklySummary: {
188
+ order: 80,
189
+ summarize: (table)=>{
190
+ const rows = figureHasColumn(table, 'units') ? figureRanked(table, 'product', 'units') : [];
191
+ return rows.length ? {
192
+ title: 'Top products',
193
+ rows: rows.map((row)=>_extends({}, row, {
194
+ value: `${row.value} sold`
195
+ }))
196
+ } : null;
197
+ }
198
+ },
162
199
  read: async (request)=>{
163
200
  const refused = refusal(request);
164
201
  if (refused) return refused;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/order-figures.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n PLUGIN_FIGURE_MAX_ROWS,\n pluginFigureChange,\n pluginFigureWindows,\n registerPluginFigureReader,\n type PluginFigureReader,\n type PluginFigureRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-figures'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n orderWindowFigures,\n productSales,\n type OrderFigureSource,\n} from '../model/order-figures'\n\n/**\n * The store's sales figures for another plugin to read (AGL-2915): what the\n * Analytics tab's card shows, as tables on the `plugin-figures` seam. The\n * insight job reads them by id and never reads an order.\n *\n * A table carries sums and counts and product names — never a buyer, an\n * address or an order. Sold under `commerceAnalytics`, the entitlement the\n * card is sold under.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Orders one window reads; a store past it is told its figures read low. */\nexport const ORDER_FIGURES_WINDOW_CEILING = 1_000\n\nconst WINDOWS: readonly number[] = [7, 14, 30, 90]\n\nconst SOURCE = { label: 'Store analytics', path: 'products/analytics' }\n\nasync function ordersBetween(\n firestore: Firestore,\n hostId: string,\n startMs: number,\n endMs: number,\n): Promise<{ orders: OrderFigureSource[]; truncated: boolean }> {\n // `createdAtMs` is the field every order writer stamps and the one the\n // orders group is indexed on; range and order on it need no composite index.\n const snapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .where('createdAtMs', '>=', startMs)\n .where('createdAtMs', '<', endMs)\n .orderBy('createdAtMs', 'desc')\n .limit(ORDER_FIGURES_WINDOW_CEILING + 1)\n .get()\n const orders = snapshot.docs.map((doc) => ({ ...(doc.data() ?? {}), $id: doc.id }) as OrderFigureSource)\n return {\n orders: orders.slice(0, ORDER_FIGURES_WINDOW_CEILING),\n truncated: orders.length > ORDER_FIGURES_WINDOW_CEILING,\n }\n}\n\nconst dollars = (cents: number): number => Math.round(cents) / 100\n\nfunction ceilingNote(truncated: boolean): string[] {\n return truncated\n ? [\n `The store took more than ${ORDER_FIGURES_WINDOW_CEILING.toLocaleString('en-US')} orders in a window, so its figures are summed over the most recent ${ORDER_FIGURES_WINDOW_CEILING.toLocaleString('en-US')} and read low.`,\n ]\n : []\n}\n\nfunction refusal(request: PluginFigureRequest) {\n if (!request.hostId) return { ok: false as const, status: 400 as const, error: 'Open a site to read its sales' }\n if (!WINDOWS.includes(request.days)) {\n return { ok: false as const, status: 400 as const, error: 'That window is not one sales are read over' }\n }\n return null\n}\n\nexport function orderFigureReaders(firestore: () => Firestore): PluginFigureReader[] {\n return [\n {\n id: 'commerce.sales',\n label: 'Sales',\n description:\n 'The store’s revenue, orders and average order over the window and the window before it, with the change in revenue and in orders. Pending, canceled and test orders are left out, and refunds come off.',\n scope: 'site',\n windows: WINDOWS,\n feature: 'commerceAnalytics',\n read: async (request) => {\n const refused = refusal(request)\n if (refused) return refused\n const hostId = request.hostId as string\n const { current, previous } = pluginFigureWindows(request.now, request.days)\n const [now, before] = await Promise.all([\n ordersBetween(firestore(), hostId, current.startMs, current.endMs),\n ordersBetween(firestore(), hostId, previous.startMs, previous.endMs),\n ])\n const a = orderWindowFigures(now.orders, current.startMs, current.endMs)\n const b = orderWindowFigures(before.orders, previous.startMs, previous.endMs)\n return {\n ok: true,\n table: {\n title: 'Sales',\n source: SOURCE,\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'window', label: 'Window', kind: 'text' },\n { key: 'revenue', label: 'Revenue', kind: 'money', currency: 'USD' },\n { key: 'orders', label: 'Orders', kind: 'count' },\n { key: 'averageOrder', label: 'Average order', kind: 'money', currency: 'USD' },\n { key: 'revenueChange', label: 'Revenue change', kind: 'change' },\n { key: 'ordersChange', label: 'Orders change', kind: 'change' },\n ],\n rows: [\n {\n window: `${current.from} to ${current.to}`,\n revenue: dollars(a.revenueCents),\n orders: a.orders,\n averageOrder: dollars(a.averageCents),\n revenueChange: pluginFigureChange(a.revenueCents, b.revenueCents),\n ordersChange: pluginFigureChange(a.orders, b.orders),\n },\n {\n window: `${previous.from} to ${previous.to}`,\n revenue: dollars(b.revenueCents),\n orders: b.orders,\n averageOrder: dollars(b.averageCents),\n revenueChange: null,\n ordersChange: null,\n },\n ],\n omitted: 0,\n notes: ceilingNote(now.truncated || before.truncated),\n },\n }\n },\n },\n {\n id: 'commerce.products',\n label: 'Top products',\n description: 'The products that brought in the most over the window, with units sold and line revenue.',\n scope: 'site',\n windows: WINDOWS,\n feature: 'commerceAnalytics',\n read: async (request) => {\n const refused = refusal(request)\n if (refused) return refused\n const { current } = pluginFigureWindows(request.now, request.days)\n const window = await ordersBetween(firestore(), request.hostId as string, current.startMs, current.endMs)\n const sales = productSales(window.orders)\n return {\n ok: true,\n table: {\n title: 'Top products',\n source: SOURCE,\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'product', label: 'Product', kind: 'text' },\n { key: 'units', label: 'Units', kind: 'count' },\n { key: 'revenue', label: 'Line revenue', kind: 'money', currency: 'USD' },\n ],\n rows: sales\n .slice(0, PLUGIN_FIGURE_MAX_ROWS)\n .map((entry) => ({ product: entry.name, units: entry.units, revenue: dollars(entry.cents) })),\n omitted: Math.max(0, sales.length - PLUGIN_FIGURE_MAX_ROWS),\n notes: [\n 'Line revenue is each line’s price times its quantity, before discounts, shipping and tax.',\n ...ceilingNote(window.truncated),\n ],\n },\n }\n },\n },\n ]\n}\n\n/** Registers the sales readers from the console surface, where insight jobs run. */\nexport function registerOrderFigureReaders(firestore: () => Firestore): void {\n for (const reader of orderFigureReaders(firestore)) {\n registerPluginFigureReader(reader, { pluginId: BUNDLE_ID })\n }\n}\n"],"names":["PLUGIN_FIGURE_MAX_ROWS","pluginFigureChange","pluginFigureWindows","registerPluginFigureReader","BUNDLE_ID","orderWindowFigures","productSales","ORDER_FIGURES_WINDOW_CEILING","WINDOWS","SOURCE","label","path","ordersBetween","firestore","hostId","startMs","endMs","snapshot","collection","doc","where","orderBy","limit","get","orders","docs","map","data","$id","id","slice","truncated","length","dollars","cents","Math","round","ceilingNote","toLocaleString","refusal","request","ok","status","error","includes","days","orderFigureReaders","description","scope","windows","feature","read","refused","current","previous","now","before","Promise","all","a","b","table","title","source","period","from","to","columns","key","kind","currency","rows","window","revenue","revenueCents","averageOrder","averageCents","revenueChange","ordersChange","omitted","notes","sales","entry","product","name","units","max","registerOrderFigureReaders","reader","pluginId"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/order-figures.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n PLUGIN_FIGURE_MAX_ROWS,\n pluginFigureChange,\n pluginFigureWindows,\n registerPluginFigureReader,\n type PluginFigureReader,\n type PluginFigureRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-figures'\nimport { figureCount, figureHasColumn, figureRanked, figureStat } from '@aglyn/aglyn/app-utils/weekly-summary'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n orderWindowFigures,\n productSales,\n type OrderFigureSource,\n} from '../model/order-figures'\n\n/**\n * The store's sales figures for another plugin to read (AGL-2915): what the\n * Analytics tab's card shows, as tables on the `plugin-figures` seam. The\n * insight job reads them by id and never reads an order.\n *\n * A table carries sums and counts and product names — never a buyer, an\n * address or an order. Sold under `commerceAnalytics`, the entitlement the\n * card is sold under.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Orders one window reads; a store past it is told its figures read low. */\nexport const ORDER_FIGURES_WINDOW_CEILING = 1_000\n\nconst WINDOWS: readonly number[] = [7, 14, 30, 90]\n\nconst SOURCE = { label: 'Store analytics', path: 'products/analytics' }\n\nasync function ordersBetween(\n firestore: Firestore,\n hostId: string,\n startMs: number,\n endMs: number,\n): Promise<{ orders: OrderFigureSource[]; truncated: boolean }> {\n // `createdAtMs` is the field every order writer stamps and the one the\n // orders group is indexed on; range and order on it need no composite index.\n const snapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .where('createdAtMs', '>=', startMs)\n .where('createdAtMs', '<', endMs)\n .orderBy('createdAtMs', 'desc')\n .limit(ORDER_FIGURES_WINDOW_CEILING + 1)\n .get()\n const orders = snapshot.docs.map((doc) => ({ ...(doc.data() ?? {}), $id: doc.id }) as OrderFigureSource)\n return {\n orders: orders.slice(0, ORDER_FIGURES_WINDOW_CEILING),\n truncated: orders.length > ORDER_FIGURES_WINDOW_CEILING,\n }\n}\n\nconst dollars = (cents: number): number => Math.round(cents) / 100\n\nfunction ceilingNote(truncated: boolean): string[] {\n return truncated\n ? [\n `The store took more than ${ORDER_FIGURES_WINDOW_CEILING.toLocaleString('en-US')} orders in a window, so its figures are summed over the most recent ${ORDER_FIGURES_WINDOW_CEILING.toLocaleString('en-US')} and read low.`,\n ]\n : []\n}\n\nfunction refusal(request: PluginFigureRequest) {\n if (!request.hostId) return { ok: false as const, status: 400 as const, error: 'Open a site to read its sales' }\n if (!WINDOWS.includes(request.days)) {\n return { ok: false as const, status: 400 as const, error: 'That window is not one sales are read over' }\n }\n return null\n}\n\nexport function orderFigureReaders(firestore: () => Firestore): PluginFigureReader[] {\n return [\n {\n id: 'commerce.sales',\n label: 'Sales',\n description:\n 'The store’s revenue, orders and average order over the window and the window before it, with the change in revenue and in orders. Pending, canceled and test orders are left out, and refunds come off.',\n scope: 'site',\n windows: WINDOWS,\n feature: 'commerceAnalytics',\n weeklySummary: {\n order: 70,\n summarize: (table) => {\n const week = table.rows[0]\n if (!week || !figureHasColumn(table, 'revenue')) return null\n if (!figureCount(week['orders']) && !figureCount(week['revenue'])) return null\n return {\n title: 'Sales',\n stats: [\n figureStat(table, week, 'revenue', 'Revenue', { change: 'revenueChange' }),\n figureStat(table, week, 'orders', 'Orders', { change: 'ordersChange', total: { one: 'order', many: 'orders' } }),\n figureStat(table, week, 'averageOrder', 'Average order'),\n ],\n }\n },\n },\n read: async (request) => {\n const refused = refusal(request)\n if (refused) return refused\n const hostId = request.hostId as string\n const { current, previous } = pluginFigureWindows(request.now, request.days)\n const [now, before] = await Promise.all([\n ordersBetween(firestore(), hostId, current.startMs, current.endMs),\n ordersBetween(firestore(), hostId, previous.startMs, previous.endMs),\n ])\n const a = orderWindowFigures(now.orders, current.startMs, current.endMs)\n const b = orderWindowFigures(before.orders, previous.startMs, previous.endMs)\n return {\n ok: true,\n table: {\n title: 'Sales',\n source: SOURCE,\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'window', label: 'Window', kind: 'text' },\n { key: 'revenue', label: 'Revenue', kind: 'money', currency: 'USD' },\n { key: 'orders', label: 'Orders', kind: 'count' },\n { key: 'averageOrder', label: 'Average order', kind: 'money', currency: 'USD' },\n { key: 'revenueChange', label: 'Revenue change', kind: 'change' },\n { key: 'ordersChange', label: 'Orders change', kind: 'change' },\n ],\n rows: [\n {\n window: `${current.from} to ${current.to}`,\n revenue: dollars(a.revenueCents),\n orders: a.orders,\n averageOrder: dollars(a.averageCents),\n revenueChange: pluginFigureChange(a.revenueCents, b.revenueCents),\n ordersChange: pluginFigureChange(a.orders, b.orders),\n },\n {\n window: `${previous.from} to ${previous.to}`,\n revenue: dollars(b.revenueCents),\n orders: b.orders,\n averageOrder: dollars(b.averageCents),\n revenueChange: null,\n ordersChange: null,\n },\n ],\n omitted: 0,\n notes: ceilingNote(now.truncated || before.truncated),\n },\n }\n },\n },\n {\n id: 'commerce.products',\n label: 'Top products',\n description: 'The products that brought in the most over the window, with units sold and line revenue.',\n scope: 'site',\n windows: WINDOWS,\n feature: 'commerceAnalytics',\n weeklySummary: {\n order: 80,\n summarize: (table) => {\n const rows = figureHasColumn(table, 'units') ? figureRanked(table, 'product', 'units') : []\n return rows.length ? { title: 'Top products', rows: rows.map((row) => ({ ...row, value: `${row.value} sold` })) } : null\n },\n },\n read: async (request) => {\n const refused = refusal(request)\n if (refused) return refused\n const { current } = pluginFigureWindows(request.now, request.days)\n const window = await ordersBetween(firestore(), request.hostId as string, current.startMs, current.endMs)\n const sales = productSales(window.orders)\n return {\n ok: true,\n table: {\n title: 'Top products',\n source: SOURCE,\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'product', label: 'Product', kind: 'text' },\n { key: 'units', label: 'Units', kind: 'count' },\n { key: 'revenue', label: 'Line revenue', kind: 'money', currency: 'USD' },\n ],\n rows: sales\n .slice(0, PLUGIN_FIGURE_MAX_ROWS)\n .map((entry) => ({ product: entry.name, units: entry.units, revenue: dollars(entry.cents) })),\n omitted: Math.max(0, sales.length - PLUGIN_FIGURE_MAX_ROWS),\n notes: [\n 'Line revenue is each line’s price times its quantity, before discounts, shipping and tax.',\n ...ceilingNote(window.truncated),\n ],\n },\n }\n },\n },\n ]\n}\n\n/** Registers the sales readers from the console surface, where insight jobs run. */\nexport function registerOrderFigureReaders(firestore: () => Firestore): void {\n for (const reader of orderFigureReaders(firestore)) {\n registerPluginFigureReader(reader, { pluginId: BUNDLE_ID })\n }\n}\n"],"names":["PLUGIN_FIGURE_MAX_ROWS","pluginFigureChange","pluginFigureWindows","registerPluginFigureReader","figureCount","figureHasColumn","figureRanked","figureStat","BUNDLE_ID","orderWindowFigures","productSales","ORDER_FIGURES_WINDOW_CEILING","WINDOWS","SOURCE","label","path","ordersBetween","firestore","hostId","startMs","endMs","snapshot","collection","doc","where","orderBy","limit","get","orders","docs","map","data","$id","id","slice","truncated","length","dollars","cents","Math","round","ceilingNote","toLocaleString","refusal","request","ok","status","error","includes","days","orderFigureReaders","description","scope","windows","feature","weeklySummary","order","summarize","table","week","rows","title","stats","change","total","one","many","read","refused","current","previous","now","before","Promise","all","a","b","source","period","from","to","columns","key","kind","currency","window","revenue","revenueCents","averageOrder","averageCents","revenueChange","ordersChange","omitted","notes","row","value","sales","entry","product","name","units","max","registerOrderFigureReaders","reader","pluginId"],"mappings":"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"}
@@ -0,0 +1,40 @@
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 PluginApiHandler } from '@aglyn/aglyn/server';
18
+ import { type HostOrder } from '../model';
19
+ export type OrderPrintKind = 'invoice' | 'packing-slip';
20
+ export declare const ORDER_PRINT_KINDS: readonly OrderPrintKind[];
21
+ type Firestore = FirebaseFirestore.Firestore;
22
+ export interface OrderPrintDeps {
23
+ firestore?: () => Firestore;
24
+ verifyIdToken?: (token: string) => Promise<Record<string, unknown> & {
25
+ uid: string;
26
+ }>;
27
+ }
28
+ /** The page and its title for one order: what the console's popup is given. */
29
+ export declare function orderPrintPage(kind: OrderPrintKind, input: {
30
+ order: HostOrder;
31
+ orderId: string;
32
+ host?: Record<string, unknown> | null;
33
+ store?: Record<string, unknown> | null;
34
+ }): {
35
+ title: string;
36
+ html: string;
37
+ };
38
+ export declare function createOrderPrintHandler(options?: OrderPrintDeps): PluginApiHandler;
39
+ export declare const orderPrintHandler: PluginApiHandler;
40
+ export {};
@@ -0,0 +1,113 @@
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
+ */ import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
17
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
18
+ import { formatOrderNumber, liftLegacyOrder } from "../model/index.js";
19
+ import { buildOrderInvoiceHtml, invoiceStoreFrom } from "../utils/order-invoice.js";
20
+ import { buildPackingSlipHtml } from "../utils/order-packing-slip.js";
21
+ import { validId } from "./products-write.js";
22
+ export const ORDER_PRINT_KINDS = [
23
+ 'invoice',
24
+ 'packing-slip'
25
+ ];
26
+ /** The page and its title for one order: what the console's popup is given. */ export function orderPrintPage(kind, input) {
27
+ const number = formatOrderNumber(input.order, input.orderId);
28
+ if (kind === 'packing-slip') {
29
+ return {
30
+ title: `Packing slip ${number}`,
31
+ html: buildPackingSlipHtml(input.order, input.orderId)
32
+ };
33
+ }
34
+ return {
35
+ title: `Invoice ${number}`,
36
+ html: buildOrderInvoiceHtml({
37
+ order: input.order,
38
+ orderId: input.orderId,
39
+ store: invoiceStoreFrom(input.host, input.store)
40
+ })
41
+ };
42
+ }
43
+ export function createOrderPrintHandler(options = {}) {
44
+ return async (req, res)=>{
45
+ var _options_firestore, _options_verifyIdToken, _req_query_kind, _req_headers_authorization;
46
+ if (req.method !== 'GET') return res.status(405).json({
47
+ error: 'Method not allowed'
48
+ });
49
+ const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : ()=>firebaseAdmin.app().firestore();
50
+ const verifyIdToken = (_options_verifyIdToken = options.verifyIdToken) != null ? _options_verifyIdToken : (token)=>firebaseAdmin.app().auth().verifyIdToken(token);
51
+ const hostId = req.query['hostId'];
52
+ const orderId = req.query['orderId'];
53
+ const kind = String((_req_query_kind = req.query['kind']) != null ? _req_query_kind : '');
54
+ if (!validId(hostId) || !validId(orderId)) return res.status(400).json({
55
+ error: 'Missing site or order'
56
+ });
57
+ if (!ORDER_PRINT_KINDS.includes(kind)) return res.status(400).json({
58
+ error: 'kind must be invoice or packing-slip'
59
+ });
60
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
61
+ const token = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : '';
62
+ if (!token) return res.status(401).json({
63
+ error: 'Unauthenticated'
64
+ });
65
+ try {
66
+ var _host_get, _stored_data, _ref;
67
+ let decoded;
68
+ try {
69
+ decoded = await verifyIdToken(token);
70
+ } catch (error) {
71
+ // Only a refused token is the caller's; an Auth outage is a 500 (AGL-2852).
72
+ if (!isRefusedIdToken(error)) throw error;
73
+ return res.status(401).json({
74
+ error: 'Sign in again to continue'
75
+ });
76
+ }
77
+ const db = firestore();
78
+ const hostRef = db.collection('hosts').doc(hostId);
79
+ const host = await hostRef.get();
80
+ if (!host.exists) return res.status(404).json({
81
+ error: 'Unknown site'
82
+ });
83
+ const role = ((_host_get = host.get('memberRoles')) != null ? _host_get : {})[decoded.uid];
84
+ if (decoded['staff'] !== true && role !== 'admin' && role !== 'editor') {
85
+ return res.status(403).json({
86
+ error: 'Not permitted'
87
+ });
88
+ }
89
+ const [stored, store] = await Promise.all([
90
+ hostRef.collection('orders').doc(orderId).get(),
91
+ kind === 'invoice' ? hostRef.collection('settings').doc('store').get() : Promise.resolve(null)
92
+ ]);
93
+ if (!stored.exists) return res.status(404).json({
94
+ error: 'This order no longer exists'
95
+ });
96
+ const order = liftLegacyOrder((_stored_data = stored.data()) != null ? _stored_data : {});
97
+ return res.status(200).json(orderPrintPage(kind, {
98
+ order,
99
+ orderId,
100
+ host: host.data(),
101
+ store: (_ref = store == null ? void 0 : store.data()) != null ? _ref : null
102
+ }));
103
+ } catch (error) {
104
+ console.error('[commerce/order-print]', error);
105
+ return res.status(500).json({
106
+ error: 'The page could not be made'
107
+ });
108
+ }
109
+ };
110
+ }
111
+ export const orderPrintHandler = createOrderPrintHandler();
112
+
113
+ //# sourceMappingURL=order-print.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/order-print.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { formatOrderNumber, liftLegacyOrder, type HostOrder } from '../model'\nimport { buildOrderInvoiceHtml, invoiceStoreFrom } from '../utils/order-invoice'\nimport { buildPackingSlipHtml } from '../utils/order-packing-slip'\nimport { validId } from './products-write'\n\n/*\n * `GET /api/commerce/order-print?hostId&orderId&kind` (AGL-3841): the order\n * dialog's \"Packing slip\" and \"Invoice\" for the native apps, which print them\n * through the device's own print sheet.\n *\n * The console builds both pages in the browser from the order it is showing\n * (`utils/order-packing-slip.ts`, `utils/order-invoice.ts`) and prints them\n * from a popup. This route runs the same two builders over the stored order,\n * the site and its store settings, and answers the markup with the title the\n * popup has, so an app prints the console's own page rather than a copy of\n * its layout.\n *\n * Gate: who may read the order (`canReadHostSensitive` in the rules: the\n * site's `admin` and `editor`), or staff. A read, so a frozen site still\n * prints.\n */\n\nexport type OrderPrintKind = 'invoice' | 'packing-slip'\n\nexport const ORDER_PRINT_KINDS: readonly OrderPrintKind[] = ['invoice', 'packing-slip']\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface OrderPrintDeps {\n firestore?: () => Firestore\n verifyIdToken?: (token: string) => Promise<Record<string, unknown> & { uid: string }>\n}\n\n/** The page and its title for one order: what the console's popup is given. */\nexport function orderPrintPage(\n kind: OrderPrintKind,\n input: {\n order: HostOrder\n orderId: string\n host?: Record<string, unknown> | null\n store?: Record<string, unknown> | null\n },\n): { title: string; html: string } {\n const number = formatOrderNumber(input.order, input.orderId)\n if (kind === 'packing-slip') {\n return { title: `Packing slip ${number}`, html: buildPackingSlipHtml(input.order, input.orderId) }\n }\n return {\n title: `Invoice ${number}`,\n html: buildOrderInvoiceHtml({\n order: input.order,\n orderId: input.orderId,\n store: invoiceStoreFrom(input.host, input.store),\n }),\n }\n}\n\nexport function createOrderPrintHandler(options: OrderPrintDeps = {}): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'GET') return res.status(405).json({ error: 'Method not allowed' })\n const firestore = options.firestore ?? (() => firebaseAdmin.app().firestore() as unknown as Firestore)\n const verifyIdToken =\n options.verifyIdToken ?? ((token: string) => firebaseAdmin.app().auth().verifyIdToken(token) as never)\n const hostId = req.query['hostId']\n const orderId = req.query['orderId']\n const kind = String(req.query['kind'] ?? '') as OrderPrintKind\n if (!validId(hostId) || !validId(orderId)) return res.status(400).json({ error: 'Missing site or order' })\n if (!ORDER_PRINT_KINDS.includes(kind)) return res.status(400).json({ error: 'kind must be invoice or packing-slip' })\n const authorization = String(req.headers.authorization ?? '')\n const token = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : ''\n if (!token) return res.status(401).json({ error: 'Unauthenticated' })\n try {\n let decoded: Record<string, unknown> & { uid: string }\n try {\n decoded = await verifyIdToken(token)\n } catch (error) {\n // Only a refused token is the caller's; an Auth outage is a 500 (AGL-2852).\n if (!isRefusedIdToken(error)) throw error\n return res.status(401).json({ error: 'Sign in again to continue' })\n }\n const db = firestore()\n const hostRef = db.collection('hosts').doc(hostId)\n const host = await hostRef.get()\n if (!host.exists) return res.status(404).json({ error: 'Unknown site' })\n const role = (host.get('memberRoles') ?? {})[decoded.uid]\n if (decoded['staff'] !== true && role !== 'admin' && role !== 'editor') {\n return res.status(403).json({ error: 'Not permitted' })\n }\n const [stored, store] = await Promise.all([\n hostRef.collection('orders').doc(orderId).get(),\n kind === 'invoice' ? hostRef.collection('settings').doc('store').get() : Promise.resolve(null),\n ])\n if (!stored.exists) return res.status(404).json({ error: 'This order no longer exists' })\n const order = liftLegacyOrder((stored.data() ?? {}) as never)\n return res.status(200).json(\n orderPrintPage(kind, {\n order,\n orderId,\n host: host.data() as Record<string, unknown>,\n store: (store?.data() ?? null) as Record<string, unknown> | null,\n }),\n )\n } catch (error) {\n console.error('[commerce/order-print]', error)\n return res.status(500).json({ error: 'The page could not be made' })\n }\n }\n}\n\nexport const orderPrintHandler = createOrderPrintHandler()\n"],"names":["isRefusedIdToken","firebaseAdmin","formatOrderNumber","liftLegacyOrder","buildOrderInvoiceHtml","invoiceStoreFrom","buildPackingSlipHtml","validId","ORDER_PRINT_KINDS","orderPrintPage","kind","input","number","order","orderId","title","html","store","host","createOrderPrintHandler","options","req","res","method","status","json","error","firestore","app","verifyIdToken","token","auth","hostId","query","String","includes","authorization","headers","startsWith","slice","length","stored","decoded","db","hostRef","collection","doc","get","exists","role","uid","Promise","all","resolve","data","console","orderPrintHandler"],"mappings":"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"}
@@ -85,6 +85,32 @@ export declare function stockAttemptId(uid: string, key: string): string;
85
85
  * it and moves nothing twice.
86
86
  */
87
87
  export declare function createProductStockHandler(options?: ProductsWriteDeps): PluginApiHandler;
88
+ /**
89
+ * The first slug in `base`, `base-2`, `base-3`… no product of the site holds,
90
+ * as the console's slug ledger claims one (`product-slugs.ts`): a second
91
+ * Duplicate of the same product does not mint a second `-copy` (AGL-3321).
92
+ */
93
+ export declare function claimProductSlug(products: FirebaseFirestore.CollectionReference, base: string): Promise<string>;
94
+ /**
95
+ * `POST /api/commerce/products/duplicate` (AGL-3841): the products list's
96
+ * Duplicate for the native apps. The console builds the copy in the browser
97
+ * (`productDuplicateFields`, its slug ledger, the copy's smart collections)
98
+ * and creates it through the quota-enforcing resources route; this runs the
99
+ * same model over the stored product and creates it under the save route's
100
+ * create door (the plan's commerce entitlement and its product quota, counted
101
+ * in the same transaction). `newProductId` is the client's, so a retry finds
102
+ * its own copy and answers `replayed`.
103
+ */
104
+ export declare function createProductDuplicateHandler(options?: ProductsWriteDeps): PluginApiHandler;
105
+ /**
106
+ * `POST /api/commerce/products/delete` (AGL-3841): the products list's Delete
107
+ * for the native apps, the console's write (`deletedAt` on the product, which
108
+ * stops it being purchasable) with the site-wide cache drop, under the save
109
+ * route's gate. Deleting a deleted product answers `already`.
110
+ */
111
+ export declare function createProductDeleteHandler(options?: ProductsWriteDeps): PluginApiHandler;
88
112
  export declare const productSaveHandler: PluginApiHandler;
89
113
  export declare const productStockHandler: PluginApiHandler;
114
+ export declare const productDuplicateHandler: PluginApiHandler;
115
+ export declare const productDeleteHandler: PluginApiHandler;
90
116
  export {};
@@ -21,7 +21,7 @@ import { hostRoleCanWrite } from "@aglyn/aglyn/app-utils/organizations";
21
21
  import { dropPluginSiteCache } from "@aglyn/aglyn/plugin-manager/plugin-site-cache";
22
22
  import { firebaseAdmin, getLockdownVerdict, getOrgForHost, isImpersonationSession } from "@aglyn/tenant-data-admin";
23
23
  import { FieldValue } from "firebase-admin/firestore";
24
- import { productCollectionIds, productEditWrite, productMembershipInput, productSaveFields, smartCollectionRulesOf, stockAdjustmentWrite, validateProduct } from "../model/index.js";
24
+ import { liftLegacyProduct, productCollectionIds, productDuplicateFields, productDuplicateName, productDuplicateSlugBase, productEditWrite, productMembershipInput, productSaveFields, smartCollectionRulesOf, stockAdjustmentWrite, validateProduct } from "../model/index.js";
25
25
  import { planRefusal, roomRefusal } from "./product-drafts.js";
26
26
  export const ROLE_REFUSAL = 'Editing requires the editor role';
27
27
  const MAX_BODY_BYTES = 256000;
@@ -369,7 +369,190 @@ export function resolved(deps) {
369
369
  }
370
370
  };
371
371
  }
372
+ /**
373
+ * The first slug in `base`, `base-2`, `base-3`… no product of the site holds,
374
+ * as the console's slug ledger claims one (`product-slugs.ts`): a second
375
+ * Duplicate of the same product does not mint a second `-copy` (AGL-3321).
376
+ */ export async function claimProductSlug(products, base) {
377
+ for(let suffix = 1; suffix < 1000; suffix += 1){
378
+ const candidate = suffix < 2 ? base : `${base}-${suffix}`;
379
+ const held = await products.where('slug', '==', candidate).limit(1).get();
380
+ if (held.empty) return candidate;
381
+ }
382
+ return `${base}-${Date.now()}`;
383
+ }
384
+ /**
385
+ * `POST /api/commerce/products/duplicate` (AGL-3841): the products list's
386
+ * Duplicate for the native apps. The console builds the copy in the browser
387
+ * (`productDuplicateFields`, its slug ledger, the copy's smart collections)
388
+ * and creates it through the quota-enforcing resources route; this runs the
389
+ * same model over the stored product and creates it under the save route's
390
+ * create door (the plan's commerce entitlement and its product quota, counted
391
+ * in the same transaction). `newProductId` is the client's, so a retry finds
392
+ * its own copy and answers `replayed`.
393
+ */ export function createProductDuplicateHandler(options = {}) {
394
+ return async (req, res)=>{
395
+ if (req.method !== 'POST') return res.status(405).json({
396
+ error: 'Method not allowed'
397
+ });
398
+ const deps = resolved(options);
399
+ const body = bodyOf(req);
400
+ const { hostId, productId, newProductId } = body;
401
+ if (!validId(hostId) || !validId(productId) || !validId(newProductId) || productId === newProductId) {
402
+ return res.status(400).json({
403
+ error: 'Missing site or product'
404
+ });
405
+ }
406
+ try {
407
+ var _source_data;
408
+ const door = await gate(req, res, hostId, deps);
409
+ if (!door) return;
410
+ const db = deps.firestore();
411
+ const hostRef = db.collection('hosts').doc(hostId);
412
+ const products = hostRef.collection('products');
413
+ const copyRef = products.doc(newProductId);
414
+ const already = await copyRef.get();
415
+ if (already.exists) return res.status(200).json({
416
+ ok: true,
417
+ id: newProductId,
418
+ replayed: true
419
+ });
420
+ const source = await products.doc(productId).get();
421
+ if (!source.exists || source.get('deletedAt')) return res.status(404).json({
422
+ error: 'This product was deleted'
423
+ });
424
+ const product = liftLegacyProduct((_source_data = source.data()) != null ? _source_data : {});
425
+ const slug = await claimProductSlug(products, productDuplicateSlugBase(product.slug, product.name));
426
+ const nowMs = deps.now();
427
+ const fields = productDuplicateFields(product, slug, nowMs);
428
+ const smartQuery = hostRef.collection('collections').where('kind', '==', 'catalog').where('mode', '==', 'smart');
429
+ const outbox = outboxEntry(db, hostId);
430
+ const answer = await db.runTransaction(async (tx)=>{
431
+ const [existing, smart] = await Promise.all([
432
+ tx.get(copyRef),
433
+ tx.get(smartQuery)
434
+ ]);
435
+ if (existing.exists) return {
436
+ status: 200,
437
+ body: {
438
+ ok: true,
439
+ id: newProductId,
440
+ replayed: true
441
+ }
442
+ };
443
+ const early = planRefusal(door.org);
444
+ if (early) return {
445
+ status: early.status,
446
+ body: {
447
+ error: early.error
448
+ }
449
+ };
450
+ const used = (await tx.get(products.count())).data().count;
451
+ const room = roomRefusal(door.org, Number(used) || 0);
452
+ if (room) return {
453
+ status: room.status,
454
+ body: {
455
+ error: room.error
456
+ }
457
+ };
458
+ // The COPY's smart collections: its name differs from the source's.
459
+ const collectionIds = productCollectionIds(productMembershipInput({
460
+ name: productDuplicateName(product.name)
461
+ }, product), smart.docs.map((doc)=>smartCollectionRulesOf(doc.id, doc.data())));
462
+ tx.create(copyRef, _extends({}, fields, {
463
+ collectionIds,
464
+ deletedAt: null,
465
+ createdAt: FieldValue.serverTimestamp(),
466
+ updatedAt: FieldValue.serverTimestamp(),
467
+ createdBy: door.uid
468
+ }));
469
+ tx.set(outbox.ref, outbox.data);
470
+ return {
471
+ status: 200,
472
+ body: {
473
+ ok: true,
474
+ id: newProductId,
475
+ replayed: false,
476
+ slug
477
+ }
478
+ };
479
+ });
480
+ if (answer.status === 200 && !answer.body.replayed) {
481
+ await settleCache(deps, hostId, outbox.ref, 'product duplicated from the app');
482
+ }
483
+ return res.status(answer.status).json(answer.body);
484
+ } catch (error) {
485
+ console.error('[commerce/products/duplicate]', error);
486
+ return res.status(500).json({
487
+ error: 'Could not duplicate product'
488
+ });
489
+ }
490
+ };
491
+ }
492
+ /**
493
+ * `POST /api/commerce/products/delete` (AGL-3841): the products list's Delete
494
+ * for the native apps, the console's write (`deletedAt` on the product, which
495
+ * stops it being purchasable) with the site-wide cache drop, under the save
496
+ * route's gate. Deleting a deleted product answers `already`.
497
+ */ export function createProductDeleteHandler(options = {}) {
498
+ return async (req, res)=>{
499
+ if (req.method !== 'POST') return res.status(405).json({
500
+ error: 'Method not allowed'
501
+ });
502
+ const deps = resolved(options);
503
+ const body = bodyOf(req);
504
+ const { hostId, productId } = body;
505
+ if (!validId(hostId) || !validId(productId)) return res.status(400).json({
506
+ error: 'Missing site or product'
507
+ });
508
+ try {
509
+ const door = await gate(req, res, hostId, deps);
510
+ if (!door) return;
511
+ const db = deps.firestore();
512
+ const productRef = db.collection('hosts').doc(hostId).collection('products').doc(productId);
513
+ const outbox = outboxEntry(db, hostId);
514
+ const answer = await db.runTransaction(async (tx)=>{
515
+ const stored = await tx.get(productRef);
516
+ if (!stored.exists) return {
517
+ status: 404,
518
+ body: {
519
+ error: 'This product no longer exists'
520
+ }
521
+ };
522
+ if (stored.get('deletedAt')) return {
523
+ status: 200,
524
+ body: {
525
+ ok: true,
526
+ already: true
527
+ }
528
+ };
529
+ tx.update(productRef, {
530
+ deletedAt: FieldValue.serverTimestamp()
531
+ });
532
+ tx.set(outbox.ref, outbox.data);
533
+ return {
534
+ status: 200,
535
+ body: {
536
+ ok: true,
537
+ already: false
538
+ }
539
+ };
540
+ });
541
+ if (answer.status === 200 && !answer.body.already) {
542
+ await settleCache(deps, hostId, outbox.ref, 'product deleted from the app');
543
+ }
544
+ return res.status(answer.status).json(answer.body);
545
+ } catch (error) {
546
+ console.error('[commerce/products/delete]', error);
547
+ return res.status(500).json({
548
+ error: 'Could not delete product'
549
+ });
550
+ }
551
+ };
552
+ }
372
553
  export const productSaveHandler = createProductSaveHandler();
373
554
  export const productStockHandler = createProductStockHandler();
555
+ export const productDuplicateHandler = createProductDuplicateHandler();
556
+ export const productDeleteHandler = createProductDeleteHandler();
374
557
 
375
558
  //# sourceMappingURL=products-write.js.map