@base44/app-plugin-commerce 0.1.6 → 0.1.7
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/README.md +11 -11
- package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
- package/base44/entities/commerce.Cart.jsonc +1 -1
- package/base44/entities/commerce.Coupon.jsonc +5 -0
- package/base44/entities/commerce.Order.jsonc +6 -7
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Product.jsonc +6 -16
- package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
- package/base44/entities/commerce.ProductVariation.jsonc +1 -9
- package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
- package/base44/functions/commerce/admin-products/entry.ts +11 -17
- package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
- package/base44/functions/commerce/admin-reports/entry.ts +3 -3
- package/base44/functions/commerce/admin-tools/entry.ts +9 -36
- package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
- package/base44/functions/commerce/payments/entry.ts +46 -42
- package/base44/functions/commerce/seed-store/defaults.ts +35 -42
- package/base44/functions/commerce/seed-store/entry.ts +68 -46
- package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
- package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
- package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
- package/base44/shared/commerce/card-payment.ts +80 -0
- package/base44/shared/commerce/coupons.ts +16 -10
- package/base44/shared/commerce/emails.ts +30 -18
- package/base44/shared/commerce/money.ts +11 -24
- package/base44/shared/commerce/payments.ts +55 -286
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +1 -1
- package/base44/shared/commerce/settings.ts +4 -9
- package/base44/shared/commerce/shipping.ts +65 -133
- package/base44/shared/commerce/tax.ts +48 -96
- package/base44/shared/commerce/totals.ts +77 -91
- package/package.json +1 -1
- package/scripts/install.js +28 -7
- package/skills/commerce/SKILL.md +14 -14
- package/skills/commerce/docs/api-admin.md +23 -26
- package/skills/commerce/docs/api-storefront.md +67 -61
- package/skills/commerce/installation-guidelines.md +8 -8
- package/skills/commerce/post-installation.md +60 -39
- package/skills/commerce/references/admin-product-form.md +15 -12
- package/skills/commerce/references/emails.md +2 -2
- package/skills/commerce/references/guest-access-security.md +2 -2
- package/skills/commerce/references/online-payments.md +24 -166
- package/skills/commerce/references/product-render.md +18 -18
- package/skills/commerce/references/reviews.md +14 -8
- package/skills/commerce/references/storefront-product-page.md +1 -1
- package/src/commerce/admin/README.md +4 -5
- package/src/commerce/admin/bot/Markdown.jsx +1 -1
- package/src/commerce/admin/hooks/useMoney.js +13 -22
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/lib/constants.js +2 -29
- package/src/commerce/admin/lib/order-utils.js +1 -1
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
- package/src/commerce/admin/pages/products/Categories.jsx +147 -177
- package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
- package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
- package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
- package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
- package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
- package/src/commerce/admin/routes.jsx +6 -12
- package/src/commerce/utils/index.js +2 -2
- package/src/commerce/utils/shipping-promos.js +45 -49
- package/src/commerce/utils/variants.js +1 -1
- package/base44/entities/commerce.ShippingClass.jsonc +0 -30
- package/base44/entities/commerce.ShippingZone.jsonc +0 -41
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
- package/base44/entities/commerce.TaxClass.jsonc +0 -23
- package/base44/entities/commerce.TaxRate.jsonc +0 -68
- package/base44/shared/commerce/stripe.ts +0 -463
- package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -280
|
@@ -5,31 +5,32 @@
|
|
|
5
5
|
* per-field errors before anything runs), (2) canary schema validation (probe
|
|
6
6
|
* create+delete per entity; abort with 422 schema_incompatible and write
|
|
7
7
|
* nothing on any failure), (3) seed required defaults (settings groups,
|
|
8
|
-
* gateways,
|
|
9
|
-
*
|
|
8
|
+
* gateways, and — only when the payload carries no `locations` — the fallback
|
|
9
|
+
* Shipping & Tax Location, so caller locations are the store's only shipping
|
|
10
|
+
* data), (4) the catalog: either the
|
|
11
|
+
* caller's `products`/`coupons`/`locations` (taxonomy get-or-created by name,
|
|
10
12
|
* variations generated from `attributes` when not given, per-product skip when
|
|
11
13
|
* the sku/slug already exists) or the generic demo catalog
|
|
12
14
|
* (with_sample_data=true, only when the store has zero products) — with
|
|
13
15
|
* best-effort rollback on mid-failure. See seed-catalog.ts for the pipeline.
|
|
14
16
|
*
|
|
15
|
-
* Body: { store_name?, currency?,
|
|
17
|
+
* Body: { store_name?, currency?, weight_unit?, dimension_unit?,
|
|
18
|
+
* with_sample_data?, products?, coupons?, locations? }
|
|
16
19
|
* — with_sample_data cannot be combined with the catalog keys.
|
|
17
20
|
*
|
|
18
21
|
* `store_name` is required on a first seed: a function's env is only
|
|
19
22
|
* BASE44_APP_ID, so it cannot read the app's name, and subjects need one.
|
|
20
|
-
* `currency` is an ISO code
|
|
21
|
-
*
|
|
22
|
-
* group is being created or already exists.
|
|
23
|
+
* `currency` is an ISO code and `weight_unit`/`dimension_unit` are the
|
|
24
|
+
* measurement units; passing any of them explicitly always wins — they set the
|
|
25
|
+
* `general` group whether it is being created or already exists.
|
|
23
26
|
*/
|
|
24
27
|
import { createClientFromRequest } from "npm:@base44/sdk";
|
|
25
28
|
import { HttpError, requireAdmin } from "../../../shared/commerce/auth.ts";
|
|
26
29
|
import { CURRENCIES } from "../../../shared/commerce/data/currencies.ts";
|
|
27
30
|
import {
|
|
28
31
|
GATEWAY_DEFAULTS,
|
|
29
|
-
|
|
30
|
-
REST_OF_WORLD_ZONE,
|
|
32
|
+
REST_OF_WORLD_LOCATION,
|
|
31
33
|
SETTINGS_DEFAULTS,
|
|
32
|
-
TAX_CLASS_DEFAULTS,
|
|
33
34
|
} from "./defaults.ts";
|
|
34
35
|
import { CatalogPayloadError, normalizeCatalogPayload, sampleCatalog, seedCatalog } from "./seed-catalog.ts";
|
|
35
36
|
|
|
@@ -47,13 +48,25 @@ Deno.serve(async (req) => {
|
|
|
47
48
|
const storeName = String(body.store_name ?? "").trim();
|
|
48
49
|
const currencyCode = String(body.currency ?? "").trim().toUpperCase();
|
|
49
50
|
const currencyInfo = currencyCode ? CURRENCIES.find((c) => c.code === currencyCode) : undefined;
|
|
51
|
+
const WEIGHT_UNITS = ["kg", "g", "lbs", "oz"];
|
|
52
|
+
const DIMENSION_UNITS = ["cm", "m", "mm", "in", "yd"];
|
|
53
|
+
const weightUnit = String(body.weight_unit ?? "").trim().toLowerCase();
|
|
54
|
+
const dimensionUnit = String(body.dimension_unit ?? "").trim().toLowerCase();
|
|
50
55
|
|
|
51
56
|
// ── 0. validate the payload — pure, so bad input fails before canaries
|
|
52
57
|
// or any write ──────────────────────────────────────────────────────
|
|
58
|
+
const unitErrors: Array<{ path: string; error: string }> = [];
|
|
53
59
|
if (currencyCode && !currencyInfo) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
unitErrors.push({ path: "currency", error: `"${currencyCode}" is not a known ISO currency code` });
|
|
61
|
+
}
|
|
62
|
+
if (weightUnit && !WEIGHT_UNITS.includes(weightUnit)) {
|
|
63
|
+
unitErrors.push({ path: "weight_unit", error: `must be one of: ${WEIGHT_UNITS.join(", ")}` });
|
|
64
|
+
}
|
|
65
|
+
if (dimensionUnit && !DIMENSION_UNITS.includes(dimensionUnit)) {
|
|
66
|
+
unitErrors.push({ path: "dimension_unit", error: `must be one of: ${DIMENSION_UNITS.join(", ")}` });
|
|
67
|
+
}
|
|
68
|
+
if (unitErrors.length) {
|
|
69
|
+
return fail(400, "Invalid catalog payload — nothing was written.", "invalid_payload", { errors: unitErrors });
|
|
57
70
|
}
|
|
58
71
|
let catalogSpec = null;
|
|
59
72
|
try {
|
|
@@ -73,7 +86,7 @@ Deno.serve(async (req) => {
|
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
// ── 2. required defaults (idempotent) ────────────────────────────────────
|
|
76
|
-
const seeded = { settings_groups: 0, gateways: 0,
|
|
89
|
+
const seeded = { settings_groups: 0, gateways: 0, locations: 0 };
|
|
77
90
|
|
|
78
91
|
const existingSettings = (await sr.entities["commerce.StoreSettings"].list(undefined, 100)) ?? [];
|
|
79
92
|
const existingGroups = new Set(existingSettings.map((s: any) => s.group_id));
|
|
@@ -82,31 +95,34 @@ Deno.serve(async (req) => {
|
|
|
82
95
|
if (!existingGroups.has("emails") && !storeName) {
|
|
83
96
|
return fail(400, "store_name is required — pass the app's name as the platform shows it.", "store_name_required");
|
|
84
97
|
}
|
|
98
|
+
// The general-group keys the caller can set directly.
|
|
99
|
+
const generalPatch: Record<string, any> = {};
|
|
100
|
+
if (currencyInfo) generalPatch.currency = currencyInfo.code;
|
|
101
|
+
if (weightUnit) generalPatch.weight_unit = weightUnit;
|
|
102
|
+
if (dimensionUnit) generalPatch.dimension_unit = dimensionUnit;
|
|
103
|
+
|
|
85
104
|
for (const group of SETTINGS_DEFAULTS) {
|
|
86
105
|
if (existingGroups.has(group.group_id)) continue;
|
|
87
106
|
const values = group.group_id === "emails"
|
|
88
107
|
? { ...group.values, store_name: storeName }
|
|
89
|
-
: group.group_id === "general"
|
|
90
|
-
? { ...group.values,
|
|
108
|
+
: group.group_id === "general"
|
|
109
|
+
? { ...group.values, ...generalPatch }
|
|
91
110
|
: group.values;
|
|
92
111
|
await sr.entities["commerce.StoreSettings"].create({ ...group, values });
|
|
93
112
|
seeded.settings_groups++;
|
|
94
113
|
}
|
|
95
114
|
|
|
96
|
-
//
|
|
97
|
-
// to distinguish a merchant's choice from the seeded
|
|
115
|
+
// Explicit currency/units always win — unlike store_name there is no
|
|
116
|
+
// "blank" to distinguish a merchant's choice from the seeded default, and a
|
|
98
117
|
// caller passing one is giving an instruction, not a fallback.
|
|
99
118
|
let currencyAction = currencyInfo ? "created" : "unchanged";
|
|
100
|
-
if (
|
|
119
|
+
if (Object.keys(generalPatch).length && existingGroups.has("general")) {
|
|
101
120
|
const general = existingSettings.find((r: any) => r.group_id === "general");
|
|
102
121
|
const values = general?.values ?? {};
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
values: { ...values, currency: currencyInfo.code, num_decimals: currencyInfo.decimals },
|
|
108
|
-
});
|
|
109
|
-
currencyAction = "updated";
|
|
122
|
+
const changed = Object.entries(generalPatch).some(([k, v]) => values[k] !== v);
|
|
123
|
+
if (changed) {
|
|
124
|
+
await sr.entities["commerce.StoreSettings"].update(general.id, { values: { ...values, ...generalPatch } });
|
|
125
|
+
if (currencyInfo) currencyAction = values.currency === currencyInfo.code ? "unchanged" : "updated";
|
|
110
126
|
}
|
|
111
127
|
}
|
|
112
128
|
|
|
@@ -126,6 +142,20 @@ Deno.serve(async (req) => {
|
|
|
126
142
|
}
|
|
127
143
|
}
|
|
128
144
|
|
|
145
|
+
// Upgrade shim: stores installed when the card gateway was slugged "stripe"
|
|
146
|
+
// carry that row; rename it so it stays THE card option instead of turning
|
|
147
|
+
// into a dead manual method next to a freshly seeded duplicate.
|
|
148
|
+
const legacyCard = ((await sr.entities["commerce.PaymentGateway"].filter({ slug: "stripe" }, undefined, 1)) ?? [])[0];
|
|
149
|
+
if (legacyCard && !((await sr.entities["commerce.PaymentGateway"].filter({ slug: "card" }, undefined, 1)) ?? []).length) {
|
|
150
|
+
const cardDefaults = GATEWAY_DEFAULTS.find((g) => g.slug === "card")!;
|
|
151
|
+
await sr.entities["commerce.PaymentGateway"].update(legacyCard.id, {
|
|
152
|
+
slug: "card",
|
|
153
|
+
method_title: cardDefaults.method_title,
|
|
154
|
+
method_description: cardDefaults.method_description,
|
|
155
|
+
settings: {},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
129
159
|
for (const gw of GATEWAY_DEFAULTS) {
|
|
130
160
|
const hits = (await sr.entities["commerce.PaymentGateway"].filter({ slug: gw.slug }, undefined, 1)) ?? [];
|
|
131
161
|
if (hits.length) continue;
|
|
@@ -133,22 +163,17 @@ Deno.serve(async (req) => {
|
|
|
133
163
|
seeded.gateways++;
|
|
134
164
|
}
|
|
135
165
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
const zoneMethods = (await sr.entities["commerce.ShippingZoneMethod"].filter({ zone_id: zone.id }, undefined, 5)) ?? [];
|
|
149
|
-
if (!zoneMethods.length) {
|
|
150
|
-
await sr.entities["commerce.ShippingZoneMethod"].create({ ...REST_OF_WORLD_EXAMPLE_METHOD, zone_id: zone.id });
|
|
151
|
-
seeded.zone_methods++;
|
|
166
|
+
// Caller-supplied `locations` ARE the store's shipping story — seeding the
|
|
167
|
+
// free-shipping fallback next to them would leave a second no-region
|
|
168
|
+
// location the merchant never asked for (shadowed while the caller's has a
|
|
169
|
+
// lower `order`, live the moment theirs is deleted or reordered). Only
|
|
170
|
+
// create the default when the payload brings no locations of its own.
|
|
171
|
+
if (!catalogSpec?.locations?.length) {
|
|
172
|
+
const fallback = ((await sr.entities["commerce.ShippingTaxLocation"].filter({ name: REST_OF_WORLD_LOCATION.name }, undefined, 1)) ?? [])[0];
|
|
173
|
+
if (!fallback) {
|
|
174
|
+
await sr.entities["commerce.ShippingTaxLocation"].create(REST_OF_WORLD_LOCATION);
|
|
175
|
+
seeded.locations++;
|
|
176
|
+
}
|
|
152
177
|
}
|
|
153
178
|
|
|
154
179
|
// ── 3. the catalog: caller-supplied, or the demo sample ──────────────────
|
|
@@ -189,19 +214,16 @@ function canarySpecs(needsCatalog: boolean): Array<{ entity: string; record: Rec
|
|
|
189
214
|
const base = [
|
|
190
215
|
{ entity: "commerce.StoreSettings", record: { group_id: "general", values: { __canary: true } } },
|
|
191
216
|
{ entity: "commerce.PaymentGateway", record: { slug: "__canary", title: "Canary", enabled: false, order: 999, settings: {} } },
|
|
192
|
-
{ entity: "commerce.
|
|
193
|
-
{ entity: "commerce.ShippingZone", record: { name: "__canary", order: 998, locations: [] } },
|
|
194
|
-
{ entity: "commerce.ShippingZoneMethod", record: { zone_id: "__canary", method_id: "flat_rate", title: "Canary", enabled: false, order: 0, settings: { cost: 0 } } },
|
|
217
|
+
{ entity: "commerce.ShippingTaxLocation", record: { name: "__canary", order: 998, regions: [], shipping_rates: [], tax_groups: [], shipping_tax: null } },
|
|
195
218
|
];
|
|
196
219
|
const catalog = [
|
|
197
220
|
{ entity: "commerce.ProductCategory", record: { name: "__canary", slug: "__canary", count: 0 } },
|
|
198
|
-
{ entity: "commerce.
|
|
221
|
+
{ entity: "commerce.ProductRibbon", record: { name: "__canary", count: 0 } },
|
|
199
222
|
{ entity: "commerce.ProductAttribute", record: { name: "__canary", code: "__canary", order: 0 } },
|
|
200
223
|
{ entity: "commerce.ProductAttributeTerm", record: { attribute_id: "__canary", name: "__canary", order: 0 } },
|
|
201
224
|
{ entity: "commerce.Product", record: { name: "__canary", status: "draft", regular_price: 1, price: 1 } },
|
|
202
225
|
{ entity: "commerce.ProductVariation", record: { product_id: "__canary", attributes: [], status: "draft" } },
|
|
203
226
|
{ entity: "commerce.Coupon", record: { code: "__canary", discount_type: "percent", amount: 1, usage_count: 0, used_by: [] } },
|
|
204
|
-
{ entity: "commerce.TaxRate", record: { country: "ZZ", rate: 1, name: "__canary", priority: 1, compound: false, shipping: true, tax_class: "standard" } },
|
|
205
227
|
];
|
|
206
228
|
return needsCatalog ? [...base, ...catalog] : base;
|
|
207
229
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Optional demo catalog: 3 categories, 2 shared attributes (+values),
|
|
3
|
-
* 10 products (3 of which carry attributes and ship with variants)
|
|
4
|
-
* 2 coupons
|
|
3
|
+
* 10 products (3 of which carry attributes and ship with variants) and
|
|
4
|
+
* 2 coupons. Every product ships with a
|
|
5
5
|
* description, short description and a small image gallery so the storefront
|
|
6
6
|
* and admin look populated out of the box.
|
|
7
7
|
* Only installed when with_sample_data=true AND the store has no products.
|
|
@@ -296,16 +296,3 @@ export const SAMPLE_COUPONS = [
|
|
|
296
296
|
used_by: [] as string[],
|
|
297
297
|
},
|
|
298
298
|
];
|
|
299
|
-
|
|
300
|
-
export const SAMPLE_TAX_RATES = [
|
|
301
|
-
{
|
|
302
|
-
country: "US", state: "CA", postcodes: [] as string[], cities: [] as string[],
|
|
303
|
-
rate: 7.25, name: "CA State Tax", priority: 1, compound: false, shipping: true,
|
|
304
|
-
tax_class: "standard", menu_order: 0,
|
|
305
|
-
},
|
|
306
|
-
{
|
|
307
|
-
country: "GB", state: "", postcodes: [] as string[], cities: [] as string[],
|
|
308
|
-
rate: 20, name: "VAT", priority: 1, compound: false, shipping: true,
|
|
309
|
-
tax_class: "standard", menu_order: 1,
|
|
310
|
-
},
|
|
311
|
-
];
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One pipeline serves two callers: the template's demo catalog
|
|
5
5
|
* (`sampleCatalog()`, over sample-data.ts) and a caller-supplied
|
|
6
|
-
* `products`/`coupons`/`
|
|
6
|
+
* `products`/`coupons`/`locations` payload (`normalizeCatalogPayload()`).
|
|
7
7
|
* Both normalize to the same CatalogSpec, and `seedCatalog()` writes it:
|
|
8
8
|
* get-or-create taxonomy (never duplicated, never rolled back when it
|
|
9
9
|
* pre-existed), create products and their variations, roll each variant
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* The caller payload is agent-ergonomic — everything is referenced by display
|
|
13
13
|
* name, and variations are generated from `attributes` when not given:
|
|
14
14
|
*
|
|
15
|
-
* { name, regular_price, sku?, stock_quantity?, images?, categories?,
|
|
15
|
+
* { name, regular_price, sku?, stock_quantity?, images?, categories?, ribbons?,
|
|
16
16
|
* attributes?: [{ name: "Size", options: ["S","M"] }] | { Size: ["S","M"] },
|
|
17
17
|
* default_options?: { Size: "M" },
|
|
18
18
|
* variations?: [{ options: { Size: "S" }, regular_price?, stock_quantity?, sku?, image? }] }
|
|
@@ -35,7 +35,6 @@ import {
|
|
|
35
35
|
SAMPLE_CATEGORIES,
|
|
36
36
|
SAMPLE_COUPONS,
|
|
37
37
|
SAMPLE_PRODUCTS,
|
|
38
|
-
SAMPLE_TAX_RATES,
|
|
39
38
|
} from "./sample-data.ts";
|
|
40
39
|
|
|
41
40
|
// ── shapes ───────────────────────────────────────────────────────────────────
|
|
@@ -48,7 +47,7 @@ interface NormalizedVariation {
|
|
|
48
47
|
interface NormalizedProduct {
|
|
49
48
|
fields: Record<string, any>; // entity passthrough, images normalized
|
|
50
49
|
categorySlugs: string[];
|
|
51
|
-
|
|
50
|
+
ribbonNames: string[];
|
|
52
51
|
attributes: Array<{ code: string; options: string[] }>;
|
|
53
52
|
defaultOptions: Record<string, string>;
|
|
54
53
|
variations: NormalizedVariation[];
|
|
@@ -60,7 +59,7 @@ export interface CatalogSpec {
|
|
|
60
59
|
termsByCode: Record<string, Array<{ name: string; order: number }>>;
|
|
61
60
|
products: NormalizedProduct[];
|
|
62
61
|
coupons: any[];
|
|
63
|
-
|
|
62
|
+
locations: any[];
|
|
64
63
|
}
|
|
65
64
|
|
|
66
65
|
/** 400-payload failure carrying every problem at once. Caught in entry.ts. */
|
|
@@ -76,20 +75,20 @@ export class CatalogPayloadError extends Error {
|
|
|
76
75
|
|
|
77
76
|
const MAX_PRODUCTS = 100;
|
|
78
77
|
const MAX_COUPONS = 50;
|
|
79
|
-
const
|
|
78
|
+
const MAX_LOCATIONS = 50;
|
|
80
79
|
const MAX_VARIATIONS_PER_PRODUCT = 50; // also the cartesian-explosion guard
|
|
81
80
|
const MAX_TOTAL_VARIATIONS = 500; // every variation is a sequential create
|
|
82
81
|
|
|
83
82
|
const PRODUCT_KEYS = new Set([
|
|
84
83
|
// spec-only keys
|
|
85
|
-
"categories", "
|
|
84
|
+
"categories", "ribbons", "attributes", "default_options", "variations",
|
|
86
85
|
// entity fields
|
|
87
86
|
"name", "slug", "sku", "status", "regular_price", "sale_price",
|
|
88
87
|
"date_on_sale_from", "date_on_sale_to", "manage_stock", "stock_quantity",
|
|
89
88
|
"backorders", "description", "short_description", "images", "featured",
|
|
90
|
-
"
|
|
91
|
-
"download_limit", "download_expiry", "tax_status", "
|
|
92
|
-
"dimensions", "sold_individually", "low_stock_amount",
|
|
89
|
+
"virtual", "downloadable", "downloads",
|
|
90
|
+
"download_limit", "download_expiry", "tax_status", "tax_group", "weight",
|
|
91
|
+
"dimensions", "sold_individually", "low_stock_amount",
|
|
93
92
|
"upsell_ids", "cross_sell_ids", "meta_data",
|
|
94
93
|
]);
|
|
95
94
|
|
|
@@ -103,8 +102,7 @@ const VARIATION_KEYS = new Set([
|
|
|
103
102
|
const ENUMS: Record<string, string[]> = {
|
|
104
103
|
status: ["draft", "pending", "private", "publish"],
|
|
105
104
|
backorders: ["no", "notify", "yes"],
|
|
106
|
-
|
|
107
|
-
tax_status: ["taxable", "shipping", "none"],
|
|
105
|
+
tax_status: ["taxable", "none"],
|
|
108
106
|
discount_type: ["percent", "fixed_cart", "fixed_product"],
|
|
109
107
|
};
|
|
110
108
|
|
|
@@ -118,14 +116,14 @@ const PRICE_KEYS = ["regular_price", "sale_price", "stock_quantity", "low_stock_
|
|
|
118
116
|
* every problem at once, before anything (canaries included) runs.
|
|
119
117
|
*/
|
|
120
118
|
export function normalizeCatalogPayload(body: any): CatalogSpec | null {
|
|
121
|
-
const hasCatalog = ["products", "coupons", "
|
|
119
|
+
const hasCatalog = ["products", "coupons", "locations"].some((k) => body[k] != null);
|
|
122
120
|
if (!hasCatalog) return null;
|
|
123
121
|
|
|
124
122
|
const errors: Array<{ path: string; error: string }> = [];
|
|
125
123
|
const err = (path: string, error: string) => errors.push({ path, error });
|
|
126
124
|
|
|
127
125
|
if (body.with_sample_data) {
|
|
128
|
-
err("with_sample_data", "cannot be combined with products/coupons/
|
|
126
|
+
err("with_sample_data", "cannot be combined with products/coupons/locations — seed either the demo catalog or your own, not both");
|
|
129
127
|
}
|
|
130
128
|
|
|
131
129
|
const list = (key: string, cap: number): any[] => {
|
|
@@ -141,7 +139,7 @@ export function normalizeCatalogPayload(body: any): CatalogSpec | null {
|
|
|
141
139
|
|
|
142
140
|
const products = list("products", MAX_PRODUCTS);
|
|
143
141
|
const coupons = list("coupons", MAX_COUPONS);
|
|
144
|
-
const
|
|
142
|
+
const rawLocations = list("locations", MAX_LOCATIONS);
|
|
145
143
|
|
|
146
144
|
// shared definition collectors (deduped across products)
|
|
147
145
|
const categoryBySlug = new Map<string, Record<string, any>>();
|
|
@@ -175,13 +173,7 @@ export function normalizeCatalogPayload(body: any): CatalogSpec | null {
|
|
|
175
173
|
if (!Number.isFinite(Number(c.amount)) || Number(c.amount) < 0) err(`${path}.amount`, "must be a non-negative number");
|
|
176
174
|
});
|
|
177
175
|
|
|
178
|
-
|
|
179
|
-
const path = `tax_rates[${i}]`;
|
|
180
|
-
if (!r || typeof r !== "object") return err(path, "must be an object");
|
|
181
|
-
if (!String(r.country ?? "").trim()) err(`${path}.country`, "is required");
|
|
182
|
-
if (!String(r.name ?? "").trim()) err(`${path}.name`, "is required");
|
|
183
|
-
if (!Number.isFinite(Number(r.rate)) || Number(r.rate) < 0) err(`${path}.rate`, "must be a non-negative number");
|
|
184
|
-
});
|
|
176
|
+
const locations = rawLocations.map((l, i) => normalizeLocation(l, `locations[${i}]`, err)).filter(Boolean);
|
|
185
177
|
|
|
186
178
|
if (errors.length) throw new CatalogPayloadError(errors);
|
|
187
179
|
|
|
@@ -191,10 +183,68 @@ export function normalizeCatalogPayload(body: any): CatalogSpec | null {
|
|
|
191
183
|
termsByCode,
|
|
192
184
|
products: normalized,
|
|
193
185
|
coupons,
|
|
194
|
-
|
|
186
|
+
locations,
|
|
195
187
|
};
|
|
196
188
|
}
|
|
197
189
|
|
|
190
|
+
/**
|
|
191
|
+
* A caller location is Wix-shaped: countries (ISO codes) or explicit regions,
|
|
192
|
+
* shipping rates, named tax groups and an optional shipping tax:
|
|
193
|
+
*
|
|
194
|
+
* { name: "Israel", countries: ["IL"],
|
|
195
|
+
* shipping_rates: [{ name: "Standard", cost: 20, free_over: 150 }],
|
|
196
|
+
* tax_groups: [{ name: "Products", rates: [{ name: "VAT", rate: 18 }] }],
|
|
197
|
+
* shipping_tax: { type: "percent", value: 18 } }
|
|
198
|
+
*/
|
|
199
|
+
function normalizeLocation(l: any, path: string, err: (p: string, e: string) => void): any | null {
|
|
200
|
+
if (!l || typeof l !== "object" || Array.isArray(l)) {
|
|
201
|
+
err(path, "must be an object");
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const name = String(l.name ?? "").trim();
|
|
205
|
+
if (!name) {
|
|
206
|
+
err(`${path}.name`, "is required");
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const regions: any[] = [];
|
|
210
|
+
for (const c of l.countries ?? []) {
|
|
211
|
+
const code = String(c ?? "").trim().toUpperCase();
|
|
212
|
+
if (code) regions.push({ type: "country", code });
|
|
213
|
+
}
|
|
214
|
+
for (const r of l.regions ?? []) {
|
|
215
|
+
if (r?.type && r?.code) regions.push({ type: String(r.type), code: String(r.code).toUpperCase() });
|
|
216
|
+
}
|
|
217
|
+
const slugBase = slugify(name);
|
|
218
|
+
const shippingRates = (Array.isArray(l.shipping_rates) ? l.shipping_rates : []).map((r: any, i: number) => {
|
|
219
|
+
const rate = { id: r?.id || `${slugBase}-${slugify(String(r?.name || `rate-${i + 1}`))}`,
|
|
220
|
+
name: String(r?.name ?? "").trim() || "Shipping",
|
|
221
|
+
cost: Number(r?.cost) || 0,
|
|
222
|
+
free_over: r?.free_over != null && Number.isFinite(Number(r.free_over)) ? Number(r.free_over) : null };
|
|
223
|
+
if (rate.cost < 0) err(`${path}.shipping_rates[${i}].cost`, "must be a non-negative number");
|
|
224
|
+
return rate;
|
|
225
|
+
});
|
|
226
|
+
const taxGroups = (Array.isArray(l.tax_groups) ? l.tax_groups : []).map((g: any, i: number) => ({
|
|
227
|
+
name: String(g?.name ?? "").trim() || "Products",
|
|
228
|
+
rates: (Array.isArray(g?.rates) ? g.rates : []).map((r: any, j: number) => {
|
|
229
|
+
const rate = { name: String(r?.name ?? "").trim(), rate: Number(r?.rate) || 0 };
|
|
230
|
+
if (rate.rate < 0 || rate.rate > 100) err(`${path}.tax_groups[${i}].rates[${j}].rate`, "must be a percentage between 0 and 100");
|
|
231
|
+
return rate;
|
|
232
|
+
}),
|
|
233
|
+
}));
|
|
234
|
+
if (!taxGroups.some((g: any) => g.name.toLowerCase() === "products")) {
|
|
235
|
+
taxGroups.unshift({ name: "Products", rates: [] });
|
|
236
|
+
}
|
|
237
|
+
let shippingTax: any = null;
|
|
238
|
+
if (l.shipping_tax != null) {
|
|
239
|
+
const type = String(l.shipping_tax.type ?? "percent");
|
|
240
|
+
if (!["percent", "fixed"].includes(type)) err(`${path}.shipping_tax.type`, "must be percent or fixed");
|
|
241
|
+
const value = Number(l.shipping_tax.value);
|
|
242
|
+
if (!Number.isFinite(value) || value < 0) err(`${path}.shipping_tax.value`, "must be a non-negative number");
|
|
243
|
+
shippingTax = { type, value: Number.isFinite(value) ? value : 0 };
|
|
244
|
+
}
|
|
245
|
+
return { name, order: Number(l.order) || 0, regions, shipping_rates: shippingRates, tax_groups: taxGroups, shipping_tax: shippingTax };
|
|
246
|
+
}
|
|
247
|
+
|
|
198
248
|
function normalizeProduct(
|
|
199
249
|
spec: any,
|
|
200
250
|
path: string,
|
|
@@ -227,7 +277,7 @@ function normalizeProduct(
|
|
|
227
277
|
}
|
|
228
278
|
return slug;
|
|
229
279
|
});
|
|
230
|
-
const
|
|
280
|
+
const ribbonNames = strList(spec.ribbons, `${path}.ribbons`, err);
|
|
231
281
|
|
|
232
282
|
// attribute axes: array form is canonical, map form accepted (insertion order)
|
|
233
283
|
const axes: Array<{ name: string; code: string; options: string[] }> = [];
|
|
@@ -363,7 +413,7 @@ function normalizeProduct(
|
|
|
363
413
|
return {
|
|
364
414
|
fields,
|
|
365
415
|
categorySlugs,
|
|
366
|
-
|
|
416
|
+
ribbonNames,
|
|
367
417
|
attributes: axes.map((a) => ({ code: a.code, options: a.options })),
|
|
368
418
|
defaultOptions,
|
|
369
419
|
variations,
|
|
@@ -440,7 +490,7 @@ export function sampleCatalog(): CatalogSpec {
|
|
|
440
490
|
return {
|
|
441
491
|
fields: { ...fields, images: fields.images ?? [] },
|
|
442
492
|
categorySlugs: categories ?? [],
|
|
443
|
-
|
|
493
|
+
ribbonNames: [],
|
|
444
494
|
attributes: (attributes ?? []).map((a: any) => ({ code: a.code, options: a.options ?? [] })),
|
|
445
495
|
defaultOptions: Object.fromEntries((default_attributes ?? []).map((d: any) => [d.code, d.option])),
|
|
446
496
|
variations: (variations ?? []).map((v: any) => {
|
|
@@ -450,7 +500,7 @@ export function sampleCatalog(): CatalogSpec {
|
|
|
450
500
|
};
|
|
451
501
|
}),
|
|
452
502
|
coupons: SAMPLE_COUPONS,
|
|
453
|
-
|
|
503
|
+
locations: [],
|
|
454
504
|
};
|
|
455
505
|
}
|
|
456
506
|
|
|
@@ -479,7 +529,7 @@ export async function seedCatalog(
|
|
|
479
529
|
};
|
|
480
530
|
const counts = {
|
|
481
531
|
categories: { created: 0, reused: 0 },
|
|
482
|
-
|
|
532
|
+
ribbons: { created: 0, reused: 0 },
|
|
483
533
|
attributes: { created: 0, reused: 0 },
|
|
484
534
|
terms: { created: 0, reused: 0 },
|
|
485
535
|
};
|
|
@@ -526,20 +576,20 @@ export async function seedCatalog(
|
|
|
526
576
|
}
|
|
527
577
|
}
|
|
528
578
|
|
|
529
|
-
let
|
|
530
|
-
const
|
|
531
|
-
const
|
|
532
|
-
const key =
|
|
533
|
-
if (
|
|
534
|
-
|
|
535
|
-
let
|
|
536
|
-
if (
|
|
579
|
+
let allRibbons: any[] | null = null;
|
|
580
|
+
const ribbonByLower: Record<string, any> = {};
|
|
581
|
+
const resolveRibbon = async (ribbonName: string) => {
|
|
582
|
+
const key = ribbonName.toLowerCase();
|
|
583
|
+
if (ribbonByLower[key]) return ribbonByLower[key];
|
|
584
|
+
allRibbons ??= await scanAll(sr.entities["commerce.ProductRibbon"], null, "name");
|
|
585
|
+
let ribbon = allRibbons!.find((t: any) => String(t.name ?? "").toLowerCase() === key);
|
|
586
|
+
if (ribbon) counts.ribbons.reused++;
|
|
537
587
|
else {
|
|
538
|
-
|
|
539
|
-
counts.
|
|
588
|
+
ribbon = await track("commerce.ProductRibbon", { name: ribbonName, count: 0 });
|
|
589
|
+
counts.ribbons.created++;
|
|
540
590
|
}
|
|
541
|
-
|
|
542
|
-
return
|
|
591
|
+
ribbonByLower[key] = ribbon;
|
|
592
|
+
return ribbon;
|
|
543
593
|
};
|
|
544
594
|
|
|
545
595
|
let allAttributes: any[] | null = null;
|
|
@@ -580,9 +630,9 @@ export async function seedCatalog(
|
|
|
580
630
|
|
|
581
631
|
// ── products + variations ──────────────────────────────────────────────
|
|
582
632
|
const results: Array<Record<string, any>> = [];
|
|
583
|
-
const countDeltas = new Map<string, number>(); // "entity
|
|
633
|
+
const countDeltas = new Map<string, number>(); // "entity|id" → +n
|
|
584
634
|
const bumpLater = (entity: string, id: string) => {
|
|
585
|
-
const key = `${entity}
|
|
635
|
+
const key = `${entity}|${id}`;
|
|
586
636
|
countDeltas.set(key, (countDeltas.get(key) ?? 0) + 1);
|
|
587
637
|
};
|
|
588
638
|
let variationsCreated = 0;
|
|
@@ -594,13 +644,13 @@ export async function seedCatalog(
|
|
|
594
644
|
}
|
|
595
645
|
const record: any = {
|
|
596
646
|
status: "publish",
|
|
597
|
-
|
|
647
|
+
ribbon_ids: [],
|
|
598
648
|
meta_data: [],
|
|
599
649
|
total_sales: 0,
|
|
600
650
|
...p.fields,
|
|
601
651
|
category_ids: p.categorySlugs.map((slug) => categoryBySlug[slug]?.id).filter(Boolean),
|
|
602
652
|
};
|
|
603
|
-
for (const
|
|
653
|
+
for (const ribbonName of p.ribbonNames) record.ribbon_ids = [...record.ribbon_ids, (await resolveRibbon(ribbonName)).id];
|
|
604
654
|
record.slug = await ensureUniqueSlug(sr, slugify(record.slug || record.name));
|
|
605
655
|
if (record.manage_stock === undefined && record.stock_quantity != null) record.manage_stock = true;
|
|
606
656
|
|
|
@@ -657,14 +707,14 @@ export async function seedCatalog(
|
|
|
657
707
|
if (seededVariations.length) await rollUpParent(sr, product, seededVariations);
|
|
658
708
|
|
|
659
709
|
for (const id of record.category_ids) bumpLater("commerce.ProductCategory", id);
|
|
660
|
-
for (const id of record.
|
|
710
|
+
for (const id of record.ribbon_ids) bumpLater("commerce.ProductRibbon", id);
|
|
661
711
|
results.push({ name: product.name, id: product.id, slug: product.slug, sku: product.sku ?? "", variation_count: seededVariations.length });
|
|
662
712
|
}
|
|
663
713
|
|
|
664
714
|
// deferred so a mid-creation rollback never leaves counts drifted;
|
|
665
715
|
// a failure from here on is count-only and recount-terms repairs it
|
|
666
716
|
for (const [key, delta] of countDeltas) {
|
|
667
|
-
const [entity, id] = key.split("
|
|
717
|
+
const [entity, id] = key.split("|");
|
|
668
718
|
await bumpCount(sr, entity, id, delta);
|
|
669
719
|
}
|
|
670
720
|
|
|
@@ -681,27 +731,27 @@ export async function seedCatalog(
|
|
|
681
731
|
couponCounts.created++;
|
|
682
732
|
}
|
|
683
733
|
|
|
684
|
-
const
|
|
685
|
-
for (const
|
|
686
|
-
const hits = (await sr.entities["commerce.
|
|
687
|
-
if (hits.
|
|
688
|
-
|
|
734
|
+
const locationCounts = { created: 0, skipped: 0 };
|
|
735
|
+
for (const loc of spec.locations) {
|
|
736
|
+
const hits = (await sr.entities["commerce.ShippingTaxLocation"].filter({ name: loc.name }, undefined, 1)) ?? [];
|
|
737
|
+
if (hits.length) {
|
|
738
|
+
locationCounts.skipped++;
|
|
689
739
|
continue;
|
|
690
740
|
}
|
|
691
|
-
await track("commerce.
|
|
692
|
-
|
|
741
|
+
await track("commerce.ShippingTaxLocation", loc);
|
|
742
|
+
locationCounts.created++;
|
|
693
743
|
}
|
|
694
744
|
|
|
695
745
|
return {
|
|
696
746
|
categories: counts.categories,
|
|
697
|
-
|
|
747
|
+
ribbons: counts.ribbons,
|
|
698
748
|
attributes: counts.attributes,
|
|
699
749
|
terms: counts.terms,
|
|
700
750
|
products_created: results.filter((r) => !r.skipped).length,
|
|
701
751
|
products_skipped: results.filter((r) => r.skipped).length,
|
|
702
752
|
variations_created: variationsCreated,
|
|
703
753
|
coupons: couponCounts,
|
|
704
|
-
|
|
754
|
+
locations: locationCounts,
|
|
705
755
|
products: results,
|
|
706
756
|
};
|
|
707
757
|
} catch (e) {
|
|
@@ -36,20 +36,16 @@ export async function loadCart(sr: any, cartToken: string | undefined): Promise<
|
|
|
36
36
|
|
|
37
37
|
export interface PricingData {
|
|
38
38
|
settings: Record<string, any>;
|
|
39
|
-
|
|
40
|
-
zones: any[];
|
|
41
|
-
zoneMethods: any[];
|
|
39
|
+
locations: any[];
|
|
42
40
|
}
|
|
43
41
|
|
|
44
42
|
/** One round of catalog-config reads shared by every priced response. */
|
|
45
43
|
export async function loadPricingData(sr: any): Promise<PricingData> {
|
|
46
|
-
const [settings,
|
|
44
|
+
const [settings, locations] = await Promise.all([
|
|
47
45
|
getSettings(sr),
|
|
48
|
-
scanAll(sr.entities["commerce.
|
|
49
|
-
scanAll(sr.entities["commerce.ShippingZone"], {}, "order", 500),
|
|
50
|
-
scanAll(sr.entities["commerce.ShippingZoneMethod"], {}, "order", 1000),
|
|
46
|
+
scanAll(sr.entities["commerce.ShippingTaxLocation"], {}, "order", 500),
|
|
51
47
|
]);
|
|
52
|
-
return { settings,
|
|
48
|
+
return { settings, locations };
|
|
53
49
|
}
|
|
54
50
|
|
|
55
51
|
export interface ResolvedItem {
|
|
@@ -179,9 +175,7 @@ export async function priceCart(sr: any, cart: any, opts: { pricingData?: Pricin
|
|
|
179
175
|
shipping_address: cart.shipping_address,
|
|
180
176
|
chosenShippingMethodId: chosenShippingMethodId || undefined,
|
|
181
177
|
settings,
|
|
182
|
-
|
|
183
|
-
zones: pricingData.zones,
|
|
184
|
-
zoneMethods: pricingData.zoneMethods,
|
|
178
|
+
locations: pricingData.locations,
|
|
185
179
|
});
|
|
186
180
|
|
|
187
181
|
let totals = priceWith(cart.chosen_shipping_method);
|
|
@@ -196,6 +190,7 @@ export async function priceCart(sr: any, cart: any, opts: { pricingData?: Pricin
|
|
|
196
190
|
shippingEnabled: getSetting(settings, "shipping", "enable_shipping", true) !== false,
|
|
197
191
|
chosenMethodId: cart.chosen_shipping_method,
|
|
198
192
|
available: totals.available_shipping_methods,
|
|
193
|
+
hasAddress: !!cart.shipping_address?.country,
|
|
199
194
|
});
|
|
200
195
|
if (shippingSelection.method_id !== (cart.chosen_shipping_method || "")) {
|
|
201
196
|
cart.chosen_shipping_method = shippingSelection.method_id;
|