@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,215 @@
1
+ /**
2
+ * Shipping-zone matching and method cost computation (standard commerce semantics):
3
+ * - zones are checked in `order` asc; the first matching zone wins
4
+ * - region locations (continent/country/state) OR together; postcode locations
5
+ * act as an AND filter on top (a zone with only postcode locations matches
6
+ * any region but requires a postcode hit)
7
+ * - a zone with NO locations is the "Rest of the world" fallback, used only
8
+ * when no located zone matches
9
+ */
10
+ import { round2 } from "./money.ts";
11
+ import { postcodeMatchesPattern } from "./tax.ts";
12
+ import { continentOf } from "./data/continents.ts";
13
+
14
+ export interface ShipAddress {
15
+ country?: string;
16
+ state?: string;
17
+ postcode?: string;
18
+ city?: string;
19
+ }
20
+
21
+ /** Re-export for callers that only import shipping.ts. */
22
+ export function postcodeMatches(pattern: string, postcode: string): boolean {
23
+ return postcodeMatchesPattern(pattern, postcode);
24
+ }
25
+
26
+ function zoneMatches(zone: any, addr: ShipAddress): boolean {
27
+ const locations: any[] = zone.locations || [];
28
+ if (!locations.length) return false; // fallback zones handled by caller
29
+ const country = (addr.country || "").toUpperCase();
30
+ const state = (addr.state || "").toUpperCase();
31
+ const stateCode = country && state ? `${country}:${state}` : "";
32
+
33
+ const regions = locations.filter((l) => l.type !== "postcode");
34
+ const postcodes = locations.filter((l) => l.type === "postcode");
35
+
36
+ let regionOk = regions.length === 0; // postcode-only zone: no region restriction
37
+ for (const loc of regions) {
38
+ const code = (loc.code || "").toUpperCase();
39
+ if (loc.type === "continent" && continentOf(country)?.code === code) regionOk = true;
40
+ if (loc.type === "country" && code === country) regionOk = true;
41
+ if (loc.type === "state" && code === stateCode) regionOk = true;
42
+ if (regionOk) break;
43
+ }
44
+ if (!regionOk) return false;
45
+
46
+ if (postcodes.length) {
47
+ return postcodes.some((loc) => postcodeMatchesPattern(loc.code || "", addr.postcode || ""));
48
+ }
49
+ return true;
50
+ }
51
+
52
+ /**
53
+ * Pick the shipping zone for an address: first located zone (by `order` asc)
54
+ * that matches, else the fallback zone (empty locations — "Rest of the world"),
55
+ * else null.
56
+ */
57
+ export function matchZone(zones: any[], addr: ShipAddress): any | null {
58
+ const sorted = [...(zones || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
59
+ for (const zone of sorted) {
60
+ if ((zone.locations || []).length && zoneMatches(zone, addr)) return zone;
61
+ }
62
+ return sorted.find((z) => !(z.locations || []).length) ?? null;
63
+ }
64
+
65
+ export interface PricedCartForShipping {
66
+ /** Lines with shipping_class_id + discounted totals (ex tax). */
67
+ lines: Array<{
68
+ shipping_class_id?: string;
69
+ virtual?: boolean;
70
+ quantity: number;
71
+ subtotal: number;
72
+ total: number;
73
+ }>;
74
+ itemsSubtotal: number; // pre-discount, ex tax
75
+ itemsSubtotalAfterDiscount: number; // post-discount, ex tax
76
+ }
77
+
78
+ export interface AvailableMethod {
79
+ method: any;
80
+ cost: number;
81
+ }
82
+
83
+ /** Outcome of {@link resolveShippingSelection}. */
84
+ export type ShippingSelectionState =
85
+ | "not_needed" // virtual-only cart, or shipping disabled store-wide
86
+ | "chosen" // the caller's method is offered for this address
87
+ | "auto_selected" // exactly one method is offered — no choice to make
88
+ | "choice_required" // several methods are offered and none is chosen yet
89
+ | "none_available"; // nothing ships to this address (or no zone/method configured)
90
+
91
+ export interface ShippingSelection {
92
+ /** The ShippingZoneMethod id to price with; "" when there is nothing to apply. */
93
+ method_id: string;
94
+ state: ShippingSelectionState;
95
+ /** A previously stored choice is no longer offered (address or cart changed). */
96
+ stale: boolean;
97
+ }
98
+
99
+ /**
100
+ * Decide which shipping method a **storefront** cart/checkout should use.
101
+ *
102
+ * Storefronts must not be able to place a shippable order with no shipping
103
+ * line — that silently ships for free — so this collapses the decision to one
104
+ * rule set the cart and checkout both follow:
105
+ *
106
+ * - virtual-only cart, or shipping disabled → nothing to do;
107
+ * - the chosen method is still offered → keep it;
108
+ * - exactly one method offered → **select it automatically**; a single option is
109
+ * not a choice, and requiring a round trip for it is the trap that makes
110
+ * storefronts forget shipping entirely;
111
+ * - several offered, none chosen → the caller must ask the customer;
112
+ * - none offered → the store does not ship to this address.
113
+ *
114
+ * Deliberately *not* applied by the totals engine itself: admin orders are
115
+ * priced through the same engine and must never grow a shipping charge the
116
+ * operator didn't add.
117
+ */
118
+ export function resolveShippingSelection(input: {
119
+ needsShipping: boolean;
120
+ shippingEnabled?: boolean;
121
+ chosenMethodId?: string;
122
+ available: Array<{ id: string }>;
123
+ }): ShippingSelection {
124
+ const chosen = String(input.chosenMethodId || "");
125
+ if (!input.needsShipping || input.shippingEnabled === false) {
126
+ return { method_id: "", state: "not_needed", stale: false };
127
+ }
128
+ const available = input.available ?? [];
129
+ if (!available.length) return { method_id: "", state: "none_available", stale: !!chosen };
130
+ if (chosen && available.some((m) => m.id === chosen)) {
131
+ return { method_id: chosen, state: "chosen", stale: false };
132
+ }
133
+ if (available.length === 1) {
134
+ return { method_id: available[0].id, state: "auto_selected", stale: !!chosen };
135
+ }
136
+ return { method_id: "", state: "choice_required", stale: !!chosen };
137
+ }
138
+
139
+ function flatRateCost(method: any, cart: PricedCartForShipping): number {
140
+ const s = method.settings || {};
141
+ let cost = Number(s.cost) || 0;
142
+ const classCosts: any[] = s.class_costs || [];
143
+ const noClassCost = Number(s.no_class_cost) || 0;
144
+ const shippable = cart.lines.filter((l) => !l.virtual);
145
+ const classesPresent = [...new Set(shippable.filter((l) => l.shipping_class_id).map((l) => l.shipping_class_id))];
146
+ const hasClassless = shippable.some((l) => !l.shipping_class_id);
147
+
148
+ const applicable: number[] = [];
149
+ for (const clsId of classesPresent) {
150
+ const entry = classCosts.find((c) => c.shipping_class_id === clsId);
151
+ if (entry) applicable.push(Number(entry.cost) || 0);
152
+ }
153
+ if (hasClassless && noClassCost) applicable.push(noClassCost);
154
+
155
+ if ((s.calculation_type || "class") === "class") {
156
+ // "Charge shipping for each class individually"
157
+ cost += applicable.reduce((a, b) => a + b, 0);
158
+ } else if (applicable.length) {
159
+ // "order": charge based on the most expensive class in the cart
160
+ cost += Math.max(...applicable);
161
+ }
162
+ return round2(cost);
163
+ }
164
+
165
+ function freeShippingAvailable(method: any, cart: PricedCartForShipping, appliedCoupons: any[]): boolean {
166
+ const s = method.settings || {};
167
+ const requires = s.requires || "";
168
+ if (!requires) return true;
169
+ const hasCoupon = (appliedCoupons || []).some((c) => c?.free_shipping);
170
+ const basis = s.ignore_discounts ? cart.itemsSubtotal : cart.itemsSubtotalAfterDiscount;
171
+ const minOk = basis >= (Number(s.min_amount) || 0);
172
+ switch (requires) {
173
+ case "coupon": return hasCoupon;
174
+ case "min_amount": return minOk;
175
+ case "either": return hasCoupon || minOk;
176
+ case "both": return hasCoupon && minOk;
177
+ default: return true;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Compute the offer-able methods (with costs) for a matched zone.
183
+ * `methods` = the zone's ShippingZoneMethod records (any order); disabled and
184
+ * unmet-requirement methods are excluded. Free-shipping coupons only *enable*
185
+ * the free_shipping method — they never zero out flat rates (intended behavior).
186
+ */
187
+ export function availableMethods(
188
+ zone: any,
189
+ methods: any[],
190
+ cart: PricedCartForShipping,
191
+ _settings: Record<string, any>,
192
+ appliedCoupons: any[] = [],
193
+ ): AvailableMethod[] {
194
+ const zoneMethods = (methods || [])
195
+ .filter((m) => m.zone_id === zone?.id && m.enabled !== false)
196
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
197
+ const out: AvailableMethod[] = [];
198
+ for (const method of zoneMethods) {
199
+ switch (method.method_id) {
200
+ case "flat_rate":
201
+ out.push({ method, cost: flatRateCost(method, cart) });
202
+ break;
203
+ case "free_shipping":
204
+ if (freeShippingAvailable(method, cart, appliedCoupons)) out.push({ method, cost: 0 });
205
+ break;
206
+ case "local_pickup":
207
+ out.push({ method, cost: round2(Number(method.settings?.cost) || 0) });
208
+ break;
209
+ default:
210
+ // unknown custom method: offer at its configured flat cost
211
+ out.push({ method, cost: round2(Number(method.settings?.cost) || 0) });
212
+ }
213
+ }
214
+ return out;
215
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Stock management with standard commerce semantics:
3
+ * - purchasability checks (status, stock/backorders, sold_individually)
4
+ * - reduce/restore around the order lifecycle, guarded by order.stock_reduced
5
+ * (this module mutates the in-memory flag; orders.ts persists it)
6
+ * - variation-aware: variation.manage_stock "yes" | "no" | "parent"
7
+ * - threshold-crossing low/out-of-stock notifications
8
+ * - expired-hold release (a lightweight reserved-stock mechanism; invoked
9
+ * opportunistically since Base44 has no cron)
10
+ */
11
+ import { sendStockEmail } from "./emails.ts";
12
+ import { getSettings } from "./settings.ts";
13
+
14
+ export interface PurchasableResult {
15
+ ok: boolean;
16
+ error?: string;
17
+ code?: string;
18
+ }
19
+
20
+ /** Where does stock live for this product/variation combo? */
21
+ function stockTarget(product: any, variation?: any): { entity: string; record: any } | null {
22
+ if (variation) {
23
+ const mode = variation.manage_stock ?? "parent";
24
+ if (mode === "yes") return { entity: "commerce.ProductVariation", record: variation };
25
+ if (mode === "parent") {
26
+ return product?.manage_stock ? { entity: "commerce.Product", record: product } : null;
27
+ }
28
+ return null; // "no": status-only tracking on the variation
29
+ }
30
+ return product?.manage_stock ? { entity: "commerce.Product", record: product } : null;
31
+ }
32
+
33
+ function effectiveBackorders(product: any, variation?: any): string {
34
+ if (variation && (variation.manage_stock ?? "parent") === "yes") {
35
+ return variation.backorders ?? "no";
36
+ }
37
+ return product?.backorders ?? "no";
38
+ }
39
+
40
+ /** Derive stock_status from a quantity. */
41
+ export function deriveStockStatus(qty: number, backorders: string, outOfStockThreshold = 0): string {
42
+ if (qty <= outOfStockThreshold) {
43
+ return backorders !== "no" ? "onbackorder" : "outofstock";
44
+ }
45
+ return "instock";
46
+ }
47
+
48
+ /**
49
+ * Can `qty` of this product/variation be bought right now?
50
+ * Codes: not_published | variation_required | not_purchasable | sold_individually |
51
+ * out_of_stock | insufficient_stock
52
+ */
53
+ export function checkPurchasable(product: any, variation: any | undefined, qty: number, settings: Record<string, any>): PurchasableResult {
54
+ if (!product || product.status !== "publish") {
55
+ return { ok: false, code: "not_published", error: "This product is not available." };
56
+ }
57
+ if (product.type === "variable" && !variation) {
58
+ return { ok: false, code: "variation_required", error: "Please choose product options." };
59
+ }
60
+ if (variation && variation.status && variation.status !== "publish") {
61
+ return { ok: false, code: "not_published", error: "This product option is not available." };
62
+ }
63
+ if (product.type === "external") {
64
+ return { ok: false, code: "not_purchasable", error: "This product can only be purchased on an external site." };
65
+ }
66
+ const src = variation ?? product;
67
+ if (src.price === undefined || src.price === null) {
68
+ return { ok: false, code: "not_purchasable", error: "This product cannot be purchased (no price set)." };
69
+ }
70
+ if (product.sold_individually && qty > 1) {
71
+ return { ok: false, code: "sold_individually", error: "Only one of this product may be purchased per order." };
72
+ }
73
+
74
+ const target = stockTarget(product, variation);
75
+ const backorders = effectiveBackorders(product, variation);
76
+ if (target) {
77
+ const available = Number(target.record.stock_quantity ?? 0);
78
+ if (qty > available && backorders === "no") {
79
+ return available <= 0
80
+ ? { ok: false, code: "out_of_stock", error: "This product is out of stock." }
81
+ : { ok: false, code: "insufficient_stock", error: `Only ${available} left in stock.` };
82
+ }
83
+ } else {
84
+ const status = (variation?.stock_status ?? product.stock_status) || "instock";
85
+ if (status === "outofstock") {
86
+ return { ok: false, code: "out_of_stock", error: "This product is out of stock." };
87
+ }
88
+ }
89
+ return { ok: true };
90
+ }
91
+
92
+ interface StockOpts {
93
+ settings?: Record<string, any>;
94
+ }
95
+
96
+ /**
97
+ * Reduce stock for every line of an order. No-op if order.stock_reduced.
98
+ * Mutates order.stock_reduced=true in memory (caller persists), bumps
99
+ * Product.total_sales, and fires low/out-of-stock notifications on
100
+ * threshold crossings.
101
+ */
102
+ export async function reduceStock(sr: any, order: any, opts: StockOpts = {}): Promise<void> {
103
+ if (order.stock_reduced) return;
104
+ const settings = opts.settings ?? (await getSettings(sr, "inventory", "emails", "general"));
105
+ const inv = settings.inventory ?? {};
106
+ const outThreshold = Number(inv.out_of_stock_threshold ?? 0);
107
+ const defaultLow = Number(inv.low_stock_threshold ?? 2);
108
+
109
+ for (const line of order.line_items || []) {
110
+ const { product, variation } = await loadLineProducts(sr, line);
111
+ if (!product) continue;
112
+
113
+ const target = stockTarget(product, variation);
114
+ if (target) {
115
+ const before = Number(target.record.stock_quantity ?? 0);
116
+ const after = before - line.quantity;
117
+ const backorders = effectiveBackorders(product, variation);
118
+ const status = deriveStockStatus(after, backorders, outThreshold);
119
+ await sr.entities[target.entity].update(target.record.id, {
120
+ stock_quantity: after,
121
+ stock_status: status,
122
+ });
123
+ target.record.stock_quantity = after;
124
+ target.record.stock_status = status;
125
+
126
+ // threshold-crossing notifications (once per crossing)
127
+ const low = Number(target.record.low_stock_amount ?? defaultLow);
128
+ if (after <= outThreshold && before > outThreshold) {
129
+ await sendStockEmail(sr, "out_of_stock", { ...product, stock_quantity: after }, { settings });
130
+ } else if (after <= low && before > low) {
131
+ await sendStockEmail(sr, "low_stock", { ...product, stock_quantity: after }, { settings });
132
+ }
133
+ }
134
+
135
+ // units sold live on the parent product regardless of variation
136
+ await sr.entities["commerce.Product"].update(product.id, {
137
+ total_sales: (product.total_sales ?? 0) + line.quantity,
138
+ });
139
+ }
140
+ order.stock_reduced = true;
141
+ }
142
+
143
+ /**
144
+ * Restore stock for every line (cancel/fail flows). No-op unless
145
+ * order.stock_reduced. Mutates order.stock_reduced=false in memory.
146
+ */
147
+ export async function restoreStock(sr: any, order: any, opts: StockOpts = {}): Promise<void> {
148
+ if (!order.stock_reduced) return;
149
+ const settings = opts.settings ?? (await getSettings(sr, "inventory"));
150
+ const outThreshold = Number(settings.inventory?.out_of_stock_threshold ?? 0);
151
+
152
+ for (const line of order.line_items || []) {
153
+ const { product, variation } = await loadLineProducts(sr, line);
154
+ if (!product) continue;
155
+ await restoreLineQuantity(sr, product, variation, line.quantity, outThreshold);
156
+ await sr.entities["commerce.Product"].update(product.id, {
157
+ total_sales: Math.max(0, (product.total_sales ?? 0) - line.quantity),
158
+ });
159
+ }
160
+ order.stock_reduced = false;
161
+ }
162
+
163
+ /**
164
+ * Restock a specific quantity for one line (refund-with-restock flow).
165
+ * Does NOT touch total_sales or the stock_reduced flag.
166
+ */
167
+ export async function restockLine(sr: any, line: any, quantity: number, opts: StockOpts = {}): Promise<void> {
168
+ const settings = opts.settings ?? (await getSettings(sr, "inventory"));
169
+ const outThreshold = Number(settings.inventory?.out_of_stock_threshold ?? 0);
170
+ const { product, variation } = await loadLineProducts(sr, line);
171
+ if (!product) return;
172
+ await restoreLineQuantity(sr, product, variation, quantity, outThreshold);
173
+ }
174
+
175
+ async function restoreLineQuantity(sr: any, product: any, variation: any | undefined, quantity: number, outThreshold: number): Promise<void> {
176
+ const target = stockTarget(product, variation);
177
+ if (!target) return;
178
+ const after = Number(target.record.stock_quantity ?? 0) + quantity;
179
+ const backorders = effectiveBackorders(product, variation);
180
+ await sr.entities[target.entity].update(target.record.id, {
181
+ stock_quantity: after,
182
+ stock_status: deriveStockStatus(after, backorders, outThreshold),
183
+ });
184
+ target.record.stock_quantity = after;
185
+ }
186
+
187
+ async function loadLineProducts(sr: any, line: any): Promise<{ product: any | null; variation?: any }> {
188
+ let product: any = null;
189
+ let variation: any = undefined;
190
+ try {
191
+ if (line.product_id) product = await sr.entities["commerce.Product"].get(line.product_id);
192
+ } catch { product = null; }
193
+ try {
194
+ if (line.variation_id) variation = await sr.entities["commerce.ProductVariation"].get(line.variation_id);
195
+ } catch { variation = undefined; }
196
+ return { product, variation };
197
+ }
198
+
199
+ /**
200
+ * Cancel pending orders whose stock hold expired, releasing their stock.
201
+ * `transitionFn` is orders.ts transitionOrder (passed in to avoid a circular
202
+ * import). Returns how many orders were released.
203
+ */
204
+ export async function releaseExpiredHolds(
205
+ sr: any,
206
+ settings: Record<string, any> | null,
207
+ transitionFn: (sr: any, order: any, status: string, opts?: any) => Promise<any>,
208
+ ): Promise<number> {
209
+ const now = Date.now();
210
+ let released = 0;
211
+ let skip = 0;
212
+ while (true) {
213
+ const page = (await sr.entities["commerce.Order"].filter({ status: "pending", stock_reduced: true }, "-created_date", 200, skip)) ?? [];
214
+ for (const order of page) {
215
+ if (order.hold_expires_at && new Date(order.hold_expires_at).getTime() < now) {
216
+ await transitionFn(sr, order, "cancelled", {
217
+ note: "Unpaid order — stock hold expired and the order was cancelled automatically.",
218
+ settings: settings ?? undefined,
219
+ });
220
+ released++;
221
+ }
222
+ }
223
+ if (page.length < 200) break;
224
+ skip += 200;
225
+ }
226
+ return released;
227
+ }