@base44/app-plugin-commerce 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +12 -12
  2. package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
  3. package/base44/entities/commerce.Cart.jsonc +1 -1
  4. package/base44/entities/commerce.Coupon.jsonc +5 -0
  5. package/base44/entities/commerce.Order.jsonc +6 -7
  6. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  7. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  8. package/base44/entities/commerce.Product.jsonc +6 -16
  9. package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
  10. package/base44/entities/commerce.ProductVariation.jsonc +1 -9
  11. package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
  12. package/base44/entities/commerce.Webhook.jsonc +1 -1
  13. package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
  14. package/base44/functions/commerce/admin-products/entry.ts +11 -17
  15. package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
  16. package/base44/functions/commerce/admin-reports/entry.ts +3 -3
  17. package/base44/functions/commerce/admin-tools/entry.ts +9 -36
  18. package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
  19. package/base44/functions/commerce/payments/entry.ts +46 -42
  20. package/base44/functions/commerce/seed-store/defaults.ts +35 -42
  21. package/base44/functions/commerce/seed-store/entry.ts +68 -46
  22. package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
  23. package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
  24. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
  25. package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
  26. package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
  27. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
  28. package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
  29. package/base44/shared/commerce/card-payment.ts +80 -0
  30. package/base44/shared/commerce/coupons.ts +16 -10
  31. package/base44/shared/commerce/emails.ts +30 -18
  32. package/base44/shared/commerce/money.ts +11 -24
  33. package/base44/shared/commerce/payments.ts +55 -286
  34. package/base44/shared/commerce/scan.ts +1 -1
  35. package/base44/shared/commerce/sequence.ts +1 -1
  36. package/base44/shared/commerce/settings.ts +4 -9
  37. package/base44/shared/commerce/shipping.ts +65 -133
  38. package/base44/shared/commerce/tax.ts +48 -96
  39. package/base44/shared/commerce/totals.ts +77 -91
  40. package/package.json +1 -1
  41. package/scripts/install.js +28 -7
  42. package/skills/commerce/SKILL.md +14 -14
  43. package/skills/commerce/docs/api-admin.md +23 -26
  44. package/skills/commerce/docs/api-storefront.md +67 -61
  45. package/skills/commerce/installation-guidelines.md +8 -8
  46. package/skills/commerce/post-installation.md +60 -39
  47. package/skills/commerce/references/admin-product-form.md +15 -12
  48. package/skills/commerce/references/emails.md +2 -2
  49. package/skills/commerce/references/guest-access-security.md +2 -2
  50. package/skills/commerce/references/online-payments.md +24 -166
  51. package/skills/commerce/references/product-render.md +18 -18
  52. package/skills/commerce/references/reviews.md +14 -8
  53. package/skills/commerce/references/storefront-product-page.md +1 -1
  54. package/src/commerce/admin/README.md +5 -6
  55. package/src/commerce/admin/bot/Markdown.jsx +1 -1
  56. package/src/commerce/admin/hooks/useMoney.js +13 -22
  57. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  58. package/src/commerce/admin/lib/constants.js +2 -29
  59. package/src/commerce/admin/lib/order-utils.js +1 -1
  60. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
  61. package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
  62. package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
  63. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
  64. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
  65. package/src/commerce/admin/pages/products/Categories.jsx +147 -177
  66. package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
  67. package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
  68. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
  69. package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
  70. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
  71. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
  72. package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
  73. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
  74. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
  75. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
  76. package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
  77. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
  78. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
  79. package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
  80. package/src/commerce/admin/routes.jsx +6 -12
  81. package/src/commerce/utils/index.js +2 -2
  82. package/src/commerce/utils/shipping-promos.js +45 -49
  83. package/src/commerce/utils/variants.js +1 -1
  84. package/base44/entities/commerce.ShippingClass.jsonc +0 -30
  85. package/base44/entities/commerce.ShippingZone.jsonc +0 -41
  86. package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
  87. package/base44/entities/commerce.TaxClass.jsonc +0 -23
  88. package/base44/entities/commerce.TaxRate.jsonc +0 -68
  89. package/base44/shared/commerce/stripe.ts +0 -463
  90. package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
  91. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
  92. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
  93. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
  94. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
  95. 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, tax classes, fallback zone), (4) the catalog: either the caller's
