@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.
- package/package.json +13 -13
- package/src/lib/components/console/order-detail-dialog.component.js +17 -45
- package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
- package/src/lib/components/console/order-fulfillment-panel.component.js +12 -25
- package/src/lib/components/console/order-fulfillment-panel.component.js.map +1 -1
- package/src/lib/components/console/order-invoice.component.js +2 -10
- package/src/lib/components/console/order-invoice.component.js.map +1 -1
- package/src/lib/components/console/order-shipping-actions.component.d.ts +1 -15
- package/src/lib/components/console/order-shipping-actions.component.js +3 -23
- package/src/lib/components/console/order-shipping-actions.component.js.map +1 -1
- package/src/lib/components/console/product-labels-dialog.component.d.ts +2 -5
- package/src/lib/components/console/product-labels-dialog.component.js +6 -24
- package/src/lib/components/console/product-labels-dialog.component.js.map +1 -1
- package/src/lib/components/console/products-hub-card.component.js +7 -20
- package/src/lib/components/console/products-hub-card.component.js.map +1 -1
- package/src/lib/model/commerce-shipping.d.ts +2 -0
- package/src/lib/model/commerce-shipping.js +1 -0
- package/src/lib/model/commerce-shipping.js.map +1 -1
- package/src/lib/model/order-dialog.d.ts +52 -0
- package/src/lib/model/order-dialog.js +85 -0
- package/src/lib/model/order-dialog.js.map +1 -0
- package/src/lib/model/product-write.d.ts +71 -0
- package/src/lib/model/product-write.js +28 -0
- package/src/lib/model/product-write.js.map +1 -1
- package/src/lib/printing/product-labels.d.ts +22 -0
- package/src/lib/printing/product-labels.js +39 -2
- package/src/lib/printing/product-labels.js.map +1 -1
- package/src/lib/server/billing-webhook.js +3 -3
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/order-figures.js +37 -0
- package/src/lib/server/order-figures.js.map +1 -1
- package/src/lib/server/order-print.d.ts +40 -0
- package/src/lib/server/order-print.js +113 -0
- package/src/lib/server/order-print.js.map +1 -0
- package/src/lib/server/products-write.d.ts +26 -0
- package/src/lib/server/products-write.js +184 -1
- package/src/lib/server/products-write.js.map +1 -1
- package/src/lib/server.js +8 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/transfer/shipping-presets.d.ts +16 -0
- package/src/lib/transfer/shipping-presets.js +34 -1
- package/src/lib/transfer/shipping-presets.js.map +1 -1
- package/src/lib/utils/order-invoice.d.ts +5 -0
- package/src/lib/utils/order-invoice.js +14 -0
- package/src/lib/utils/order-invoice.js.map +1 -1
- package/src/lib/utils/order-packing-slip.d.ts +31 -0
- package/src/lib/utils/order-packing-slip.js +46 -0
- 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
|