@base44/app-plugin-commerce 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
  4. package/base44/entities/commerce.Cart.jsonc +73 -0
  5. package/base44/entities/commerce.Coupon.jsonc +113 -0
  6. package/base44/entities/commerce.Customer.jsonc +96 -0
  7. package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
  8. package/base44/entities/commerce.EmailLog.jsonc +43 -0
  9. package/base44/entities/commerce.Order.jsonc +287 -0
  10. package/base44/entities/commerce.OrderNote.jsonc +31 -0
  11. package/base44/entities/commerce.OrderRefund.jsonc +64 -0
  12. package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
  13. package/base44/entities/commerce.Product.jsonc +291 -0
  14. package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
  15. package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
  16. package/base44/entities/commerce.ProductCategory.jsonc +51 -0
  17. package/base44/entities/commerce.ProductReview.jsonc +48 -0
  18. package/base44/entities/commerce.ProductTag.jsonc +30 -0
  19. package/base44/entities/commerce.ProductVariation.jsonc +167 -0
  20. package/base44/entities/commerce.ShippingClass.jsonc +30 -0
  21. package/base44/entities/commerce.ShippingZone.jsonc +41 -0
  22. package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
  23. package/base44/entities/commerce.StoreSettings.jsonc +23 -0
  24. package/base44/entities/commerce.TaxClass.jsonc +23 -0
  25. package/base44/entities/commerce.TaxRate.jsonc +68 -0
  26. package/base44/entities/commerce.Webhook.jsonc +57 -0
  27. package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
  28. package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
  29. package/base44/functions/commerce/admin-customers/entry.ts +141 -0
  30. package/base44/functions/commerce/admin-orders/entry.ts +396 -0
  31. package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
  32. package/base44/functions/commerce/admin-products/entry.ts +506 -0
  33. package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
  34. package/base44/functions/commerce/admin-reports/entry.ts +283 -0
  35. package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
  36. package/base44/functions/commerce/admin-tools/entry.ts +261 -0
  37. package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
  38. package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
  39. package/base44/functions/commerce/payments/entry.ts +238 -0
  40. package/base44/functions/commerce/seed-store/defaults.ts +162 -0
  41. package/base44/functions/commerce/seed-store/entry.ts +310 -0
  42. package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
  43. package/base44/functions/commerce/storefront-account/entry.ts +207 -0
  44. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
  45. package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
  46. package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
  47. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
  48. package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
  49. package/base44/shared/commerce/auth.ts +60 -0
  50. package/base44/shared/commerce/coupons.ts +257 -0
  51. package/base44/shared/commerce/data/continents.ts +75 -0
  52. package/base44/shared/commerce/data/countries.ts +307 -0
  53. package/base44/shared/commerce/data/currencies.ts +46 -0
  54. package/base44/shared/commerce/email-templates.ts +240 -0
  55. package/base44/shared/commerce/emails.ts +225 -0
  56. package/base44/shared/commerce/money.ts +66 -0
  57. package/base44/shared/commerce/orders.ts +251 -0
  58. package/base44/shared/commerce/payments.ts +495 -0
  59. package/base44/shared/commerce/reviews.ts +36 -0
  60. package/base44/shared/commerce/scan.ts +57 -0
  61. package/base44/shared/commerce/sequence.ts +35 -0
  62. package/base44/shared/commerce/settings.ts +57 -0
  63. package/base44/shared/commerce/shipping.ts +215 -0
  64. package/base44/shared/commerce/stock.ts +227 -0
  65. package/base44/shared/commerce/stripe.ts +463 -0
  66. package/base44/shared/commerce/tax.ts +136 -0
  67. package/base44/shared/commerce/totals.ts +314 -0
  68. package/base44/shared/commerce/webhooks.ts +116 -0
  69. package/package.json +37 -0
  70. package/scripts/install.js +156 -0
  71. package/skills/commerce/SKILL.md +62 -0
  72. package/skills/commerce/docs/api-admin.md +186 -0
  73. package/skills/commerce/docs/api-storefront.md +408 -0
  74. package/skills/commerce/installation-guidelines.md +91 -0
  75. package/skills/commerce/post-installation.md +157 -0
  76. package/skills/commerce/references/emails.md +13 -0
  77. package/skills/commerce/references/guest-access-security.md +18 -0
  78. package/skills/commerce/references/limits-and-performance.md +16 -0
  79. package/skills/commerce/references/media-and-downloads.md +4 -0
  80. package/skills/commerce/references/online-payments.md +201 -0
  81. package/skills/commerce/references/product-render.md +87 -0
  82. package/skills/commerce/references/scheduled-work.md +19 -0
  83. package/skills/commerce/references/storefront-product-page.md +83 -0
  84. package/skills/commerce/references/webhooks.md +8 -0
  85. package/src/commerce/admin/README.md +107 -0
  86. package/src/commerce/admin/bot/Markdown.jsx +138 -0
  87. package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
  88. package/src/commerce/admin/bot/pipe-tables.js +116 -0
  89. package/src/commerce/admin/components/AddressForm.jsx +78 -0
  90. package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
  91. package/src/commerce/admin/components/CountrySelect.jsx +81 -0
  92. package/src/commerce/admin/components/DataTable.jsx +192 -0
  93. package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
  94. package/src/commerce/admin/components/EmptyState.jsx +17 -0
  95. package/src/commerce/admin/components/MediaUploader.jsx +116 -0
  96. package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
  97. package/src/commerce/admin/components/MoneyInput.jsx +50 -0
  98. package/src/commerce/admin/components/PageHeader.jsx +29 -0
  99. package/src/commerce/admin/components/RichTextarea.jsx +21 -0
  100. package/src/commerce/admin/components/SearchSelect.jsx +142 -0
  101. package/src/commerce/admin/components/StatusBadge.jsx +17 -0
  102. package/src/commerce/admin/context/BasePathContext.jsx +26 -0
  103. package/src/commerce/admin/context/SettingsContext.jsx +207 -0
  104. package/src/commerce/admin/hooks/useAsync.js +46 -0
  105. package/src/commerce/admin/hooks/useDebounce.js +11 -0
  106. package/src/commerce/admin/hooks/useMoney.js +52 -0
  107. package/src/commerce/admin/hooks/usePagedList.js +83 -0
  108. package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
  109. package/src/commerce/admin/hooks/useRealtime.js +129 -0
  110. package/src/commerce/admin/index.jsx +34 -0
  111. package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
  112. package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
  113. package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
  114. package/src/commerce/admin/layout/Sidebar.jsx +130 -0
  115. package/src/commerce/admin/layout/Topbar.jsx +94 -0
  116. package/src/commerce/admin/lib/api.js +55 -0
  117. package/src/commerce/admin/lib/constants.js +157 -0
  118. package/src/commerce/admin/lib/format.js +27 -0
  119. package/src/commerce/admin/lib/geo-data.js +125 -0
  120. package/src/commerce/admin/lib/order-utils.js +147 -0
  121. package/src/commerce/admin/lib/paths.js +35 -0
  122. package/src/commerce/admin/lib/product-utils.js +55 -0
  123. package/src/commerce/admin/pages/Dashboard.jsx +245 -0
  124. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
  125. package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
  126. package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
  127. package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
  128. package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
  129. package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
  130. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
  131. package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
  132. package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
  133. package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
  134. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
  135. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
  136. package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
  137. package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
  138. package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
  139. package/src/commerce/admin/pages/products/Categories.jsx +236 -0
  140. package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
  141. package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
  142. package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
  143. package/src/commerce/admin/pages/products/Tags.jsx +150 -0
  144. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
  145. package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
  146. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
  147. package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
  148. package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
  149. package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
  150. package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
  151. package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
  152. package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
  153. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
  154. package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
  155. package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
  156. package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
  157. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
  158. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
  159. package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
  160. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
  161. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
  162. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
  163. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
  164. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
  165. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
  166. package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
  167. package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
  168. package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
  169. package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
  170. package/src/commerce/admin/routes.jsx +151 -0
  171. package/src/commerce/utils/index.js +19 -0
  172. package/src/commerce/utils/shipping-promos.js +99 -0
  173. package/src/commerce/utils/variants.js +411 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * commerce/admin-reports — on-demand report aggregation over orders.
