@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,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commerce/admin-products — product + variation mutations with side effects
|
|
3
|
+
* (derived pricing, stock status, taxonomy counts, webhooks).
|
|
4
|
+
*
|
|
5
|
+
* Actions: save | delete | batch | duplicate | set-stock | search |
|
|
6
|
+
* save-term | delete-term | list-terms
|
|
7
|
+
* Body: { action, ...payload } — see skills/commerce/docs/api-admin.md.
|
|
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 { deriveStockStatus } from "../../../shared/commerce/stock.ts";
|
|
13
|
+
import { sendStockEmail } from "../../../shared/commerce/emails.ts";
|
|
14
|
+
import { dispatch } from "../../../shared/commerce/webhooks.ts";
|
|
15
|
+
import { pageSlice, scanAll, textMatch } from "../../../shared/commerce/scan.ts";
|
|
16
|
+
import { round2 } from "../../../shared/commerce/money.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
|
+
const admin = await requireAdmin(base44);
|
|
26
|
+
const sr = base44.asServiceRole;
|
|
27
|
+
const { action, ...payload } = await req.json();
|
|
28
|
+
|
|
29
|
+
switch (action) {
|
|
30
|
+
case "save":
|
|
31
|
+
return ok(await save(sr, payload));
|
|
32
|
+
case "delete":
|
|
33
|
+
return ok(await remove(sr, payload.id));
|
|
34
|
+
case "batch":
|
|
35
|
+
return ok(await batch(sr, payload));
|
|
36
|
+
case "duplicate":
|
|
37
|
+
return ok(await duplicate(sr, payload.id));
|
|
38
|
+
case "set-stock":
|
|
39
|
+
return ok(await setStock(sr, payload));
|
|
40
|
+
case "search":
|
|
41
|
+
return ok(await search(sr, payload));
|
|
42
|
+
case "save-term":
|
|
43
|
+
return ok(await saveTerm(sr, payload));
|
|
44
|
+
case "delete-term":
|
|
45
|
+
return ok(await deleteTerm(sr, payload));
|
|
46
|
+
case "list-terms":
|
|
47
|
+
return ok(await listTerms(sr, payload));
|
|
48
|
+
default:
|
|
49
|
+
return fail(400, `Unknown action: ${action}`, "unknown_action");
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
if (e instanceof HttpError) return fail(e.status, e.message, e.code);
|
|
53
|
+
console.error("commerce/admin-products error:", e);
|
|
54
|
+
return fail(500, (e as Error).message ?? "Internal error");
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── derivations ──────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function slugify(name: string): string {
|
|
61
|
+
return String(name || "")
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.trim()
|
|
64
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
65
|
+
.replace(/^-+|-+$/g, "") || "product";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** price/on_sale from the sale window. Mutates rec. */
|
|
69
|
+
function derivePricing(rec: any): void {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const from = rec.date_on_sale_from ? new Date(rec.date_on_sale_from).getTime() : -Infinity;
|
|
72
|
+
const to = rec.date_on_sale_to ? new Date(rec.date_on_sale_to).getTime() : Infinity;
|
|
73
|
+
const saleActive = rec.sale_price != null && rec.sale_price !== "" && now >= from && now <= to;
|
|
74
|
+
rec.on_sale = !!saleActive;
|
|
75
|
+
const effective = saleActive ? rec.sale_price : rec.regular_price;
|
|
76
|
+
if (effective != null && effective !== "") rec.price = round2(Number(effective));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** stock_status derivation when stock is managed. Mutates rec. */
|
|
80
|
+
function deriveStock(rec: any, outThreshold: number, managed: boolean): void {
|
|
81
|
+
if (managed) {
|
|
82
|
+
rec.stock_status = deriveStockStatus(
|
|
83
|
+
Number(rec.stock_quantity ?? 0),
|
|
84
|
+
rec.backorders ?? "no",
|
|
85
|
+
outThreshold,
|
|
86
|
+
);
|
|
87
|
+
} else if (!rec.stock_status) {
|
|
88
|
+
rec.stock_status = "instock";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── uniqueness ───────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
async function ensureUniqueSlug(
|
|
95
|
+
sr: any,
|
|
96
|
+
slug: string,
|
|
97
|
+
selfId?: string,
|
|
98
|
+
entity = "commerce.Product",
|
|
99
|
+
scope?: Record<string, any>,
|
|
100
|
+
): Promise<string> {
|
|
101
|
+
let candidate = slug;
|
|
102
|
+
for (let i = 2; i < 100; i++) {
|
|
103
|
+
const hits = (await sr.entities[entity].filter({ ...(scope ?? {}), slug: candidate }, undefined, 2)) ?? [];
|
|
104
|
+
if (!hits.some((p: any) => p.id !== selfId)) return candidate;
|
|
105
|
+
candidate = `${slug}-${i}`;
|
|
106
|
+
}
|
|
107
|
+
return `${slug}-${crypto.randomUUID().slice(0, 6)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** SKU must be unique across products AND variations. Throws duplicate_sku. */
|
|
111
|
+
async function assertUniqueSku(sr: any, sku: string, opts: { productId?: string; variationId?: string }): Promise<void> {
|
|
112
|
+
if (!sku) return;
|
|
113
|
+
const prods = (await sr.entities["commerce.Product"].filter({ sku }, undefined, 2)) ?? [];
|
|
114
|
+
if (prods.some((p: any) => p.id !== opts.productId)) {
|
|
115
|
+
throw new HttpError(409, `SKU "${sku}" is already in use by another product.`, "duplicate_sku");
|
|
116
|
+
}
|
|
117
|
+
const vars = (await sr.entities["commerce.ProductVariation"].filter({ sku }, undefined, 2)) ?? [];
|
|
118
|
+
if (vars.some((v: any) => v.id !== opts.variationId)) {
|
|
119
|
+
throw new HttpError(409, `SKU "${sku}" is already in use by a product variation.`, "duplicate_sku");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── taxonomy counts ──────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
async function adjustTermCounts(sr: any, entity: string, prevIds: string[], nextIds: string[]): Promise<void> {
|
|
126
|
+
const added = nextIds.filter((id) => !prevIds.includes(id));
|
|
127
|
+
const removed = prevIds.filter((id) => !nextIds.includes(id));
|
|
128
|
+
for (const id of added) await bumpCount(sr, entity, id, +1);
|
|
129
|
+
for (const id of removed) await bumpCount(sr, entity, id, -1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function bumpCount(sr: any, entity: string, id: string, delta: number): Promise<void> {
|
|
133
|
+
try {
|
|
134
|
+
const rec = await sr.entities[entity].get(id);
|
|
135
|
+
if (rec) await sr.entities[entity].update(id, { count: Math.max(0, (rec.count ?? 0) + delta) });
|
|
136
|
+
} catch { /* stale reference — recount-terms repairs */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── save ─────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
async function save(sr: any, payload: any): Promise<any> {
|
|
142
|
+
const { product, variations } = payload;
|
|
143
|
+
if (!product || (!product.id && !product.name)) {
|
|
144
|
+
throw new HttpError(400, "product.name is required", "invalid_payload");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const settings = await getSettings(sr, "inventory");
|
|
148
|
+
const outThreshold = Number(settings.inventory?.out_of_stock_threshold ?? 0);
|
|
149
|
+
|
|
150
|
+
const prev = product.id ? await sr.entities["commerce.Product"].get(product.id) : null;
|
|
151
|
+
if (product.id && !prev) throw new HttpError(404, "Product not found", "not_found");
|
|
152
|
+
|
|
153
|
+
const rec = { ...(prev ?? {}), ...product };
|
|
154
|
+
if (!rec.slug) rec.slug = slugify(rec.name);
|
|
155
|
+
rec.slug = await ensureUniqueSlug(sr, slugify(rec.slug), rec.id);
|
|
156
|
+
await assertUniqueSku(sr, rec.sku, { productId: rec.id });
|
|
157
|
+
derivePricing(rec);
|
|
158
|
+
deriveStock(rec, outThreshold, !!rec.manage_stock && rec.type !== "variable");
|
|
159
|
+
|
|
160
|
+
const { id: _id, created_date: _cd, updated_date: _ud, created_by: _cb, ...fields } = rec;
|
|
161
|
+
let saved: any;
|
|
162
|
+
if (prev) {
|
|
163
|
+
await sr.entities["commerce.Product"].update(prev.id, fields);
|
|
164
|
+
saved = { ...prev, ...fields };
|
|
165
|
+
} else {
|
|
166
|
+
saved = await sr.entities["commerce.Product"].create(fields);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
await adjustTermCounts(sr, "commerce.ProductCategory", prev?.category_ids ?? [], saved.category_ids ?? []);
|
|
170
|
+
await adjustTermCounts(sr, "commerce.ProductTag", prev?.tag_ids ?? [], saved.tag_ids ?? []);
|
|
171
|
+
if ((prev?.shipping_class_id ?? "") !== (saved.shipping_class_id ?? "")) {
|
|
172
|
+
if (prev?.shipping_class_id) await bumpCount(sr, "commerce.ShippingClass", prev.shipping_class_id, -1);
|
|
173
|
+
if (saved.shipping_class_id) await bumpCount(sr, "commerce.ShippingClass", saved.shipping_class_id, +1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// variations diff — only when the caller sends a variations array
|
|
177
|
+
let savedVariations: any[] | undefined;
|
|
178
|
+
if (Array.isArray(variations)) {
|
|
179
|
+
savedVariations = await diffVariations(sr, saved, variations, outThreshold);
|
|
180
|
+
await rollUpParentStock(sr, saved, savedVariations);
|
|
181
|
+
} else if (saved.type === "variable") {
|
|
182
|
+
const existing = await scanAll(sr.entities["commerce.ProductVariation"], { product_id: saved.id });
|
|
183
|
+
await rollUpParentStock(sr, saved, existing);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await dispatch(sr, prev ? "product.updated" : "product.created", saved);
|
|
187
|
+
return { product: saved, variations: savedVariations };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function diffVariations(sr: any, parent: any, incoming: any[], outThreshold: number): Promise<any[]> {
|
|
191
|
+
const existing = await scanAll(sr.entities["commerce.ProductVariation"], { product_id: parent.id });
|
|
192
|
+
const incomingIds = new Set(incoming.filter((v) => v.id).map((v) => v.id));
|
|
193
|
+
const out: any[] = [];
|
|
194
|
+
|
|
195
|
+
for (const stale of existing.filter((v: any) => !incomingIds.has(v.id))) {
|
|
196
|
+
await sr.entities["commerce.ProductVariation"].delete(stale.id);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const v of incoming) {
|
|
200
|
+
const prev = v.id ? existing.find((e: any) => e.id === v.id) : null;
|
|
201
|
+
const rec = { ...(prev ?? {}), ...v, product_id: parent.id };
|
|
202
|
+
await assertUniqueSku(sr, rec.sku, { variationId: rec.id });
|
|
203
|
+
derivePricing(rec);
|
|
204
|
+
deriveStock(rec, outThreshold, (rec.manage_stock ?? "parent") === "yes");
|
|
205
|
+
const { id: _id, created_date: _cd, updated_date: _ud, created_by: _cb, ...fields } = rec;
|
|
206
|
+
if (prev) {
|
|
207
|
+
await sr.entities["commerce.ProductVariation"].update(prev.id, fields);
|
|
208
|
+
out.push({ ...prev, ...fields });
|
|
209
|
+
} else {
|
|
210
|
+
out.push(await sr.entities["commerce.ProductVariation"].create(fields));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Variable parent shows outofstock only when every purchasable variation is out. */
|
|
217
|
+
async function rollUpParentStock(sr: any, parent: any, variations: any[]): Promise<void> {
|
|
218
|
+
if (parent.type !== "variable" || parent.manage_stock) return;
|
|
219
|
+
const live = variations.filter((v) => (v.status ?? "publish") === "publish");
|
|
220
|
+
const status = live.length && live.every((v) => v.stock_status === "outofstock")
|
|
221
|
+
? "outofstock"
|
|
222
|
+
: "instock";
|
|
223
|
+
if (status !== parent.stock_status) {
|
|
224
|
+
await sr.entities["commerce.Product"].update(parent.id, { stock_status: status });
|
|
225
|
+
parent.stock_status = status;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── delete / batch / duplicate ───────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
async function remove(sr: any, id: string): Promise<any> {
|
|
232
|
+
const product = await sr.entities["commerce.Product"].get(id);
|
|
233
|
+
if (!product) throw new HttpError(404, "Product not found", "not_found");
|
|
234
|
+
|
|
235
|
+
const variations = await scanAll(sr.entities["commerce.ProductVariation"], { product_id: id });
|
|
236
|
+
for (const v of variations) await sr.entities["commerce.ProductVariation"].delete(v.id);
|
|
237
|
+
|
|
238
|
+
await adjustTermCounts(sr, "commerce.ProductCategory", product.category_ids ?? [], []);
|
|
239
|
+
await adjustTermCounts(sr, "commerce.ProductTag", product.tag_ids ?? [], []);
|
|
240
|
+
if (product.shipping_class_id) await bumpCount(sr, "commerce.ShippingClass", product.shipping_class_id, -1);
|
|
241
|
+
|
|
242
|
+
await sr.entities["commerce.Product"].delete(id);
|
|
243
|
+
await dispatch(sr, "product.deleted", product);
|
|
244
|
+
return { deleted: id, variations_deleted: variations.length };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function batch(sr: any, payload: any): Promise<any> {
|
|
248
|
+
const results = { create: [] as any[], update: [] as any[], delete: [] as any[] };
|
|
249
|
+
const cap = 100;
|
|
250
|
+
for (const p of (payload.create ?? []).slice(0, cap)) {
|
|
251
|
+
try { results.create.push({ success: true, ...(await save(sr, { product: p })) }); }
|
|
252
|
+
catch (e) { results.create.push({ success: false, error: (e as Error).message }); }
|
|
253
|
+
}
|
|
254
|
+
for (const p of (payload.update ?? []).slice(0, cap)) {
|
|
255
|
+
try { results.update.push({ success: true, ...(await save(sr, { product: p })) }); }
|
|
256
|
+
catch (e) { results.update.push({ success: false, id: p.id, error: (e as Error).message }); }
|
|
257
|
+
}
|
|
258
|
+
for (const id of (payload.delete ?? []).slice(0, cap)) {
|
|
259
|
+
try { results.delete.push({ success: true, ...(await remove(sr, id)) }); }
|
|
260
|
+
catch (e) { results.delete.push({ success: false, id, error: (e as Error).message }); }
|
|
261
|
+
}
|
|
262
|
+
return results;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function duplicate(sr: any, id: string): Promise<any> {
|
|
266
|
+
const source = await sr.entities["commerce.Product"].get(id);
|
|
267
|
+
if (!source) throw new HttpError(404, "Product not found", "not_found");
|
|
268
|
+
|
|
269
|
+
const { id: _id, created_date: _cd, updated_date: _ud, created_by: _cb, ...fields } = source;
|
|
270
|
+
const copy: any = {
|
|
271
|
+
...fields,
|
|
272
|
+
name: `${source.name} (Copy)`,
|
|
273
|
+
status: "draft",
|
|
274
|
+
total_sales: 0,
|
|
275
|
+
average_rating: 0,
|
|
276
|
+
rating_count: 0,
|
|
277
|
+
};
|
|
278
|
+
copy.slug = await ensureUniqueSlug(sr, slugify(copy.name));
|
|
279
|
+
if (copy.sku) {
|
|
280
|
+
copy.sku = `${copy.sku}-copy`;
|
|
281
|
+
try { await assertUniqueSku(sr, copy.sku, {}); }
|
|
282
|
+
catch { copy.sku = `${copy.sku}-${crypto.randomUUID().slice(0, 4)}`; }
|
|
283
|
+
}
|
|
284
|
+
const created = await sr.entities["commerce.Product"].create(copy);
|
|
285
|
+
|
|
286
|
+
const variations = await scanAll(sr.entities["commerce.ProductVariation"], { product_id: id });
|
|
287
|
+
let copied = 0;
|
|
288
|
+
for (const v of variations) {
|
|
289
|
+
const { id: _vi, created_date: _vc, updated_date: _vu, created_by: _vb, ...vf } = v;
|
|
290
|
+
const vCopy: any = { ...vf, product_id: created.id };
|
|
291
|
+
if (vCopy.sku) vCopy.sku = `${vCopy.sku}-copy-${crypto.randomUUID().slice(0, 4)}`;
|
|
292
|
+
await sr.entities["commerce.ProductVariation"].create(vCopy);
|
|
293
|
+
copied++;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// duplicated product participates in taxonomy counts too
|
|
297
|
+
await adjustTermCounts(sr, "commerce.ProductCategory", [], created.category_ids ?? []);
|
|
298
|
+
await adjustTermCounts(sr, "commerce.ProductTag", [], created.tag_ids ?? []);
|
|
299
|
+
if (created.shipping_class_id) await bumpCount(sr, "commerce.ShippingClass", created.shipping_class_id, +1);
|
|
300
|
+
|
|
301
|
+
await dispatch(sr, "product.created", created);
|
|
302
|
+
return { product: created, variations_copied: copied };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── set-stock ────────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
async function setStock(sr: any, payload: any): Promise<any> {
|
|
308
|
+
const { id, variation_id, quantity } = payload;
|
|
309
|
+
const product = await sr.entities["commerce.Product"].get(id);
|
|
310
|
+
if (!product) throw new HttpError(404, "Product not found", "not_found");
|
|
311
|
+
const variation = variation_id ? await sr.entities["commerce.ProductVariation"].get(variation_id) : null;
|
|
312
|
+
|
|
313
|
+
const settings = await getSettings(sr, "inventory", "emails", "general");
|
|
314
|
+
const inv = settings.inventory ?? {};
|
|
315
|
+
const outThreshold = Number(inv.out_of_stock_threshold ?? 0);
|
|
316
|
+
const target = variation ?? product;
|
|
317
|
+
const entity = variation ? "commerce.ProductVariation" : "commerce.Product";
|
|
318
|
+
const backorders = variation && (variation.manage_stock ?? "parent") === "yes"
|
|
319
|
+
? variation.backorders ?? "no"
|
|
320
|
+
: product.backorders ?? "no";
|
|
321
|
+
|
|
322
|
+
const before = Number(target.stock_quantity ?? 0);
|
|
323
|
+
const after = Number(quantity);
|
|
324
|
+
const status = deriveStockStatus(after, backorders, outThreshold);
|
|
325
|
+
await sr.entities[entity].update(target.id, { stock_quantity: after, stock_status: status });
|
|
326
|
+
|
|
327
|
+
const low = Number(target.low_stock_amount ?? inv.low_stock_threshold ?? 2);
|
|
328
|
+
if (after <= outThreshold && before > outThreshold) {
|
|
329
|
+
await sendStockEmail(sr, "out_of_stock", { ...product, stock_quantity: after }, { settings });
|
|
330
|
+
} else if (after <= low && before > low) {
|
|
331
|
+
await sendStockEmail(sr, "low_stock", { ...product, stock_quantity: after }, { settings });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
await dispatch(sr, "product.updated", { ...product, stock_quantity: variation ? product.stock_quantity : after });
|
|
335
|
+
return { id: target.id, stock_quantity: after, stock_status: status };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── search ───────────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
/** Category subtree ids (self + descendants) for the category filter. */
|
|
341
|
+
async function categoryWithDescendants(sr: any, rootId: string): Promise<Set<string>> {
|
|
342
|
+
const all = await scanAll(sr.entities["commerce.ProductCategory"], null, "menu_order");
|
|
343
|
+
const wanted = new Set<string>([rootId]);
|
|
344
|
+
let grew = true;
|
|
345
|
+
while (grew) {
|
|
346
|
+
grew = false;
|
|
347
|
+
for (const c of all) {
|
|
348
|
+
if (c.parent_id && wanted.has(c.parent_id) && !wanted.has(c.id)) {
|
|
349
|
+
wanted.add(c.id);
|
|
350
|
+
grew = true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return wanted;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function search(sr: any, payload: any): Promise<any> {
|
|
358
|
+
const { q, category_id, type, stock_status, status, sort = "-created_date", limit = 20, skip = 0 } = payload;
|
|
359
|
+
|
|
360
|
+
const query: Record<string, any> = {};
|
|
361
|
+
if (type) query.type = type;
|
|
362
|
+
if (status) query.status = status;
|
|
363
|
+
if (stock_status) query.stock_status = stock_status;
|
|
364
|
+
|
|
365
|
+
let rows = await scanAll(sr.entities["commerce.Product"], Object.keys(query).length ? query : null, sort);
|
|
366
|
+
if (q) {
|
|
367
|
+
rows = rows.filter((p) =>
|
|
368
|
+
textMatch(p.name, q) || textMatch(p.sku, q) || textMatch(p.description, q)
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
if (category_id) {
|
|
372
|
+
const cats = await categoryWithDescendants(sr, category_id);
|
|
373
|
+
rows = rows.filter((p) => (p.category_ids ?? []).some((c: string) => cats.has(c)));
|
|
374
|
+
}
|
|
375
|
+
return pageSlice(rows, Number(limit), Number(skip));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── taxonomy terms ───────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
const TAXONOMIES: Record<string, string> = {
|
|
381
|
+
category: "commerce.ProductCategory",
|
|
382
|
+
tag: "commerce.ProductTag",
|
|
383
|
+
attribute: "commerce.ProductAttribute",
|
|
384
|
+
"attribute-term": "commerce.ProductAttributeTerm",
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
function taxonomyEntity(taxonomy: string): string {
|
|
388
|
+
const entity = TAXONOMIES[String(taxonomy || "").toLowerCase().replace(/s$/, "")];
|
|
389
|
+
if (!entity) {
|
|
390
|
+
throw new HttpError(400, `taxonomy must be one of: ${Object.keys(TAXONOMIES).join(", ")}`, "invalid_taxonomy");
|
|
391
|
+
}
|
|
392
|
+
return entity;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Upsert a category, tag, attribute or attribute term. Exists so an agent (or
|
|
397
|
+
* any API caller) can create these — referencing one from a product is useless
|
|
398
|
+
* while the record itself can only be made in the admin UI. An attribute plus
|
|
399
|
+
* its terms is what a variable product's `attributes[].options` draws on.
|
|
400
|
+
*/
|
|
401
|
+
async function saveTerm(sr: any, payload: any): Promise<any> {
|
|
402
|
+
const entity = taxonomyEntity(payload.taxonomy);
|
|
403
|
+
const term = { ...(payload.term ?? {}) };
|
|
404
|
+
const name = String(term.name ?? "").trim();
|
|
405
|
+
if (!name) throw new HttpError(400, "term.name is required", "invalid_payload");
|
|
406
|
+
|
|
407
|
+
const fields: Record<string, any> = { name };
|
|
408
|
+
|
|
409
|
+
if (entity === "commerce.ProductAttributeTerm") {
|
|
410
|
+
// A term without its attribute is unreachable, and its slug is only required
|
|
411
|
+
// to be unique inside that attribute.
|
|
412
|
+
const attributeId = String(term.attribute_id ?? "").trim();
|
|
413
|
+
if (!attributeId) throw new HttpError(400, "term.attribute_id is required for an attribute term", "invalid_payload");
|
|
414
|
+
if (!(await sr.entities["commerce.ProductAttribute"].get(attributeId))) {
|
|
415
|
+
throw new HttpError(404, `Attribute ${attributeId} not found`, "not_found");
|
|
416
|
+
}
|
|
417
|
+
fields.attribute_id = attributeId;
|
|
418
|
+
fields.description = term.description ?? "";
|
|
419
|
+
fields.menu_order = Number(term.menu_order ?? 0) || 0;
|
|
420
|
+
fields.slug = await ensureUniqueSlug(sr, slugify(term.slug || name), term.id, entity, { attribute_id: attributeId });
|
|
421
|
+
} else if (entity === "commerce.ProductAttribute") {
|
|
422
|
+
fields.type = term.type ?? "select";
|
|
423
|
+
fields.order_by = term.order_by ?? "menu_order";
|
|
424
|
+
fields.has_archives = !!term.has_archives;
|
|
425
|
+
fields.slug = await ensureUniqueSlug(sr, slugify(term.slug || name), term.id, entity);
|
|
426
|
+
} else {
|
|
427
|
+
fields.description = term.description ?? "";
|
|
428
|
+
fields.slug = await ensureUniqueSlug(sr, slugify(term.slug || name), term.id, entity);
|
|
429
|
+
if (entity === "commerce.ProductCategory") {
|
|
430
|
+
fields.parent_id = term.parent_id ?? "";
|
|
431
|
+
if (term.image !== undefined) fields.image = term.image;
|
|
432
|
+
if (term.menu_order !== undefined) fields.menu_order = Number(term.menu_order) || 0;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (term.id) {
|
|
437
|
+
const existing = await sr.entities[entity].get(term.id);
|
|
438
|
+
if (!existing) throw new HttpError(404, "Term not found", "not_found");
|
|
439
|
+
// parent_id === own id would make the tree unwalkable (categoryWithDescendants loops on it).
|
|
440
|
+
if (fields.parent_id === term.id) fields.parent_id = "";
|
|
441
|
+
await sr.entities[entity].update(term.id, fields);
|
|
442
|
+
return { ...existing, ...fields, id: term.id };
|
|
443
|
+
}
|
|
444
|
+
const withCount = entity === "commerce.ProductAttribute" ? fields : { ...fields, count: 0 };
|
|
445
|
+
return await sr.entities[entity].create(withCount);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Delete a term. For a category or tag, products keep the id in
|
|
450
|
+
* category_ids/tag_ids — same as the admin UI, and the storefront skips ids that
|
|
451
|
+
* no longer resolve; detach: true strips it from every product instead.
|
|
452
|
+
* Deleting an attribute always takes its terms with it: a term outliving its
|
|
453
|
+
* attribute is unreachable.
|
|
454
|
+
*/
|
|
455
|
+
async function deleteTerm(sr: any, payload: any): Promise<any> {
|
|
456
|
+
const entity = taxonomyEntity(payload.taxonomy);
|
|
457
|
+
const id = String(payload.id ?? "");
|
|
458
|
+
if (!id) throw new HttpError(400, "id is required", "invalid_payload");
|
|
459
|
+
|
|
460
|
+
let detached = 0;
|
|
461
|
+
let terms_deleted = 0;
|
|
462
|
+
|
|
463
|
+
if (entity === "commerce.ProductAttribute") {
|
|
464
|
+
for (const t of await sr.entities["commerce.ProductAttributeTerm"].filter({ attribute_id: id }, undefined, 500) ?? []) {
|
|
465
|
+
await sr.entities["commerce.ProductAttributeTerm"].delete(t.id);
|
|
466
|
+
terms_deleted += 1;
|
|
467
|
+
}
|
|
468
|
+
if (payload.detach) {
|
|
469
|
+
for (const p of await scanAll(sr.entities["commerce.Product"], null, "-created_date")) {
|
|
470
|
+
if ((p.attributes ?? []).some((a: any) => a.attribute_id === id)) {
|
|
471
|
+
await sr.entities["commerce.Product"].update(p.id, {
|
|
472
|
+
attributes: p.attributes.filter((a: any) => a.attribute_id !== id),
|
|
473
|
+
});
|
|
474
|
+
detached += 1;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
} else if (entity !== "commerce.ProductAttributeTerm" && payload.detach) {
|
|
479
|
+
const field = entity === "commerce.ProductCategory" ? "category_ids" : "tag_ids";
|
|
480
|
+
for (const p of await scanAll(sr.entities["commerce.Product"], null, "-created_date")) {
|
|
481
|
+
if ((p[field] ?? []).includes(id)) {
|
|
482
|
+
await sr.entities["commerce.Product"].update(p.id, {
|
|
483
|
+
[field]: p[field].filter((x: string) => x !== id),
|
|
484
|
+
});
|
|
485
|
+
detached += 1;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
await sr.entities[entity].delete(id);
|
|
491
|
+
return { deleted: id, detached, terms_deleted };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Terms for the taxonomy, so a caller can reuse an existing one before creating a duplicate. */
|
|
495
|
+
async function listTerms(sr: any, payload: any): Promise<any> {
|
|
496
|
+
const entity = taxonomyEntity(payload.taxonomy);
|
|
497
|
+
const sort = entity === "commerce.ProductCategory" || entity === "commerce.ProductAttributeTerm"
|
|
498
|
+
? "menu_order"
|
|
499
|
+
: "name";
|
|
500
|
+
const query = entity === "commerce.ProductAttributeTerm" && payload.attribute_id
|
|
501
|
+
? { attribute_id: String(payload.attribute_id) }
|
|
502
|
+
: null;
|
|
503
|
+
let rows = await scanAll(sr.entities[entity], query, sort);
|
|
504
|
+
if (payload.q) rows = rows.filter((t: any) => textMatch(t.name, payload.q) || textMatch(t.slug, payload.q));
|
|
505
|
+
return pageSlice(rows, Number(payload.limit ?? 100), Number(payload.skip ?? 0));
|
|
506
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commerce/admin-refunds — create/delete order refunds with optional restock and
|
|
3
|
+
* (placeholder) gateway refunds.
|
|
4
|
+
*
|
|
5
|
+
* Actions: create | delete
|
|
6
|
+
*/
|
|
7
|
+
import { createClientFromRequest } from "npm:@base44/sdk";
|
|
8
|
+
import { HttpError, requireAdmin } from "../../../shared/commerce/auth.ts";
|
|
9
|
+
import { refundOnlinePayment } from "../../../shared/commerce/payments.ts";
|
|
10
|
+
import { round2 } from "../../../shared/commerce/money.ts";
|
|
11
|
+
import { restockLine } from "../../../shared/commerce/stock.ts";
|
|
12
|
+
import { transitionOrder } from "../../../shared/commerce/orders.ts";
|
|
13
|
+
import { sendOrderEmail } from "../../../shared/commerce/emails.ts";
|
|
14
|
+
import { dispatch } from "../../../shared/commerce/webhooks.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
|
+
const admin = await requireAdmin(base44);
|
|
24
|
+
const sr = base44.asServiceRole;
|
|
25
|
+
const { action, ...payload } = await req.json();
|
|
26
|
+
|
|
27
|
+
switch (action) {
|
|
28
|
+
case "create":
|
|
29
|
+
return ok(await create(sr, payload, admin.email ?? "admin"), 201);
|
|
30
|
+
case "delete":
|
|
31
|
+
return ok(await remove(sr, payload));
|
|
32
|
+
default:
|
|
33
|
+
return fail(400, `Unknown action: ${action}`, "unknown_action");
|
|
34
|
+
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e instanceof HttpError) return fail(e.status, e.message, e.code);
|
|
37
|
+
console.error("commerce/admin-refunds error:", e);
|
|
38
|
+
return fail(500, (e as Error).message ?? "Internal error");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* payload: {order_id, amount, reason?, line_items?: [{line_id, quantity,
|
|
44
|
+
* refund_total, refund_tax?}], restock_items?, refund_payment?}
|
|
45
|
+
*/
|
|
46
|
+
async function create(sr: any, payload: any, actor: string): Promise<any> {
|
|
47
|
+
const order = await sr.entities["commerce.Order"].get(payload.order_id);
|
|
48
|
+
if (!order) throw new HttpError(404, "Order not found", "not_found");
|
|
49
|
+
|
|
50
|
+
const amount = round2(Number(payload.amount) || 0);
|
|
51
|
+
if (amount <= 0) throw new HttpError(400, "Refund amount must be greater than zero.", "invalid_amount");
|
|
52
|
+
const alreadyRefunded = round2(order.total_refunded ?? 0);
|
|
53
|
+
const refundable = round2((order.total ?? 0) - alreadyRefunded);
|
|
54
|
+
if (amount > refundable + 0.005) {
|
|
55
|
+
throw new HttpError(400, `Refund amount exceeds the remaining refundable total (${refundable}).`, "amount_exceeds_refundable");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Refund the money first: a provider refund that fails must not leave a
|
|
59
|
+
// recorded refund behind, so nothing local is written until this succeeds.
|
|
60
|
+
// Returns null when the order wasn't paid online (manual gateways settle
|
|
61
|
+
// outside the store, so the record alone is the refund).
|
|
62
|
+
let gatewayRefund: Awaited<ReturnType<typeof refundOnlinePayment>> = null;
|
|
63
|
+
if (payload.refund_payment) {
|
|
64
|
+
gatewayRefund = await refundOnlinePayment(sr, order, {
|
|
65
|
+
amount,
|
|
66
|
+
reason: payload.reason,
|
|
67
|
+
idempotencyKey: `refund-${order.id}-${amount}-${alreadyRefunded}`,
|
|
68
|
+
});
|
|
69
|
+
if (!gatewayRefund) {
|
|
70
|
+
throw new HttpError(
|
|
71
|
+
400,
|
|
72
|
+
"This order has no online payment to refund — record the refund without `refund_payment`, and return the money the way it was taken.",
|
|
73
|
+
"no_online_payment",
|
|
74
|
+
{ payment_method: order.payment_method ?? "" },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// enrich refund line specs with product refs from the order's line items
|
|
80
|
+
const lineItems = (payload.line_items ?? []).map((spec: any) => {
|
|
81
|
+
const line = (order.line_items ?? []).find((l: any) => l.line_id === spec.line_id) ?? {};
|
|
82
|
+
return {
|
|
83
|
+
line_id: spec.line_id,
|
|
84
|
+
product_id: line.product_id ?? "",
|
|
85
|
+
variation_id: line.variation_id ?? "",
|
|
86
|
+
quantity: Number(spec.quantity) || 0,
|
|
87
|
+
refund_total: round2(Number(spec.refund_total) || 0),
|
|
88
|
+
refund_tax: round2(Number(spec.refund_tax) || 0),
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const refund = await sr.entities["commerce.OrderRefund"].create({
|
|
93
|
+
order_id: order.id,
|
|
94
|
+
amount,
|
|
95
|
+
reason: payload.reason ?? "",
|
|
96
|
+
refunded_by: actor,
|
|
97
|
+
refunded_payment: !!gatewayRefund, // true only when the provider really refunded
|
|
98
|
+
restock_items: !!payload.restock_items,
|
|
99
|
+
line_items: lineItems,
|
|
100
|
+
meta_data: [],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (payload.restock_items) {
|
|
104
|
+
for (const li of lineItems) {
|
|
105
|
+
if (li.quantity > 0) await restockLine(sr, li, li.quantity);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const totalRefunded = round2(alreadyRefunded + amount);
|
|
110
|
+
await sr.entities["commerce.Order"].update(order.id, { total_refunded: totalRefunded });
|
|
111
|
+
order.total_refunded = totalRefunded;
|
|
112
|
+
|
|
113
|
+
await sr.entities["commerce.OrderNote"].create({
|
|
114
|
+
order_id: order.id,
|
|
115
|
+
note: `Refund of ${amount} created${payload.reason ? ` — ${payload.reason}` : ""}.${payload.restock_items ? " Items restocked." : ""}`,
|
|
116
|
+
is_customer_note: false,
|
|
117
|
+
added_by: actor,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const fullyRefunded = totalRefunded >= round2(order.total ?? 0) - 0.005;
|
|
121
|
+
if (fullyRefunded && order.status !== "refunded") {
|
|
122
|
+
await transitionOrder(sr, order, "refunded", { actor });
|
|
123
|
+
} else {
|
|
124
|
+
await sendOrderEmail(sr, "partial_refund", order, { force: true, extra: { refund } });
|
|
125
|
+
await dispatch(sr, "order.updated", order);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (gatewayRefund) {
|
|
129
|
+
await sr.entities["commerce.OrderNote"].create({
|
|
130
|
+
order_id: order.id,
|
|
131
|
+
note: `Refunded ${amount} through ${gatewayRefund.provider} (${gatewayRefund.id}, ${gatewayRefund.status}).`,
|
|
132
|
+
is_customer_note: false,
|
|
133
|
+
added_by: actor,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return { refund, order, ...(gatewayRefund ? { gateway_refund: gatewayRefund } : {}) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function remove(sr: any, payload: any): Promise<any> {
|
|
140
|
+
const refund = await sr.entities["commerce.OrderRefund"].get(payload.refund_id);
|
|
141
|
+
if (!refund) throw new HttpError(404, "Refund not found", "not_found");
|
|
142
|
+
const order = await sr.entities["commerce.Order"].get(refund.order_id);
|
|
143
|
+
|
|
144
|
+
if (order) {
|
|
145
|
+
const totalRefunded = round2(Math.max(0, (order.total_refunded ?? 0) - (refund.amount ?? 0)));
|
|
146
|
+
await sr.entities["commerce.Order"].update(order.id, { total_refunded: totalRefunded });
|
|
147
|
+
await sr.entities["commerce.OrderNote"].create({
|
|
148
|
+
order_id: order.id,
|
|
149
|
+
note: `Refund of ${refund.amount} deleted. Note: restocked items (if any) were NOT un-restocked — adjust stock manually if needed.`,
|
|
150
|
+
is_customer_note: false,
|
|
151
|
+
added_by: "system",
|
|
152
|
+
});
|
|
153
|
+
await dispatch(sr, "order.updated", { ...order, total_refunded: totalRefunded });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await sr.entities["commerce.OrderRefund"].delete(refund.id);
|
|
157
|
+
return { deleted: refund.id };
|
|
158
|
+
}
|