@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.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
- package/base44/entities/commerce.Cart.jsonc +73 -0
- package/base44/entities/commerce.Coupon.jsonc +113 -0
- package/base44/entities/commerce.Customer.jsonc +96 -0
- package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
- package/base44/entities/commerce.EmailLog.jsonc +43 -0
- package/base44/entities/commerce.Order.jsonc +287 -0
- package/base44/entities/commerce.OrderNote.jsonc +31 -0
- package/base44/entities/commerce.OrderRefund.jsonc +64 -0
- package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
- package/base44/entities/commerce.Product.jsonc +291 -0
- package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
- package/base44/entities/commerce.ProductCategory.jsonc +51 -0
- package/base44/entities/commerce.ProductReview.jsonc +48 -0
- package/base44/entities/commerce.ProductTag.jsonc +30 -0
- package/base44/entities/commerce.ProductVariation.jsonc +167 -0
- package/base44/entities/commerce.ShippingClass.jsonc +30 -0
- package/base44/entities/commerce.ShippingZone.jsonc +41 -0
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
- package/base44/entities/commerce.StoreSettings.jsonc +23 -0
- package/base44/entities/commerce.TaxClass.jsonc +23 -0
- package/base44/entities/commerce.TaxRate.jsonc +68 -0
- package/base44/entities/commerce.Webhook.jsonc +57 -0
- package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
- package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
- package/base44/functions/commerce/admin-customers/entry.ts +141 -0
- package/base44/functions/commerce/admin-orders/entry.ts +396 -0
- package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
- package/base44/functions/commerce/admin-products/entry.ts +506 -0
- package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
- package/base44/functions/commerce/admin-reports/entry.ts +283 -0
- package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
- package/base44/functions/commerce/admin-tools/entry.ts +261 -0
- package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
- package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
- package/base44/functions/commerce/payments/entry.ts +238 -0
- package/base44/functions/commerce/seed-store/defaults.ts +162 -0
- package/base44/functions/commerce/seed-store/entry.ts +310 -0
- package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
- package/base44/functions/commerce/storefront-account/entry.ts +207 -0
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
- package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
- package/base44/shared/commerce/auth.ts +60 -0
- package/base44/shared/commerce/coupons.ts +257 -0
- package/base44/shared/commerce/data/continents.ts +75 -0
- package/base44/shared/commerce/data/countries.ts +307 -0
- package/base44/shared/commerce/data/currencies.ts +46 -0
- package/base44/shared/commerce/email-templates.ts +240 -0
- package/base44/shared/commerce/emails.ts +225 -0
- package/base44/shared/commerce/money.ts +66 -0
- package/base44/shared/commerce/orders.ts +251 -0
- package/base44/shared/commerce/payments.ts +495 -0
- package/base44/shared/commerce/reviews.ts +36 -0
- package/base44/shared/commerce/scan.ts +57 -0
- package/base44/shared/commerce/sequence.ts +35 -0
- package/base44/shared/commerce/settings.ts +57 -0
- package/base44/shared/commerce/shipping.ts +215 -0
- package/base44/shared/commerce/stock.ts +227 -0
- package/base44/shared/commerce/stripe.ts +463 -0
- package/base44/shared/commerce/tax.ts +136 -0
- package/base44/shared/commerce/totals.ts +314 -0
- package/base44/shared/commerce/webhooks.ts +116 -0
- package/package.json +37 -0
- package/scripts/install.js +156 -0
- package/skills/commerce/SKILL.md +62 -0
- package/skills/commerce/docs/api-admin.md +186 -0
- package/skills/commerce/docs/api-storefront.md +408 -0
- package/skills/commerce/installation-guidelines.md +91 -0
- package/skills/commerce/post-installation.md +157 -0
- package/skills/commerce/references/emails.md +13 -0
- package/skills/commerce/references/guest-access-security.md +18 -0
- package/skills/commerce/references/limits-and-performance.md +16 -0
- package/skills/commerce/references/media-and-downloads.md +4 -0
- package/skills/commerce/references/online-payments.md +201 -0
- package/skills/commerce/references/product-render.md +87 -0
- package/skills/commerce/references/scheduled-work.md +19 -0
- package/skills/commerce/references/storefront-product-page.md +83 -0
- package/skills/commerce/references/webhooks.md +8 -0
- package/src/commerce/admin/README.md +107 -0
- package/src/commerce/admin/bot/Markdown.jsx +138 -0
- package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
- package/src/commerce/admin/bot/pipe-tables.js +116 -0
- package/src/commerce/admin/components/AddressForm.jsx +78 -0
- package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
- package/src/commerce/admin/components/CountrySelect.jsx +81 -0
- package/src/commerce/admin/components/DataTable.jsx +192 -0
- package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
- package/src/commerce/admin/components/EmptyState.jsx +17 -0
- package/src/commerce/admin/components/MediaUploader.jsx +116 -0
- package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
- package/src/commerce/admin/components/MoneyInput.jsx +50 -0
- package/src/commerce/admin/components/PageHeader.jsx +29 -0
- package/src/commerce/admin/components/RichTextarea.jsx +21 -0
- package/src/commerce/admin/components/SearchSelect.jsx +142 -0
- package/src/commerce/admin/components/StatusBadge.jsx +17 -0
- package/src/commerce/admin/context/BasePathContext.jsx +26 -0
- package/src/commerce/admin/context/SettingsContext.jsx +207 -0
- package/src/commerce/admin/hooks/useAsync.js +46 -0
- package/src/commerce/admin/hooks/useDebounce.js +11 -0
- package/src/commerce/admin/hooks/useMoney.js +52 -0
- package/src/commerce/admin/hooks/usePagedList.js +83 -0
- package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
- package/src/commerce/admin/hooks/useRealtime.js +129 -0
- package/src/commerce/admin/index.jsx +34 -0
- package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
- package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
- package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
- package/src/commerce/admin/layout/Sidebar.jsx +130 -0
- package/src/commerce/admin/layout/Topbar.jsx +94 -0
- package/src/commerce/admin/lib/api.js +55 -0
- package/src/commerce/admin/lib/constants.js +157 -0
- package/src/commerce/admin/lib/format.js +27 -0
- package/src/commerce/admin/lib/geo-data.js +125 -0
- package/src/commerce/admin/lib/order-utils.js +147 -0
- package/src/commerce/admin/lib/paths.js +35 -0
- package/src/commerce/admin/lib/product-utils.js +55 -0
- package/src/commerce/admin/pages/Dashboard.jsx +245 -0
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
- package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
- package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
- package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
- package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
- package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
- package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
- package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
- package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
- package/src/commerce/admin/pages/products/Categories.jsx +236 -0
- package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
- package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
- package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
- package/src/commerce/admin/pages/products/Tags.jsx +150 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
- package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
- package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
- package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
- package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
- package/src/commerce/admin/routes.jsx +151 -0
- package/src/commerce/utils/index.js +19 -0
- package/src/commerce/utils/shipping-promos.js +99 -0
- package/src/commerce/utils/variants.js +411 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commerce/admin-orders — order creation, editing, status lifecycle, notes, emails,
|
|
3
|
+
* download permissions and hold release.
|
|
4
|
+
*
|
|
5
|
+
* Actions: create-draft | create | update | preview | update-status |
|
|
6
|
+
* bulk-status | status-counts | search | recalculate | apply-coupon |
|
|
7
|
+
* remove-coupon | add-note | delete-note | send-email | grant-download |
|
|
8
|
+
* revoke-download | delete | release-expired-holds
|
|
9
|
+
*/
|
|
10
|
+
import { createClientFromRequest } from "npm:@base44/sdk";
|
|
11
|
+
import { HttpError, requireAdmin } from "../../../shared/commerce/auth.ts";
|
|
12
|
+
import { getSettings } from "../../../shared/commerce/settings.ts";
|
|
13
|
+
import { generateOrderKey, nextOrderNumber } from "../../../shared/commerce/sequence.ts";
|
|
14
|
+
import { ORDER_STATUSES, transitionOrder } from "../../../shared/commerce/orders.ts";
|
|
15
|
+
import { releaseExpiredHolds } from "../../../shared/commerce/stock.ts";
|
|
16
|
+
import { validateCoupon } from "../../../shared/commerce/coupons.ts";
|
|
17
|
+
import { sendOrderEmail } from "../../../shared/commerce/emails.ts";
|
|
18
|
+
import { dispatch } from "../../../shared/commerce/webhooks.ts";
|
|
19
|
+
import { pageSlice, scanAll, textMatch } from "../../../shared/commerce/scan.ts";
|
|
20
|
+
import {
|
|
21
|
+
gatewayTitle,
|
|
22
|
+
isLocked,
|
|
23
|
+
loadPricingContext,
|
|
24
|
+
repriceOptsFromPatch,
|
|
25
|
+
repriceOrder,
|
|
26
|
+
resolveCoupons,
|
|
27
|
+
} from "./helpers.ts";
|
|
28
|
+
|
|
29
|
+
const ok = (data: unknown, status = 200) => Response.json({ success: true, data }, { status });
|
|
30
|
+
const fail = (status: number, error: string, code?: string) =>
|
|
31
|
+
Response.json({ success: false, error, code }, { status });
|
|
32
|
+
|
|
33
|
+
Deno.serve(async (req) => {
|
|
34
|
+
try {
|
|
35
|
+
const base44 = createClientFromRequest(req);
|
|
36
|
+
const admin = await requireAdmin(base44);
|
|
37
|
+
const sr = base44.asServiceRole;
|
|
38
|
+
const { action, ...payload } = await req.json();
|
|
39
|
+
const actor = admin.email ?? "admin";
|
|
40
|
+
|
|
41
|
+
switch (action) {
|
|
42
|
+
case "create-draft": return ok(await createDraft(sr));
|
|
43
|
+
case "create": return ok(await create(sr, payload, actor), 201);
|
|
44
|
+
case "update": return ok(await update(sr, payload, actor));
|
|
45
|
+
case "preview": return ok(await preview(sr, payload));
|
|
46
|
+
case "update-status": {
|
|
47
|
+
const order = await getOrder(sr, payload.order_id);
|
|
48
|
+
assertStatus(payload.status);
|
|
49
|
+
await transitionOrder(sr, order, payload.status, { actor, note: payload.note });
|
|
50
|
+
return ok(order);
|
|
51
|
+
}
|
|
52
|
+
case "bulk-status": return ok(await bulkStatus(sr, payload, actor));
|
|
53
|
+
case "status-counts": return ok(await statusCounts(sr));
|
|
54
|
+
case "search": return ok(await search(sr, payload));
|
|
55
|
+
case "recalculate": {
|
|
56
|
+
const order = await getOrder(sr, payload.order_id);
|
|
57
|
+
const { fields } = await repriceOrder(sr, order, { repriceFromCatalog: !!payload.reprice_from_catalog });
|
|
58
|
+
await sr.entities["commerce.Order"].update(order.id, fields);
|
|
59
|
+
Object.assign(order, fields);
|
|
60
|
+
await dispatch(sr, "order.updated", order);
|
|
61
|
+
return ok(order);
|
|
62
|
+
}
|
|
63
|
+
case "apply-coupon": return ok(await applyCoupon(sr, payload));
|
|
64
|
+
case "remove-coupon": return ok(await removeCoupon(sr, payload));
|
|
65
|
+
case "add-note": return ok(await addNote(sr, payload, actor), 201);
|
|
66
|
+
case "delete-note": {
|
|
67
|
+
await sr.entities["commerce.OrderNote"].delete(payload.note_id);
|
|
68
|
+
return ok({ deleted: payload.note_id });
|
|
69
|
+
}
|
|
70
|
+
case "send-email": {
|
|
71
|
+
const order = await getOrder(sr, payload.order_id);
|
|
72
|
+
const result = await sendOrderEmail(sr, payload.type ?? "customer_invoice", order, { force: true });
|
|
73
|
+
return ok(result);
|
|
74
|
+
}
|
|
75
|
+
case "grant-download": return ok(await grantDownload(sr, payload));
|
|
76
|
+
case "revoke-download": {
|
|
77
|
+
await sr.entities["commerce.DownloadPermission"].delete(payload.permission_id);
|
|
78
|
+
return ok({ deleted: payload.permission_id });
|
|
79
|
+
}
|
|
80
|
+
case "delete": return ok(await remove(sr, payload.order_id));
|
|
81
|
+
case "release-expired-holds": {
|
|
82
|
+
const settings = await getSettings(sr);
|
|
83
|
+
const released = await releaseExpiredHolds(sr, settings, transitionOrder);
|
|
84
|
+
return ok({ released });
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
return fail(400, `Unknown action: ${action}`, "unknown_action");
|
|
88
|
+
}
|
|
89
|
+
} catch (e) {
|
|
90
|
+
if (e instanceof HttpError) return fail(e.status, e.message, e.code);
|
|
91
|
+
console.error("commerce/admin-orders error:", e);
|
|
92
|
+
return fail(500, (e as Error).message ?? "Internal error");
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
async function getOrder(sr: any, orderId: string): Promise<any> {
|
|
99
|
+
if (!orderId) throw new HttpError(400, "order_id is required", "invalid_payload");
|
|
100
|
+
const order = await sr.entities["commerce.Order"].get(orderId);
|
|
101
|
+
if (!order) throw new HttpError(404, "Order not found", "not_found");
|
|
102
|
+
return order;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function assertStatus(status: string): void {
|
|
106
|
+
if (!ORDER_STATUSES.includes(status as any)) {
|
|
107
|
+
throw new HttpError(400, `Invalid order status: ${status}`, "invalid_status");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── create ───────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/** Empty admin order shell — no lifecycle side effects until it's edited/saved. */
|
|
114
|
+
async function createDraft(sr: any): Promise<any> {
|
|
115
|
+
const settings = await getSettings(sr, "general");
|
|
116
|
+
const order = await sr.entities["commerce.Order"].create({
|
|
117
|
+
order_number: await nextOrderNumber(sr),
|
|
118
|
+
order_key: generateOrderKey(),
|
|
119
|
+
status: "pending",
|
|
120
|
+
currency: settings.general?.currency ?? "USD",
|
|
121
|
+
created_via: "admin",
|
|
122
|
+
customer_id: "",
|
|
123
|
+
billing: {},
|
|
124
|
+
shipping: {},
|
|
125
|
+
line_items: [],
|
|
126
|
+
shipping_lines: [],
|
|
127
|
+
tax_lines: [],
|
|
128
|
+
fee_lines: [],
|
|
129
|
+
coupon_lines: [],
|
|
130
|
+
subtotal: 0, discount_total: 0, discount_tax: 0, shipping_total: 0,
|
|
131
|
+
shipping_tax: 0, cart_tax: 0, total_tax: 0, total: 0, total_refunded: 0,
|
|
132
|
+
stock_reduced: false, coupon_usages_counted: false,
|
|
133
|
+
download_permissions_granted: false, emails_sent: [], meta_data: [],
|
|
134
|
+
});
|
|
135
|
+
await sr.entities["commerce.OrderNote"].create({
|
|
136
|
+
order_id: order.id, note: "Order draft created by admin.", is_customer_note: false, added_by: "system",
|
|
137
|
+
});
|
|
138
|
+
return order;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Manual admin order: prices via the same totals engine as checkout.
|
|
143
|
+
* payload: {items, coupon_codes?, fees?, billing?, shipping?, customer_id?,
|
|
144
|
+
* chosen_shipping_method?, payment_method?, customer_note?, status?, reduce_stock?}
|
|
145
|
+
*/
|
|
146
|
+
async function create(sr: any, payload: any, actor: string): Promise<any> {
|
|
147
|
+
const ctx = await loadPricingContext(sr);
|
|
148
|
+
const shipping = payload.shipping ?? payload.billing ?? {};
|
|
149
|
+
const { fields } = await repriceOrder(sr, {
|
|
150
|
+
line_items: [], coupon_lines: [], fee_lines: [], shipping_lines: [],
|
|
151
|
+
billing: payload.billing ?? {}, shipping,
|
|
152
|
+
}, {
|
|
153
|
+
items: payload.items ?? [],
|
|
154
|
+
couponCodes: payload.coupon_codes ?? [],
|
|
155
|
+
fees: payload.fees ?? [],
|
|
156
|
+
billing: payload.billing ?? {},
|
|
157
|
+
shipping,
|
|
158
|
+
chosenShippingMethodId: payload.chosen_shipping_method,
|
|
159
|
+
ctx,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const order = await sr.entities["commerce.Order"].create({
|
|
163
|
+
order_number: await nextOrderNumber(sr),
|
|
164
|
+
order_key: generateOrderKey(),
|
|
165
|
+
status: "pending",
|
|
166
|
+
currency: ctx.settings.general?.currency ?? "USD",
|
|
167
|
+
created_via: "admin",
|
|
168
|
+
customer_id: payload.customer_id ?? "",
|
|
169
|
+
customer_note: payload.customer_note ?? "",
|
|
170
|
+
billing: payload.billing ?? {},
|
|
171
|
+
shipping,
|
|
172
|
+
payment_method: payload.payment_method ?? "",
|
|
173
|
+
payment_method_title: await gatewayTitle(sr, payload.payment_method ?? ""),
|
|
174
|
+
...fields,
|
|
175
|
+
total_refunded: 0,
|
|
176
|
+
stock_reduced: false, coupon_usages_counted: false,
|
|
177
|
+
download_permissions_granted: false, emails_sent: [],
|
|
178
|
+
meta_data: payload.meta_data ?? [],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const target = payload.status ?? "pending";
|
|
182
|
+
assertStatus(target);
|
|
183
|
+
if (payload.reduce_stock === false && target === "pending") {
|
|
184
|
+
// creation without a stock reduction: fire the creation effects manually
|
|
185
|
+
await sendOrderEmail(sr, "new_order", order, { settings: ctx.settings });
|
|
186
|
+
await sr.entities["commerce.OrderNote"].create({
|
|
187
|
+
order_id: order.id, note: "Order created via admin (pending, stock not reduced).",
|
|
188
|
+
is_customer_note: false, added_by: actor,
|
|
189
|
+
});
|
|
190
|
+
await dispatch(sr, "order.created", order);
|
|
191
|
+
} else {
|
|
192
|
+
await transitionOrder(sr, order, target, { isCreation: true, actor, settings: ctx.settings });
|
|
193
|
+
}
|
|
194
|
+
return order;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── update ───────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Patch an order. Line-level edits ({items, fees, coupon_codes,
|
|
201
|
+
* chosen_shipping_method}) require an unlocked order (pending/on-hold) and
|
|
202
|
+
* trigger a full reprice. Addresses/customer/meta are editable any time.
|
|
203
|
+
* A `status` in the patch is delegated to the transition engine last.
|
|
204
|
+
*/
|
|
205
|
+
async function update(sr: any, payload: any, actor: string): Promise<any> {
|
|
206
|
+
const order = await getOrder(sr, payload.order_id);
|
|
207
|
+
const patch = payload.patch ?? {};
|
|
208
|
+
const wantsLineEdit = ["items", "fees", "coupon_codes", "chosen_shipping_method", "shipping_lines"]
|
|
209
|
+
.some((k) => patch[k] !== undefined);
|
|
210
|
+
if (wantsLineEdit && isLocked(order)) {
|
|
211
|
+
throw new HttpError(409, `Order items can only be edited while pending or on-hold (current: ${order.status}).`, "order_locked");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const fields: Record<string, any> = {};
|
|
215
|
+
for (const k of ["billing", "shipping", "customer_id", "customer_note", "payment_method", "meta_data"]) {
|
|
216
|
+
if (patch[k] !== undefined) fields[k] = patch[k];
|
|
217
|
+
}
|
|
218
|
+
if (patch.payment_method !== undefined) {
|
|
219
|
+
fields.payment_method_title = await gatewayTitle(sr, patch.payment_method);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const addressesChanged = patch.billing !== undefined || patch.shipping !== undefined;
|
|
223
|
+
if (wantsLineEdit || addressesChanged) {
|
|
224
|
+
const { fields: priced } = await repriceOrder(sr, order, repriceOptsFromPatch(order, patch));
|
|
225
|
+
Object.assign(fields, priced);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await sr.entities["commerce.Order"].update(order.id, fields);
|
|
229
|
+
Object.assign(order, fields);
|
|
230
|
+
|
|
231
|
+
if (patch.status && patch.status !== order.status) {
|
|
232
|
+
assertStatus(patch.status);
|
|
233
|
+
await transitionOrder(sr, order, patch.status, { actor });
|
|
234
|
+
} else {
|
|
235
|
+
await dispatch(sr, "order.updated", order);
|
|
236
|
+
}
|
|
237
|
+
return order;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Price an `update` patch **without writing anything** — the totals the admin
|
|
242
|
+
* would get if they saved right now. Lets the order editor show live totals
|
|
243
|
+
* (per-line tax, coupon discount, shipping tax, grand total) for unsaved edits
|
|
244
|
+
* instead of stale persisted figures, without reimplementing the tax/coupon
|
|
245
|
+
* rules on the client.
|
|
246
|
+
*
|
|
247
|
+
* payload: { order_id, patch } — same patch shape as `update`; unlike `update`
|
|
248
|
+
* it is allowed on locked orders (nothing is persisted) and fires no webhooks.
|
|
249
|
+
* → { fields } (the Order fields `update` would have written: line_items,
|
|
250
|
+
* shipping_lines, fee_lines, coupon_lines, tax_lines and every total)
|
|
251
|
+
*/
|
|
252
|
+
async function preview(sr: any, payload: any): Promise<any> {
|
|
253
|
+
const order = await getOrder(sr, payload.order_id);
|
|
254
|
+
const patch = payload.patch ?? {};
|
|
255
|
+
const { fields } = await repriceOrder(sr, order, repriceOptsFromPatch(order, patch));
|
|
256
|
+
return { fields };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function bulkStatus(sr: any, payload: any, actor: string): Promise<any> {
|
|
260
|
+
assertStatus(payload.status);
|
|
261
|
+
const results: any[] = [];
|
|
262
|
+
for (const id of payload.ids ?? []) {
|
|
263
|
+
try {
|
|
264
|
+
const order = await getOrder(sr, id);
|
|
265
|
+
await transitionOrder(sr, order, payload.status, { actor });
|
|
266
|
+
results.push({ id, success: true });
|
|
267
|
+
} catch (e) {
|
|
268
|
+
results.push({ id, success: false, error: (e as Error).message });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { results };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── lists ────────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
async function statusCounts(sr: any): Promise<Record<string, number>> {
|
|
277
|
+
const orders = await scanAll(sr.entities["commerce.Order"], null, "-created_date", { fields: ["id", "status"] });
|
|
278
|
+
const counts: Record<string, number> = { all: orders.length };
|
|
279
|
+
for (const s of ORDER_STATUSES) counts[s] = 0;
|
|
280
|
+
for (const o of orders) counts[o.status] = (counts[o.status] ?? 0) + 1;
|
|
281
|
+
return counts;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function search(sr: any, payload: any): Promise<any> {
|
|
285
|
+
const { q, status, date_min, date_max, sort = "-created_date", limit = 20, skip = 0 } = payload;
|
|
286
|
+
let rows = await scanAll(sr.entities["commerce.Order"], status ? { status } : null, sort);
|
|
287
|
+
if (q) {
|
|
288
|
+
rows = rows.filter((o) =>
|
|
289
|
+
String(o.order_number ?? "").includes(String(q).replace(/^#/, "")) ||
|
|
290
|
+
textMatch(`${o.billing?.first_name ?? ""} ${o.billing?.last_name ?? ""}`, q) ||
|
|
291
|
+
textMatch(o.billing?.email, q)
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (date_min) rows = rows.filter((o) => o.created_date >= date_min);
|
|
295
|
+
if (date_max) rows = rows.filter((o) => o.created_date <= `${date_max}~`); // '~' sorts after any time suffix
|
|
296
|
+
return pageSlice(rows, Number(limit), Number(skip));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── coupons on an order ──────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
async function applyCoupon(sr: any, payload: any): Promise<any> {
|
|
302
|
+
const order = await getOrder(sr, payload.order_id);
|
|
303
|
+
if (isLocked(order)) throw new HttpError(409, "Coupons can only change while the order is pending or on-hold.", "order_locked");
|
|
304
|
+
const code = String(payload.code ?? "").toLowerCase().trim();
|
|
305
|
+
if (!code) throw new HttpError(400, "code is required", "invalid_payload");
|
|
306
|
+
if ((order.coupon_lines ?? []).some((c: any) => c.code === code)) {
|
|
307
|
+
throw new HttpError(409, "Coupon already applied.", "already_applied");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const [coupon] = await resolveCoupons(sr, [code]);
|
|
311
|
+
const applied = await resolveCoupons(sr, (order.coupon_lines ?? []).map((c: any) => c.code));
|
|
312
|
+
const lines = (order.line_items ?? []).map((l: any) => ({
|
|
313
|
+
line_id: l.line_id, product_id: l.product_id, variation_id: l.variation_id,
|
|
314
|
+
quantity: l.quantity, unit_price: l.price, subtotal: l.subtotal, discount: 0,
|
|
315
|
+
}));
|
|
316
|
+
const verdict = validateCoupon(coupon ?? null, {
|
|
317
|
+
lines, itemsSubtotal: order.subtotal ?? 0,
|
|
318
|
+
customerEmail: order.billing?.email, appliedCoupons: applied,
|
|
319
|
+
});
|
|
320
|
+
if (!verdict.valid) throw new HttpError(400, verdict.error ?? "Invalid coupon", verdict.code);
|
|
321
|
+
|
|
322
|
+
const codes = [...(order.coupon_lines ?? []).map((c: any) => c.code), code];
|
|
323
|
+
const { fields } = await repriceOrder(sr, order, { couponCodes: codes });
|
|
324
|
+
await sr.entities["commerce.Order"].update(order.id, fields);
|
|
325
|
+
Object.assign(order, fields);
|
|
326
|
+
await dispatch(sr, "order.updated", order);
|
|
327
|
+
return order;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function removeCoupon(sr: any, payload: any): Promise<any> {
|
|
331
|
+
const order = await getOrder(sr, payload.order_id);
|
|
332
|
+
if (isLocked(order)) throw new HttpError(409, "Coupons can only change while the order is pending or on-hold.", "order_locked");
|
|
333
|
+
const code = String(payload.code ?? "").toLowerCase().trim();
|
|
334
|
+
const codes = (order.coupon_lines ?? []).map((c: any) => c.code).filter((c: string) => c !== code);
|
|
335
|
+
const { fields } = await repriceOrder(sr, order, { couponCodes: codes });
|
|
336
|
+
await sr.entities["commerce.Order"].update(order.id, fields);
|
|
337
|
+
Object.assign(order, fields);
|
|
338
|
+
await dispatch(sr, "order.updated", order);
|
|
339
|
+
return order;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── notes / downloads / delete ───────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
async function addNote(sr: any, payload: any, actor: string): Promise<any> {
|
|
345
|
+
const order = await getOrder(sr, payload.order_id);
|
|
346
|
+
const note = await sr.entities["commerce.OrderNote"].create({
|
|
347
|
+
order_id: order.id,
|
|
348
|
+
note: payload.note ?? "",
|
|
349
|
+
is_customer_note: !!payload.is_customer_note,
|
|
350
|
+
added_by: actor,
|
|
351
|
+
});
|
|
352
|
+
if (payload.is_customer_note) {
|
|
353
|
+
await sendOrderEmail(sr, "customer_note", order, { force: true, extra: { note: payload.note } });
|
|
354
|
+
}
|
|
355
|
+
return note;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Grant download permissions for one downloadable product on the order. */
|
|
359
|
+
async function grantDownload(sr: any, payload: any): Promise<any> {
|
|
360
|
+
const order = await getOrder(sr, payload.order_id);
|
|
361
|
+
const product = await sr.entities["commerce.Product"].get(payload.product_id);
|
|
362
|
+
if (!product) throw new HttpError(404, "Product not found", "not_found");
|
|
363
|
+
if (!product.downloadable || !(product.downloads ?? []).length) {
|
|
364
|
+
throw new HttpError(400, "Product has no downloadable files.", "not_downloadable");
|
|
365
|
+
}
|
|
366
|
+
const expiryDays = product.download_expiry ?? -1;
|
|
367
|
+
const created: any[] = [];
|
|
368
|
+
for (const dl of product.downloads) {
|
|
369
|
+
created.push(await sr.entities["commerce.DownloadPermission"].create({
|
|
370
|
+
order_id: order.id,
|
|
371
|
+
order_key: order.order_key ?? "",
|
|
372
|
+
customer_email: order.billing?.email ?? "",
|
|
373
|
+
product_id: product.id,
|
|
374
|
+
variation_id: "",
|
|
375
|
+
download_name: dl.name ?? "Download",
|
|
376
|
+
file_url: dl.file_url ?? "",
|
|
377
|
+
downloads_remaining: product.download_limit ?? -1,
|
|
378
|
+
access_expires: expiryDays > 0 ? new Date(Date.now() + expiryDays * 86_400_000).toISOString() : null,
|
|
379
|
+
download_count: 0,
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
return { granted: created };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function remove(sr: any, orderId: string): Promise<any> {
|
|
386
|
+
const order = await getOrder(sr, orderId);
|
|
387
|
+
if (!["pending", "cancelled", "failed"].includes(order.status)) {
|
|
388
|
+
throw new HttpError(409, `Only pending, cancelled or failed orders can be deleted (current: ${order.status}).`, "order_locked");
|
|
389
|
+
}
|
|
390
|
+
await sr.entities["commerce.OrderNote"].deleteMany({ order_id: order.id }).catch(() => {});
|
|
391
|
+
await sr.entities["commerce.OrderRefund"].deleteMany({ order_id: order.id }).catch(() => {});
|
|
392
|
+
await sr.entities["commerce.DownloadPermission"].deleteMany({ order_id: order.id }).catch(() => {});
|
|
393
|
+
await sr.entities["commerce.Order"].delete(order.id);
|
|
394
|
+
await dispatch(sr, "order.deleted", order);
|
|
395
|
+
return { deleted: order.id };
|
|
396
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commerce/admin-orders internals: catalog loading, item resolution and the
|
|
3
|
+
* order (re)pricing pipeline shared by create / update / recalculate /
|
|
4
|
+
* apply-coupon / remove-coupon.
|
|
5
|
+
*/
|
|
6
|
+
import { calculateTotals } from "../../../shared/commerce/totals.ts";
|
|
7
|
+
import { getSettings } from "../../../shared/commerce/settings.ts";
|
|
8
|
+
import { scanAll } from "../../../shared/commerce/scan.ts";
|
|
9
|
+
import { HttpError } from "../../../shared/commerce/auth.ts";
|
|
10
|
+
import { round2 } from "../../../shared/commerce/money.ts";
|
|
11
|
+
import { uuid } from "../../../shared/commerce/sequence.ts";
|
|
12
|
+
|
|
13
|
+
export interface PricingContext {
|
|
14
|
+
settings: Record<string, any>;
|
|
15
|
+
taxRates: any[];
|
|
16
|
+
zones: any[];
|
|
17
|
+
zoneMethods: any[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Everything calculateTotals needs from the catalog, in one fetch. */
|
|
21
|
+
export async function loadPricingContext(sr: any): Promise<PricingContext> {
|
|
22
|
+
const [settings, taxRates, zones, zoneMethods] = await Promise.all([
|
|
23
|
+
getSettings(sr),
|
|
24
|
+
scanAll(sr.entities["commerce.TaxRate"], null, "menu_order"),
|
|
25
|
+
scanAll(sr.entities["commerce.ShippingZone"], null, "order"),
|
|
26
|
+
scanAll(sr.entities["commerce.ShippingZoneMethod"], null, "order"),
|
|
27
|
+
]);
|
|
28
|
+
return { settings, taxRates, zones, zoneMethods };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ItemSpec {
|
|
32
|
+
product_id: string;
|
|
33
|
+
variation_id?: string;
|
|
34
|
+
quantity: number;
|
|
35
|
+
/** Admin manual unit price (ex-tax). Overrides the catalog price. */
|
|
36
|
+
price_override?: number;
|
|
37
|
+
attributes?: Array<{ name: string; option: string }>;
|
|
38
|
+
meta_data?: Array<{ key: string; value: string }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve item specs to {product, variation, quantity} inputs for the totals
|
|
43
|
+
* engine. price_override works by cloning the priced record — the engine
|
|
44
|
+
* reads `.price` off the variation ?? product.
|
|
45
|
+
*/
|
|
46
|
+
export async function resolveItems(sr: any, items: ItemSpec[]): Promise<any[]> {
|
|
47
|
+
const out: any[] = [];
|
|
48
|
+
for (const spec of items ?? []) {
|
|
49
|
+
if (!spec.product_id || !spec.quantity) continue;
|
|
50
|
+
const product = await sr.entities["commerce.Product"].get(spec.product_id);
|
|
51
|
+
if (!product) throw new HttpError(400, `Product ${spec.product_id} not found`, "product_not_found");
|
|
52
|
+
let variation: any = undefined;
|
|
53
|
+
if (spec.variation_id) {
|
|
54
|
+
variation = await sr.entities["commerce.ProductVariation"].get(spec.variation_id);
|
|
55
|
+
if (!variation) throw new HttpError(400, `Variation ${spec.variation_id} not found`, "variation_not_found");
|
|
56
|
+
}
|
|
57
|
+
if (spec.price_override != null) {
|
|
58
|
+
if (variation) variation = { ...variation, price: round2(Number(spec.price_override)) };
|
|
59
|
+
else Object.assign(product, { price: round2(Number(spec.price_override)) });
|
|
60
|
+
}
|
|
61
|
+
out.push({
|
|
62
|
+
product,
|
|
63
|
+
variation,
|
|
64
|
+
quantity: Number(spec.quantity),
|
|
65
|
+
attributes: spec.attributes ?? variation?.attributes?.map((a: any) => ({ name: a.name, option: a.option })) ?? [],
|
|
66
|
+
meta_data: spec.meta_data ?? [],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Load Coupon records for a list of codes (unknown codes are skipped). */
|
|
73
|
+
export async function resolveCoupons(sr: any, codes: string[]): Promise<any[]> {
|
|
74
|
+
const out: any[] = [];
|
|
75
|
+
for (const code of codes ?? []) {
|
|
76
|
+
const hits = (await sr.entities["commerce.Coupon"].filter({ code: String(code).toLowerCase() }, undefined, 1)) ?? [];
|
|
77
|
+
if (hits[0]) out.push(hits[0]);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function gatewayTitle(sr: any, slug: string): Promise<string> {
|
|
83
|
+
if (!slug) return "";
|
|
84
|
+
const hits = (await sr.entities["commerce.PaymentGateway"].filter({ slug }, undefined, 1)) ?? [];
|
|
85
|
+
return hits[0]?.title ?? slug;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Map a TotalsResult (+ existing order fields) onto Order entity fields. */
|
|
89
|
+
export function totalsToOrderFields(totals: any): Record<string, any> {
|
|
90
|
+
return {
|
|
91
|
+
prices_include_tax: totals.prices_include_tax,
|
|
92
|
+
line_items: totals.line_items,
|
|
93
|
+
shipping_lines: totals.shipping_lines,
|
|
94
|
+
tax_lines: totals.tax_lines,
|
|
95
|
+
fee_lines: totals.fee_lines,
|
|
96
|
+
coupon_lines: totals.coupon_lines,
|
|
97
|
+
subtotal: totals.subtotal,
|
|
98
|
+
discount_total: totals.discount_total,
|
|
99
|
+
discount_tax: totals.discount_tax,
|
|
100
|
+
shipping_total: totals.shipping_total,
|
|
101
|
+
shipping_tax: totals.shipping_tax,
|
|
102
|
+
cart_tax: totals.cart_tax,
|
|
103
|
+
total_tax: totals.total_tax,
|
|
104
|
+
total: totals.total,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface RepriceOpts {
|
|
109
|
+
/** Item specs; defaults to the order's current line_items (catalog-priced,
|
|
110
|
+
* preserving stored unit price as an override so manual pricing survives). */
|
|
111
|
+
items?: ItemSpec[];
|
|
112
|
+
/** Coupon codes; defaults to the order's current coupon_lines codes. */
|
|
113
|
+
couponCodes?: string[];
|
|
114
|
+
/** Fees; defaults to the order's current fee_lines. */
|
|
115
|
+
fees?: Array<{ name: string; amount: number; tax_class?: string; tax_status?: string }>;
|
|
116
|
+
billing?: any;
|
|
117
|
+
shipping?: any;
|
|
118
|
+
chosenShippingMethodId?: string;
|
|
119
|
+
/**
|
|
120
|
+
* Admin manual shipping lines (arbitrary title + cost) that the zone/method
|
|
121
|
+
* engine can't represent. When provided (and no chosenShippingMethodId),
|
|
122
|
+
* these REPLACE zone-computed shipping verbatim. An empty array means "no
|
|
123
|
+
* shipping". Ignored when chosenShippingMethodId is set (that wins).
|
|
124
|
+
*/
|
|
125
|
+
manualShippingLines?: Array<{ method_title?: string; total: number; total_tax?: number; method_id?: string }>;
|
|
126
|
+
/** Re-price lines from the current catalog instead of stored unit prices. */
|
|
127
|
+
repriceFromCatalog?: boolean;
|
|
128
|
+
ctx?: PricingContext;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Re-run the totals engine for an order and return {fields, totals, coupons}.
|
|
133
|
+
* Preserves the order's shipping line when its zone-method no longer resolves
|
|
134
|
+
* (manual/legacy shipping) by re-adding it verbatim and adjusting totals.
|
|
135
|
+
*/
|
|
136
|
+
export async function repriceOrder(sr: any, order: any, opts: RepriceOpts = {}): Promise<{ fields: Record<string, any>; totals: any; coupons: any[] }> {
|
|
137
|
+
const ctx = opts.ctx ?? (await loadPricingContext(sr));
|
|
138
|
+
|
|
139
|
+
const itemSpecs: ItemSpec[] = opts.items ?? (order.line_items ?? []).map((l: any) => ({
|
|
140
|
+
product_id: l.product_id,
|
|
141
|
+
variation_id: l.variation_id || undefined,
|
|
142
|
+
quantity: l.quantity,
|
|
143
|
+
// Use the PRE-discount unit price (subtotal/qty) as the override so coupons
|
|
144
|
+
// re-apply exactly once. `l.price` is post-discount, so using it here would
|
|
145
|
+
// double-apply discounts on every reprice. Fall back to l.price if subtotal
|
|
146
|
+
// is missing. Skipped entirely when repricing from the live catalog.
|
|
147
|
+
price_override: opts.repriceFromCatalog
|
|
148
|
+
? undefined
|
|
149
|
+
: (l.subtotal != null && l.quantity ? round2(l.subtotal / l.quantity) : l.price),
|
|
150
|
+
attributes: l.attributes,
|
|
151
|
+
meta_data: l.meta_data,
|
|
152
|
+
}));
|
|
153
|
+
const items = await resolveItems(sr, itemSpecs);
|
|
154
|
+
|
|
155
|
+
const couponCodes = opts.couponCodes ?? (order.coupon_lines ?? []).map((c: any) => c.code);
|
|
156
|
+
const coupons = await resolveCoupons(sr, couponCodes);
|
|
157
|
+
|
|
158
|
+
const fees = opts.fees ?? (order.fee_lines ?? []).map((f: any) => ({
|
|
159
|
+
name: f.name,
|
|
160
|
+
amount: f.total,
|
|
161
|
+
tax_class: f.tax_class,
|
|
162
|
+
tax_status: f.tax_status,
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
// Manual shipping mode: caller passed explicit lines and no zone method.
|
|
166
|
+
const manualMode = opts.manualShippingLines !== undefined && !opts.chosenShippingMethodId;
|
|
167
|
+
const chosen = opts.chosenShippingMethodId ??
|
|
168
|
+
(manualMode ? undefined : order.shipping_lines?.[0]?.instance_id) ?? undefined;
|
|
169
|
+
|
|
170
|
+
const totals = calculateTotals({
|
|
171
|
+
items,
|
|
172
|
+
coupons,
|
|
173
|
+
fees,
|
|
174
|
+
billing: opts.billing ?? order.billing,
|
|
175
|
+
shipping_address: opts.shipping ?? order.shipping ?? order.billing,
|
|
176
|
+
chosenShippingMethodId: chosen,
|
|
177
|
+
settings: ctx.settings,
|
|
178
|
+
taxRates: ctx.taxRates,
|
|
179
|
+
zones: ctx.zones,
|
|
180
|
+
zoneMethods: ctx.zoneMethods,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (manualMode) {
|
|
184
|
+
// Replace zone-computed shipping (empty, since chosen is undefined) with the
|
|
185
|
+
// admin's manual lines verbatim and fold their cost/tax into the totals.
|
|
186
|
+
const manual = (opts.manualShippingLines ?? []).map((s) => ({
|
|
187
|
+
line_id: uuid(),
|
|
188
|
+
method_id: s.method_id ?? "manual",
|
|
189
|
+
instance_id: "",
|
|
190
|
+
method_title: s.method_title ?? "Shipping",
|
|
191
|
+
total: round2(Number(s.total) || 0),
|
|
192
|
+
total_tax: round2(Number(s.total_tax) || 0),
|
|
193
|
+
taxes: [],
|
|
194
|
+
}));
|
|
195
|
+
const mTotal = round2(manual.reduce((a, s) => a + s.total, 0));
|
|
196
|
+
const mTax = round2(manual.reduce((a, s) => a + s.total_tax, 0));
|
|
197
|
+
totals.shipping_lines = manual;
|
|
198
|
+
totals.shipping_total = mTotal;
|
|
199
|
+
totals.shipping_tax = mTax;
|
|
200
|
+
totals.total_tax = round2(totals.total_tax + mTax);
|
|
201
|
+
totals.total = round2(totals.total + mTotal + mTax);
|
|
202
|
+
} else if (!totals.shipping_lines.length && (order.shipping_lines ?? []).length && !opts.chosenShippingMethodId) {
|
|
203
|
+
// legacy/manual shipping line: zone method no longer offered → keep it as-is
|
|
204
|
+
const kept = order.shipping_lines;
|
|
205
|
+
const keptTotal = round2(kept.reduce((a: number, s: any) => a + (s.total ?? 0), 0));
|
|
206
|
+
const keptTax = round2(kept.reduce((a: number, s: any) => a + (s.total_tax ?? 0), 0));
|
|
207
|
+
totals.shipping_lines = kept;
|
|
208
|
+
totals.shipping_total = keptTotal;
|
|
209
|
+
totals.shipping_tax = round2(totals.shipping_tax + keptTax);
|
|
210
|
+
totals.total_tax = round2(totals.total_tax + keptTax);
|
|
211
|
+
totals.total = round2(totals.total + keptTotal + keptTax);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { fields: totalsToOrderFields(totals), totals, coupons };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Line-level fields an admin may only touch while the order is unlocked. */
|
|
218
|
+
export const LINE_FIELDS = ["items", "fees", "coupon_codes", "chosen_shipping_method", "shipping_lines"];
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Translate an `update` patch into RepriceOpts. Shared by `update` and the
|
|
222
|
+
* read-only `preview` action so a preview is priced by exactly the same call
|
|
223
|
+
* that saving would make — the admin UI can show live totals for unsaved edits
|
|
224
|
+
* without duplicating any of the pricing rules on the client.
|
|
225
|
+
*/
|
|
226
|
+
export function repriceOptsFromPatch(order: any, patch: Record<string, any>): RepriceOpts {
|
|
227
|
+
return {
|
|
228
|
+
items: patch.items,
|
|
229
|
+
couponCodes: patch.coupon_codes,
|
|
230
|
+
fees: patch.fees,
|
|
231
|
+
billing: patch.billing ?? order.billing,
|
|
232
|
+
shipping: patch.shipping ?? order.shipping,
|
|
233
|
+
chosenShippingMethodId: patch.chosen_shipping_method,
|
|
234
|
+
manualShippingLines: patch.shipping_lines !== undefined
|
|
235
|
+
? (patch.shipping_lines ?? []).map((s: any) => ({
|
|
236
|
+
method_title: s.method_title,
|
|
237
|
+
total: s.total,
|
|
238
|
+
total_tax: s.total_tax,
|
|
239
|
+
}))
|
|
240
|
+
: undefined,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function isLocked(order: any): boolean {
|
|
245
|
+
return !["pending", "on-hold"].includes(order.status);
|
|
246
|
+
}
|