3
+ * Fine to ~10k orders; larger stores should materialize stats
4
+ * (see skills/commerce/references/limits-and-performance.md).
5
+ *
6
+ * Actions: summary | sales | top-sellers | stock |
7
+ * orders-totals | products-totals | customers-totals | coupons-totals |
8
+ * reviews-totals | categories-totals | tags-totals | attributes-totals
9
+ */
10
+ import { createClientFromRequest } from "npm:@base44/sdk";
11
+ import { HttpError, requireAdmin } from "../../../shared/commerce/auth.ts";
12
+ import { round2 } from "../../../shared/commerce/money.ts";
13
+ import { getSettings } from "../../../shared/commerce/settings.ts";
14
+ import { scanAll } from "../../../shared/commerce/scan.ts";
15
+
16
+ const ok = (data: unknown, status = 200) => Response.json({ success: true, data }, { status });
17
+ const fail = (status: number, error: string, code?: string) =>
18
+ Response.json({ success: false, error, code }, { status });
19
+
20
+ Deno.serve(async (req) => {
21
+ try {
22
+ const base44 = createClientFromRequest(req);
23
+ await requireAdmin(base44);
24
+ const sr = base44.asServiceRole;
25
+ const { action, ...payload } = await req.json();
26
+
27
+ switch (action) {
28
+ case "summary": return ok(await summary(sr));
29
+ case "sales": return ok(await sales(sr, payload));
30
+ case "top-sellers": return ok(await topSellers(sr, payload));
31
+ case "stock": return ok(await stockReport(sr));
32
+ case "orders-totals": return ok(await ordersTotals(sr));
33
+ case "products-totals": return ok(await groupCount(sr.entities["commerce.Product"], "type"));
34
+ case "customers-totals": return ok(await customersTotals(sr));
35
+ case "coupons-totals": return ok(await groupCount(sr.entities["commerce.Coupon"], "discount_type"));
36
+ case "reviews-totals": return ok(await groupCount(sr.entities["commerce.ProductReview"], "status"));
37
+ case "categories-totals": return ok(await termTotals(sr.entities["commerce.ProductCategory"]));
38
+ case "tags-totals": return ok(await termTotals(sr.entities["commerce.ProductTag"]));
39
+ case "attributes-totals": return ok(await attributesTotals(sr));
40
+ default:
41
+ return fail(400, `Unknown action: ${action}`, "unknown_action");
42
+ }
43
+ } catch (e) {
44
+ if (e instanceof HttpError) return fail(e.status, e.message, e.code);
45
+ console.error("commerce/admin-reports error:", e);
46
+ return fail(500, (e as Error).message ?? "Internal error");
47
+ }
48
+ });
49
+
50
+ // ── shared aggregation ───────────────────────────────────────────────────────
51
+
52
+ /** An order counts toward sales when it was paid (standard report semantics). */
53
+ function isCounted(o: any): boolean {
54
+ return !!o.date_paid || ["processing", "completed"].includes(o.status);
55
+ }
56
+
57
+ function inRange(dateIso: string | undefined, min?: string, max?: string): boolean {
58
+ if (!dateIso) return false;
59
+ if (min && dateIso < min) return false;
60
+ if (max && dateIso > `${max}~`) return false; // '~' > any time suffix
61
+ return true;
62
+ }
63
+
64
+ function bucketKey(dateIso: string, interval: string): string {
65
+ const d = new Date(dateIso);
66
+ const y = d.getUTCFullYear();
67
+ const m = String(d.getUTCMonth() + 1).padStart(2, "0");
68
+ const day = String(d.getUTCDate()).padStart(2, "0");
69
+ if (interval === "month") return `${y}-${m}`;
70
+ if (interval === "week") {
71
+ // bucket by the Monday of the week (UTC)
72
+ const monday = new Date(d);
73
+ const dow = (d.getUTCDay() + 6) % 7; // Mon=0
74
+ monday.setUTCDate(d.getUTCDate() - dow);
75
+ return `${monday.getUTCFullYear()}-${String(monday.getUTCMonth() + 1).padStart(2, "0")}-${String(monday.getUTCDate()).padStart(2, "0")}`;
76
+ }
77
+ return `${y}-${m}-${day}`; // day
78
+ }
79
+
80
+ interface SalesAgg {
81
+ gross_sales: number;
82
+ net_sales: number;
83
+ orders: number;
84
+ items: number;
85
+ tax: number;
86
+ shipping: number;
87
+ discount: number;
88
+ refunds: number;
89
+ }
90
+
91
+ const emptyAgg = (): SalesAgg => ({
92
+ gross_sales: 0, net_sales: 0, orders: 0, items: 0, tax: 0, shipping: 0, discount: 0, refunds: 0,
93
+ });
94
+
95
+ function addOrder(agg: SalesAgg, o: any): void {
96
+ agg.gross_sales = round2(agg.gross_sales + (o.total ?? 0));
97
+ agg.orders += 1;
98
+ agg.items += (o.line_items ?? []).reduce((a: number, l: any) => a + (l.quantity ?? 0), 0);
99
+ agg.tax = round2(agg.tax + (o.total_tax ?? 0));
100
+ agg.shipping = round2(agg.shipping + (o.shipping_total ?? 0));
101
+ agg.discount = round2(agg.discount + (o.discount_total ?? 0));
102
+ }
103
+
104
+ function finalizeNet(agg: SalesAgg): void {
105
+ // net = gross − refunds − tax − shipping
106
+ agg.net_sales = round2(agg.gross_sales - agg.refunds - agg.tax - agg.shipping);
107
+ }
108
+
109
+ async function salesAggregation(sr: any, dateMin?: string, dateMax?: string, interval?: string): Promise<{ totals: SalesAgg; series: Array<{ period: string } & SalesAgg> }> {
110
+ const orders = (await scanAll(sr.entities["commerce.Order"])).filter(
111
+ (o) => isCounted(o) && inRange(o.created_date, dateMin, dateMax),
112
+ );
113
+ // refunds counted by refund date (intended behavior)
114
+ const refunds = (await scanAll(sr.entities["commerce.OrderRefund"])).filter(
115
+ (r) => inRange(r.created_date, dateMin, dateMax),
116
+ );
117
+
118
+ const totals = emptyAgg();
119
+ const buckets = new Map<string, SalesAgg>();
120
+ const bucketOf = (dateIso: string) => {
121
+ const key = bucketKey(dateIso, interval ?? "day");
122
+ if (!buckets.has(key)) buckets.set(key, emptyAgg());
123
+ return buckets.get(key)!;
124
+ };
125
+
126
+ for (const o of orders) {
127
+ addOrder(totals, o);
128
+ if (interval) addOrder(bucketOf(o.created_date), o);
129
+ }
130
+ for (const r of refunds) {
131
+ totals.refunds = round2(totals.refunds + (r.amount ?? 0));
132
+ if (interval) {
133
+ const b = bucketOf(r.created_date);
134
+ b.refunds = round2(b.refunds + (r.amount ?? 0));
135
+ }
136
+ }
137
+ finalizeNet(totals);
138
+ const series = [...buckets.entries()]
139
+ .sort(([a], [b]) => a.localeCompare(b))
140
+ .map(([period, agg]) => {
141
+ finalizeNet(agg);
142
+ return { period, ...agg };
143
+ });
144
+ return { totals, series };
145
+ }
146
+
147
+ // ── actions ──────────────────────────────────────────────────────────────────
148
+
149
+ async function summary(sr: any): Promise<any> {
150
+ const now = new Date();
151
+ const today = now.toISOString().slice(0, 10);
152
+ const monthStart = `${now.toISOString().slice(0, 7)}-01`;
153
+
154
+ const [todayAgg, monthAgg, statusAgg, stock, top] = await Promise.all([
155
+ salesAggregation(sr, today, today),
156
+ salesAggregation(sr, monthStart, today),
157
+ ordersTotals(sr),
158
+ stockReport(sr),
159
+ topSellers(sr, { date_min: monthStart, date_max: today, limit: 1 }),
160
+ ]);
161
+
162
+ return {
163
+ sales_today: todayAgg.totals,
164
+ sales_month: monthAgg.totals,
165
+ orders_by_status: statusAgg,
166
+ low_stock_count: stock.low_stock.length,
167
+ out_of_stock_count: stock.out_of_stock.length,
168
+ top_seller: top.rows[0] ?? null,
169
+ };
170
+ }
171
+
172
+ async function sales(sr: any, payload: any): Promise<any> {
173
+ const { date_min, date_max, interval = "day" } = payload;
174
+ return await salesAggregation(sr, date_min, date_max, interval);
175
+ }
176
+
177
+ async function topSellers(sr: any, payload: any): Promise<any> {
178
+ const { date_min, date_max, limit = 10 } = payload;
179
+ const orders = (await scanAll(sr.entities["commerce.Order"])).filter(
180
+ (o) => isCounted(o) && inRange(o.created_date, date_min, date_max),
181
+ );
182
+ const byProduct = new Map<string, { product_id: string; quantity: number; net_revenue: number }>();
183
+ for (const o of orders) {
184
+ for (const l of o.line_items ?? []) {
185
+ if (!l.product_id) continue;
186
+ const cur = byProduct.get(l.product_id) ?? { product_id: l.product_id, quantity: 0, net_revenue: 0 };
187
+ cur.quantity += l.quantity ?? 0;
188
+ cur.net_revenue = round2(cur.net_revenue + (l.total ?? 0));
189
+ byProduct.set(l.product_id, cur);
190
+ }
191
+ }
192
+ const top = [...byProduct.values()]
193
+ .sort((a, b) => b.quantity - a.quantity)
194
+ .slice(0, Number(limit));
195
+ for (const row of top as any[]) {
196
+ try {
197
+ const p = await sr.entities["commerce.Product"].get(row.product_id);
198
+ row.name = p?.name ?? "(deleted product)";
199
+ row.sku = p?.sku ?? "";
200
+ } catch { row.name = "(deleted product)"; }
201
+ }
202
+ return { rows: top };
203
+ }
204
+
205
+ async function stockReport(sr: any): Promise<any> {
206
+ const settings = await getSettings(sr, "inventory");
207
+ const defaultLow = Number(settings.inventory?.low_stock_threshold ?? 2);
208
+ const outThreshold = Number(settings.inventory?.out_of_stock_threshold ?? 0);
209
+
210
+ const products = await scanAll(sr.entities["commerce.Product"]);
211
+ const variations = await scanAll(sr.entities["commerce.ProductVariation"]);
212
+
213
+ const low: any[] = [];
214
+ const out: any[] = [];
215
+ const consider = (rec: any, name: string, isVariation: boolean, parent?: any) => {
216
+ const managed = isVariation ? (rec.manage_stock === "yes") : !!rec.manage_stock;
217
+ const entry = {
218
+ id: rec.id, product_id: isVariation ? rec.product_id : rec.id,
219
+ name, sku: rec.sku ?? "", stock_quantity: rec.stock_quantity ?? null,
220
+ is_variation: isVariation,
221
+ };
222
+ if (managed) {
223
+ const qty = Number(rec.stock_quantity ?? 0);
224
+ const lowAt = Number(rec.low_stock_amount ?? parent?.low_stock_amount ?? defaultLow);
225
+ if (qty <= outThreshold) out.push(entry);
226
+ else if (qty <= lowAt) low.push(entry);
227
+ } else if (rec.stock_status === "outofstock") {
228
+ out.push(entry);
229
+ }
230
+ };
231
+
232
+ const byId = new Map(products.map((p) => [p.id, p]));
233
+ for (const p of products) consider(p, p.name, false);
234
+ for (const v of variations) {
235
+ const parent = byId.get(v.product_id);
236
+ const label = `${parent?.name ?? "?"} — ${(v.attributes ?? []).map((a: any) => a.option).join(" / ")}`;
237
+ consider(v, label, true, parent);
238
+ }
239
+ return { low_stock: low, out_of_stock: out };
240
+ }
241
+
242
+ async function ordersTotals(sr: any): Promise<Record<string, number>> {
243
+ const orders = await scanAll(sr.entities["commerce.Order"], null, "-created_date", { fields: ["id", "status"] });
244
+ const counts: Record<string, number> = {};
245
+ for (const o of orders) counts[o.status] = (counts[o.status] ?? 0) + 1;
246
+ return counts;
247
+ }
248
+
249
+ async function customersTotals(sr: any): Promise<any> {
250
+ const customers = await scanAll(sr.entities["commerce.Customer"], null, "-created_date", { fields: ["id", "is_guest", "is_paying_customer"] });
251
+ return {
252
+ total: customers.length,
253
+ guests: customers.filter((c) => c.is_guest).length,
254
+ registered: customers.filter((c) => !c.is_guest).length,
255
+ paying: customers.filter((c) => c.is_paying_customer).length,
256
+ };
257
+ }
258
+
259
+ async function groupCount(entityApi: any, field: string): Promise<any> {
260
+ const rows = await scanAll(entityApi, null, "-created_date", { fields: ["id", field] });
261
+ const counts: Record<string, number> = {};
262
+ for (const r of rows) {
263
+ const key = r[field] ?? "unknown";
264
+ counts[key] = (counts[key] ?? 0) + 1;
265
+ }
266
+ return { total: rows.length, by: counts };
267
+ }
268
+
269
+ async function termTotals(entityApi: any): Promise<any> {
270
+ const rows = await scanAll(entityApi, null, "-created_date", { fields: ["id", "name", "count"] });
271
+ return { total: rows.length, terms: rows.map((r) => ({ id: r.id, name: r.name, count: r.count ?? 0 })) };
272
+ }
273
+
274
+ async function attributesTotals(sr: any): Promise<any> {
275
+ const attrs = await scanAll(sr.entities["commerce.ProductAttribute"]);
276
+ const terms = await scanAll(sr.entities["commerce.ProductAttributeTerm"], null, "-created_date", { fields: ["id", "attribute_id"] });
277
+ const byAttr: Record<string, number> = {};
278
+ for (const t of terms) byAttr[t.attribute_id] = (byAttr[t.attribute_id] ?? 0) + 1;
279
+ return {
280
+ total: attrs.length,
281
+ attributes: attrs.map((a) => ({ id: a.id, name: a.name, terms: byAttr[a.id] ?? 0 })),
282
+ };
283
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * commerce/admin-reviews — review moderation; keeps product rating aggregates in sync.
3
+ *
4
+ * Actions: set-status | update | delete
5
+ */
6
+ import { createClientFromRequest } from "npm:@base44/sdk";
7
+ import { HttpError, requireAdmin } from "../../../shared/commerce/auth.ts";
8
+ import { recalcProductRating } from "../../../shared/commerce/reviews.ts";
9
+
10
+ const ok = (data: unknown, status = 200) => Response.json({ success: true, data }, { status });
11
+ const fail = (status: number, error: string, code?: string) =>
12
+ Response.json({ success: false, error, code }, { status });
13
+
14
+ const STATUSES = ["approved", "hold", "spam", "trash"];
15
+
16
+ Deno.serve(async (req) => {
17
+ try {
18
+ const base44 = createClientFromRequest(req);
19
+ await requireAdmin(base44);
20
+ const sr = base44.asServiceRole;
21
+ const { action, ...payload } = await req.json();
22
+
23
+ const getReview = async (id: string) => {
24
+ const review = await sr.entities["commerce.ProductReview"].get(id);
25
+ if (!review) throw new HttpError(404, "Review not found", "not_found");
26
+ return review;
27
+ };
28
+
29
+ switch (action) {
30
+ case "set-status": {
31
+ if (!STATUSES.includes(payload.status)) {
32
+ throw new HttpError(400, `Invalid review status: ${payload.status}`, "invalid_status");
33
+ }
34
+ const review = await getReview(payload.id);
35
+ await sr.entities["commerce.ProductReview"].update(review.id, { status: payload.status });
36
+ const aggregates = await recalcProductRating(sr, review.product_id);
37
+ return ok({ review: { ...review, status: payload.status }, aggregates });
38
+ }
39
+ case "update": {
40
+ const review = await getReview(payload.id);
41
+ const patch: Record<string, any> = {};
42
+ for (const k of ["review", "rating", "reviewer", "reviewer_email", "status"]) {
43
+ if (payload[k] !== undefined) patch[k] = payload[k];
44
+ }
45
+ if (patch.rating != null && (patch.rating < 0 || patch.rating > 5)) {
46
+ throw new HttpError(400, "Rating must be between 0 and 5.", "invalid_rating");
47
+ }
48
+ await sr.entities["commerce.ProductReview"].update(review.id, patch);
49
+ const aggregates = await recalcProductRating(sr, review.product_id);
50
+ return ok({ review: { ...review, ...patch }, aggregates });
51
+ }
52
+ case "delete": {
53
+ const review = await getReview(payload.id);
54
+ await sr.entities["commerce.ProductReview"].delete(review.id);
55
+ const aggregates = await recalcProductRating(sr, review.product_id);
56
+ return ok({ deleted: review.id, aggregates });
57
+ }
58
+ default:
59
+ return fail(400, `Unknown action: ${action}`, "unknown_action");
60
+ }
61
+ } catch (e) {
62
+ if (e instanceof HttpError) return fail(e.status, e.message, e.code);
63
+ console.error("commerce/admin-reviews error:", e);
64
+ return fail(500, (e as Error).message ?? "Internal error");
65
+ }
66
+ });
@@ -0,0 +1,261 @@
1
+ /**
2
+ * commerce/admin-tools — maintenance and diagnostics (mini "system status").
3
+ *
4
+ * Actions: status | payment-connector-status | admin-email-recipients |
5
+ * recount-terms | recount-coupon-usage | recalculate-customer-stats-all |
6
+ * prune-webhook-deliveries | clear-abandoned-carts |
7
+ * regenerate-download-permissions
8
+ */
9
+ import { createClientFromRequest } from "npm:@base44/sdk";
10
+ import { HttpError, requireAdmin } from "../../../shared/commerce/auth.ts";
11
+ import { getSettings } from "../../../shared/commerce/settings.ts";
12
+ import { adminUserEmails } from "../../../shared/commerce/emails.ts";
13
+ import { onlinePaymentStatus } from "../../../shared/commerce/payments.ts";
14
+ import { round2 } from "../../../shared/commerce/money.ts";
15
+ import { grantDownloadPermissions, revokeDownloadPermissions } from "../../../shared/commerce/orders.ts";
16
+ import { scanAll } from "../../../shared/commerce/scan.ts";
17
+
18
+ const ok = (data: unknown, status = 200) => Response.json({ success: true, data }, { status });
19
+ const fail = (status: number, error: string, code?: string) =>
20
+ Response.json({ success: false, error, code }, { status });
21
+
22
+ Deno.serve(async (req) => {
23
+ try {
24
+ const base44 = createClientFromRequest(req);
25
+ await requireAdmin(base44);
26
+ const sr = base44.asServiceRole;
27
+ const { action, ...payload } = await req.json();
28
+
29
+ switch (action) {
30
+ case "status": return ok(await status(sr));
31
+ case "payment-connector-status": return ok(await paymentConnectorStatus(sr, payload));
32
+ case "admin-email-recipients": return ok(await adminEmailRecipients(sr));
33
+ case "recount-terms": return ok(await recountTerms(sr));
34
+ case "recount-coupon-usage": return ok(await recountCouponUsage(sr));
35
+ case "recalculate-customer-stats-all": return ok(await recalcAllCustomerStats(sr));
36
+ case "prune-webhook-deliveries": return ok(await pruneDeliveries(sr, payload));
37
+ case "clear-abandoned-carts": return ok(await clearCarts(sr, payload));
38
+ case "regenerate-download-permissions": return ok(await regenPermissions(sr, payload));
39
+ default:
40
+ return fail(400, `Unknown action: ${action}`, "unknown_action");
41
+ }
42
+ } catch (e) {
43
+ if (e instanceof HttpError) return fail(e.status, e.message, e.code);
44
+ console.error("commerce/admin-tools error:", e);
45
+ return fail(500, (e as Error).message ?? "Internal error");
46
+ }
47
+ });
48
+
49
+ /**
50
+ * Where admin notifications actually go right now, so Settings → Emails can
51
+ * name the effective recipients instead of describing the rule. `source` is
52
+ * "settings" when admin_recipients is filled in, else "admin_users" for the
53
+ * runtime fallback (which the client can't resolve itself — listing users needs
54
+ * service role).
55
+ */
56
+ async function adminEmailRecipients(
57
+ sr: any,
58
+ ): Promise<{ recipients: string[]; source: string; admin_users: string[] }> {
59
+ const settings = await getSettings(sr, "emails");
60
+ const configured = (settings.emails?.admin_recipients ?? [])
61
+ .map((r: any) => String(r ?? "").trim())
62
+ .filter(Boolean);
63
+ // admin_users is returned either way: the UI shows it as the fallback even
64
+ // while explicit recipients are set.
65
+ const adminUsers = await adminUserEmails(sr);
66
+ return {
67
+ recipients: configured.length ? configured : adminUsers,
68
+ source: configured.length ? "settings" : "admin_users",
69
+ admin_users: adminUsers,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Is a gateway's Base44 connector actually connected?
75
+ *
76
+ * The admin UI calls this instead of hardcoding a "not wired" notice, so the
77
+ * warning disappears on its own once the connector is connected. Connectors are
78
+ * service-role only, which is why this needs a round trip rather than a client
79
+ * check. Any failure (SDK without `connectors`, connector never set up) is
80
+ * reported as `connected: false` — the conservative answer.
81
+ */
82
+ /**
83
+ * Is an online payment provider usable right now? Delegates to the payment
84
+ * utility, so this answers for whichever provider is wired — the admin UI shows
85
+ * "no payment provider connected" rather than naming one.
86
+ */
87
+ async function paymentConnectorStatus(sr: any, _payload: any): Promise<any> {
88
+ const status = await onlinePaymentStatus(sr);
89
+ return {
90
+ ...status,
91
+ connector: status.provider, // kept for callers written against the old shape
92
+ };
93
+ }
94
+
95
+ /** Entity counts (capped at 1000 — shown as "1000+"), settings sanity, seeded flag. */
96
+ async function status(sr: any): Promise<any> {
97
+ const entities = [
98
+ "commerce.Product", "commerce.ProductVariation", "commerce.ProductCategory", "commerce.ProductTag", "commerce.ProductAttribute",
99
+ "commerce.Order", "commerce.OrderRefund", "commerce.Coupon", "commerce.Customer", "commerce.ProductReview",
100
+ "commerce.TaxRate", "commerce.ShippingZone", "commerce.PaymentGateway", "commerce.Webhook", "commerce.Cart",
101
+ ];
102
+ const counts: Record<string, string | number> = {};
103
+ for (const name of entities) {
104
+ try {
105
+ const page = (await sr.entities[name].list(undefined, 1000)) ?? [];
106
+ counts[name] = page.length >= 1000 ? "1000+" : page.length;
107
+ } catch (e) {
108
+ counts[name] = `error: ${(e as Error).message}`;
109
+ }
110
+ }
111
+ const settings = await getSettings(sr);
112
+ const groups = Object.keys(settings);
113
+ return {
114
+ template_version: "1.0.0",
115
+ seeded: groups.includes("general"),
116
+ settings_groups: groups,
117
+ counts,
118
+ checks: {
119
+ has_payment_gateways: (counts.PaymentGateway as number) > 0,
120
+ has_default_zone: (counts.ShippingZone as number) > 0,
121
+ taxes_enabled: settings.general?.enable_taxes ?? null,
122
+ },
123
+ };
124
+ }
125
+
126
+ /** Rebuild category/tag/shipping-class/attribute-term `count` fields from products. */
127
+ async function recountTerms(sr: any): Promise<any> {
128
+ const products = await scanAll(sr.entities["commerce.Product"]);
129
+ const catCounts: Record<string, number> = {};
130
+ const tagCounts: Record<string, number> = {};
131
+ const classCounts: Record<string, number> = {};
132
+ const termCounts: Record<string, number> = {}; // `${attribute_id}::${option}`
133
+
134
+ for (const p of products) {
135
+ for (const c of p.category_ids ?? []) catCounts[c] = (catCounts[c] ?? 0) + 1;
136
+ for (const t of p.tag_ids ?? []) tagCounts[t] = (tagCounts[t] ?? 0) + 1;
137
+ if (p.shipping_class_id) classCounts[p.shipping_class_id] = (classCounts[p.shipping_class_id] ?? 0) + 1;
138
+ for (const a of p.attributes ?? []) {
139
+ if (!a.attribute_id) continue;
140
+ for (const opt of a.options ?? []) {
141
+ const key = `${a.attribute_id}::${String(opt).toLowerCase()}`;
142
+ termCounts[key] = (termCounts[key] ?? 0) + 1;
143
+ }
144
+ }
145
+ }
146
+
147
+ const updateCounts = async (entity: string, expected: Record<string, number>) => {
148
+ let updated = 0;
149
+ for (const rec of await scanAll(sr.entities[entity])) {
150
+ const want = expected[rec.id] ?? 0;
151
+ if ((rec.count ?? 0) !== want) {
152
+ await sr.entities[entity].update(rec.id, { count: want });
153
+ updated++;
154
+ }
155
+ }
156
+ return updated;
157
+ };
158
+
159
+ const categories = await updateCounts("commerce.ProductCategory", catCounts);
160
+ const tags = await updateCounts("commerce.ProductTag", tagCounts);
161
+ const classes = await updateCounts("commerce.ShippingClass", classCounts);
162
+
163
+ let terms = 0;
164
+ for (const term of await scanAll(sr.entities["commerce.ProductAttributeTerm"])) {
165
+ const want = termCounts[`${term.attribute_id}::${String(term.name).toLowerCase()}`] ?? 0;
166
+ if ((term.count ?? 0) !== want) {
167
+ await sr.entities["commerce.ProductAttributeTerm"].update(term.id, { count: want });
168
+ terms++;
169
+ }
170
+ }
171
+ return { updated: { categories, tags, shipping_classes: classes, attribute_terms: terms } };
172
+ }
173
+
174
+ /** Rebuild coupon usage_count/used_by from orders where usage was counted. */
175
+ async function recountCouponUsage(sr: any): Promise<any> {
176
+ const orders = await scanAll(sr.entities["commerce.Order"], { coupon_usages_counted: true });
177
+ const usage: Record<string, string[]> = {};
178
+ for (const o of orders) {
179
+ const email = (o.billing?.email ?? "").toLowerCase();
180
+ for (const cl of o.coupon_lines ?? []) {
181
+ const code = (cl.code ?? "").toLowerCase();
182
+ if (!code) continue;
183
+ (usage[code] ??= []).push(email);
184
+ }
185
+ }
186
+ let updated = 0;
187
+ for (const coupon of await scanAll(sr.entities["commerce.Coupon"])) {
188
+ const uses = usage[coupon.code] ?? [];
189
+ if ((coupon.usage_count ?? 0) !== uses.length) {
190
+ await sr.entities["commerce.Coupon"].update(coupon.id, { usage_count: uses.length, used_by: uses });
191
+ updated++;
192
+ }
193
+ }
194
+ return { coupons_updated: updated };
195
+ }
196
+
197
+ async function recalcAllCustomerStats(sr: any): Promise<any> {
198
+ const customers = await scanAll(sr.entities["commerce.Customer"]);
199
+ const orders = await scanAll(sr.entities["commerce.Order"]);
200
+ const byCustomer = new Map<string, any[]>();
201
+ for (const o of orders) {
202
+ if (!o.customer_id) continue;
203
+ (byCustomer.get(o.customer_id) ?? byCustomer.set(o.customer_id, []).get(o.customer_id))!.push(o);
204
+ }
205
+
206
+ let updated = 0;
207
+ for (const c of customers) {
208
+ const own = byCustomer.get(c.id) ?? [];
209
+ const counted = own.filter((o) => !["cancelled", "failed"].includes(o.status));
210
+ const paid = own.filter((o) => o.date_paid || ["processing", "completed"].includes(o.status));
211
+ const totalSpent = round2(paid.reduce((a, o) => a + (o.total ?? 0) - (o.total_refunded ?? 0), 0));
212
+ const patch = {
213
+ orders_count: counted.length,
214
+ total_spent: totalSpent,
215
+ is_paying_customer: totalSpent > 0,
216
+ };
217
+ if (patch.orders_count !== (c.orders_count ?? 0) || patch.total_spent !== (c.total_spent ?? 0)) {
218
+ await sr.entities["commerce.Customer"].update(c.id, patch);
219
+ updated++;
220
+ }
221
+ }
222
+ return { customers_updated: updated };
223
+ }
224
+
225
+ async function pruneDeliveries(sr: any, payload: any): Promise<any> {
226
+ const keepDays = Number(payload.keep_days ?? 30);
227
+ const cutoff = new Date(Date.now() - keepDays * 86_400_000).toISOString();
228
+ const deliveries = await scanAll(sr.entities["commerce.WebhookDelivery"], null, "created_date");
229
+ let deleted = 0;
230
+ for (const d of deliveries) {
231
+ if (d.created_date < cutoff) {
232
+ await sr.entities["commerce.WebhookDelivery"].delete(d.id);
233
+ deleted++;
234
+ }
235
+ }
236
+ return { deleted, cutoff };
237
+ }
238
+
239
+ async function clearCarts(sr: any, payload: any): Promise<any> {
240
+ const olderThanDays = Number(payload.older_than_days ?? 30);
241
+ const cutoff = new Date(Date.now() - olderThanDays * 86_400_000).toISOString();
242
+ const carts = await scanAll(sr.entities["commerce.Cart"], null, "created_date");
243
+ let deleted = 0;
244
+ for (const cart of carts) {
245
+ const stale = (cart.updated_date ?? cart.created_date) < cutoff;
246
+ if (stale || cart.status === "converted") {
247
+ await sr.entities["commerce.Cart"].delete(cart.id);
248
+ deleted++;
249
+ }
250
+ }
251
+ return { deleted, cutoff };
252
+ }
253
+
254
+ async function regenPermissions(sr: any, payload: any): Promise<any> {
255
+ const order = await sr.entities["commerce.Order"].get(payload.order_id);
256
+ if (!order) throw new HttpError(404, "Order not found", "not_found");
257
+ await revokeDownloadPermissions(sr, order);
258
+ const granted = await grantDownloadPermissions(sr, order);
259
+ await sr.entities["commerce.Order"].update(order.id, { download_permissions_granted: granted > 0 });
260
+ return { granted };
261
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * commerce/admin-webhooks — test pings and redelivery for webhook endpoints.
3
+ * (Webhook CRUD itself is direct entity access — admin-only RLS.)
4
+ *
5
+ * Actions: test | redeliver
6
+ */
7
+ import { createClientFromRequest } from "npm:@base44/sdk";
8
+ import { HttpError, requireAdmin } from "../../../shared/commerce/auth.ts";
9
+ import { deliverOne } from "../../../shared/commerce/webhooks.ts";
10
+
11
+ const ok = (data: unknown, status = 200) => Response.json({ success: true, data }, { status });
12
+ const fail = (status: number, error: string, code?: string) =>
13
+ Response.json({ success: false, error, code }, { status });
14
+
15
+ Deno.serve(async (req) => {
16
+ try {
17
+ const base44 = createClientFromRequest(req);
18
+ await requireAdmin(base44);
19
+ const sr = base44.asServiceRole;
20
+ const { action, ...payload } = await req.json();
21
+
22
+ switch (action) {
23
+ case "test": {
24
+ const hook = await sr.entities["commerce.Webhook"].get(payload.webhook_id);
25
+ if (!hook) throw new HttpError(404, "Webhook not found", "not_found");
26
+ const result = await deliverOne(sr, hook, hook.topic, {
27
+ webhook_id: hook.id,
28
+ topic: hook.topic,
29
+ test: true,
30
+ timestamp: new Date().toISOString(),
31
+ });
32
+ return ok(result);
33
+ }
34
+ case "redeliver": {
35
+ const delivery = await sr.entities["commerce.WebhookDelivery"].get(payload.delivery_id);
36
+ if (!delivery) throw new HttpError(404, "Delivery not found", "not_found");
37
+ const hook = await sr.entities["commerce.Webhook"].get(delivery.webhook_id);
38
+ if (!hook) throw new HttpError(404, "Webhook no longer exists", "not_found");
39
+ let body: unknown = {};
40
+ try { body = JSON.parse(delivery.request_body || "{}"); } catch { body = { raw: delivery.request_body }; }
41
+ const result = await deliverOne(sr, hook, delivery.topic || hook.topic, body);
42
+ return ok(result);
43
+ }
44
+ default:
45
+ return fail(400, `Unknown action: ${action}`, "unknown_action");
46
+ }
47
+ } catch (e) {
48
+ if (e instanceof HttpError) return fail(e.status, e.message, e.code);
49
+ console.error("commerce/admin-webhooks error:", e);
50
+ return fail(500, (e as Error).message ?? "Internal error");
51
+ }
52
+ });