9
- * `products`/`coupons`/`tax_rates` (taxonomy get-or-created by name,
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?, with_sample_data?, products?, coupons?, tax_rates? }
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; passing it explicitly always wins — it sets
21
- * general.currency (and the currency's standard num_decimals) whether the
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
- REST_OF_WORLD_EXAMPLE_METHOD,
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
- return fail(400, "Invalid catalog payload — nothing was written.", "invalid_payload", {
55
- errors: [{ path: "currency", error: `"${currencyCode}" is not a known ISO currency code` }],
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, tax_classes: 0, zones: 0, zone_methods: 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" && currencyInfo
90
- ? { ...group.values, currency: currencyInfo.code, num_decimals: currencyInfo.decimals }
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
- // An explicit currency always wins — unlike store_name there is no "blank"
97
- // to distinguish a merchant's choice from the seeded USD default, and a
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 (currencyInfo && existingGroups.has("general")) {
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
- if (values.currency === currencyInfo.code && values.num_decimals === currencyInfo.decimals) {
104
- currencyAction = "unchanged";
105
- } else {
106
- await sr.entities["commerce.StoreSettings"].update(general.id, {
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
- for (const tc of TAX_CLASS_DEFAULTS) {
137
- const hits = (await sr.entities["commerce.TaxClass"].filter({ slug: tc.slug }, undefined, 1)) ?? [];
138
- if (hits.length) continue;
139
- await sr.entities["commerce.TaxClass"].create(tc);
140
- seeded.tax_classes++;
141
- }
142
-
143
- let zone = ((await sr.entities["commerce.ShippingZone"].filter({ name: REST_OF_WORLD_ZONE.name }, undefined, 1)) ?? [])[0];
144
- if (!zone) {
145
- zone = await sr.entities["commerce.ShippingZone"].create(REST_OF_WORLD_ZONE);
146
- seeded.zones++;
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.TaxClass", record: { slug: "__canary", name: "Canary" } },
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.ProductTag", record: { name: "__canary", count: 0 } },
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, 2 tax rates. Every product ships with a
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`/`tax_rates` payload (`normalizeCatalogPayload()`).
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?, tags?,
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
- tagNames: string[];
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
- tax_rates: any[];
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 MAX_TAX_RATES = 50;
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", "tags", "attributes", "default_options", "variations",
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
- "catalog_visibility", "virtual", "downloadable", "downloads",
91
- "download_limit", "download_expiry", "tax_status", "tax_class", "weight",
92
- "dimensions", "sold_individually", "low_stock_amount", "shipping_class_id",
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
- catalog_visibility: ["visible", "catalog", "search", "hidden"],
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", "tax_rates"].some((k) => body[k] != null);
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/tax_rates — seed either the demo catalog or your own, not both");
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 taxRates = list("tax_rates", MAX_TAX_RATES);
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
- taxRates.forEach((r, i) => {
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
- tax_rates: taxRates,
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 tagNames = strList(spec.tags, `${path}.tags`, err);
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
- tagNames,
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
- tagNames: [],
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
- tax_rates: SAMPLE_TAX_RATES,
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
- tags: { created: 0, reused: 0 },
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 allTags: any[] | null = null;
530
- const tagByLower: Record<string, any> = {};
531
- const resolveTag = async (tagName: string) => {
532
- const key = tagName.toLowerCase();
533
- if (tagByLower[key]) return tagByLower[key];
534
- allTags ??= await scanAll(sr.entities["commerce.ProductTag"], null, "name");
535
- let tag = allTags!.find((t: any) => String(t.name ?? "").toLowerCase() === key);
536
- if (tag) counts.tags.reused++;
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
- tag = await track("commerce.ProductTag", { name: tagName, count: 0 });
539
- counts.tags.created++;
588
+ ribbon = await track("commerce.ProductRibbon", { name: ribbonName, count: 0 });
589
+ counts.ribbons.created++;
540
590
  }
541
- tagByLower[key] = tag;
542
- return tag;
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>(); // "entityid" → +n
633
+ const countDeltas = new Map<string, number>(); // "entity|id" → +n
584
634
  const bumpLater = (entity: string, id: string) => {
585
- const key = `${entity}${id}`;
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
- tag_ids: [],
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 tagName of p.tagNames) record.tag_ids = [...record.tag_ids, (await resolveTag(tagName)).id];
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.tag_ids) bumpLater("commerce.ProductTag", id);
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 taxCounts = { created: 0, skipped: 0 };
685
- for (const r of spec.tax_rates) {
686
- const hits = (await sr.entities["commerce.TaxRate"].filter({ country: r.country, name: r.name }, undefined, 5)) ?? [];
687
- if (hits.some((h: any) => String(h.state ?? "") === String(r.state ?? ""))) {
688
- taxCounts.skipped++;
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.TaxRate", { state: "", postcodes: [], cities: [], priority: 1, compound: false, shipping: true, tax_class: "standard", menu_order: 0, ...r });
692
- taxCounts.created++;
741
+ await track("commerce.ShippingTaxLocation", loc);
742
+ locationCounts.created++;
693
743
  }
694
744
 
695
745
  return {
696
746
  categories: counts.categories,
697
- tags: counts.tags,
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
- tax_rates: taxCounts,
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
- taxRates: any[];
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, taxRates, zones, zoneMethods] = await Promise.all([
44
+ const [settings, locations] = await Promise.all([
47
45
  getSettings(sr),
48
- scanAll(sr.entities["commerce.TaxRate"], {}, "menu_order", 2000),
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, taxRates, zones, zoneMethods };
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
- taxRates: pricingData.taxRates,
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;