@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
@@ -33,21 +33,16 @@ export function getSetting(groups: Record<string, any>, group: string, key: stri
33
33
  */
34
34
  export function storefrontSafeSettings(groups: Record<string, any>): Record<string, any> {
35
35
  const general = groups.general ?? {};
36
- const products = groups.products ?? {};
37
36
  const tax = groups.tax ?? {};
38
37
  const inventory = groups.inventory ?? {};
39
38
  return {
40
39
  // The one key taken from the emails group: the store's own name is public.
41
40
  store_name: groups.emails?.store_name ?? "",
41
+ // Currency is a value, not a format: render prices with
42
+ // Intl.NumberFormat(undefined, { style: "currency", currency }).
42
43
  currency: general.currency ?? "USD",
43
- currency_position: general.currency_position ?? "left",
44
- thousand_sep: general.thousand_sep ?? ",",
45
- decimal_sep: general.decimal_sep ?? ".",
46
- num_decimals: general.num_decimals ?? 2,
47
- weight_unit: products.weight_unit ?? "kg",
48
- dimension_unit: products.dimension_unit ?? "cm",
49
- enable_reviews: products.enable_reviews ?? true,
50
- review_rating_required: products.review_rating_required ?? true,
44
+ weight_unit: general.weight_unit ?? "kg",
45
+ dimension_unit: general.dimension_unit ?? "cm",
51
46
  prices_include_tax: tax.prices_include_tax ?? false,
52
47
  display_prices_shop: tax.display_prices_shop ?? "excl",
53
48
  display_prices_cart: tax.display_prices_cart ?? "excl",
@@ -1,14 +1,14 @@
1
1
  /**
2
- * Shipping-zone matching and method cost computation (standard commerce semantics):
3
- * - zones are checked in `order` asc; the first matching zone wins
4
- * - region locations (continent/country/state) OR together; postcode locations
5
- * act as an AND filter on top (a zone with only postcode locations matches
6
- * any region but requires a postcode hit)
7
- * - a zone with NO locations is the "Rest of the world" fallback, used only
8
- * when no located zone matches
2
+ * Shipping over Shipping & Tax Locations:
3
+ * - locations are checked in `order` asc; the first whose regions match wins
4
+ * - regions OR together (continent/country/state — no postcode filters)
5
+ * - a location with NO regions is the "Rest of the world" fallback, used only
6
+ * when no regioned location matches
7
+ * - a location's shipping_rates are the checkout options; a rate with
8
+ * `free_over` costs 0 once the discounted items subtotal reaches it, and a
9
+ * free-shipping coupon zeroes every offered rate
9
10
  */
10
11
  import { round2 } from "./money.ts";
11
- import { postcodeMatchesPattern } from "./tax.ts";
12
12
  import { continentOf } from "./data/continents.ts";
13
13
 
14
14
  export interface ShipAddress {
@@ -18,78 +18,56 @@ export interface ShipAddress {
18
18
  city?: string;
19
19
  }
20
20
 
21
- /** Re-export for callers that only import shipping.ts. */
22
- export function postcodeMatches(pattern: string, postcode: string): boolean {
23
- return postcodeMatchesPattern(pattern, postcode);
24
- }
25
-
26
- function zoneMatches(zone: any, addr: ShipAddress): boolean {
27
- const locations: any[] = zone.locations || [];
28
- if (!locations.length) return false; // fallback zones handled by caller
21
+ function regionsMatch(location: any, addr: ShipAddress): boolean {
22
+ const regions: any[] = location.regions || [];
23
+ if (!regions.length) return false; // fallback locations handled by caller
29
24
  const country = (addr.country || "").toUpperCase();
30
25
  const state = (addr.state || "").toUpperCase();
31
26
  const stateCode = country && state ? `${country}:${state}` : "";
32
27
 
33
- const regions = locations.filter((l) => l.type !== "postcode");
34
- const postcodes = locations.filter((l) => l.type === "postcode");
35
-
36
- let regionOk = regions.length === 0; // postcode-only zone: no region restriction
37
- for (const loc of regions) {
38
- const code = (loc.code || "").toUpperCase();
39
- if (loc.type === "continent" && continentOf(country)?.code === code) regionOk = true;
40
- if (loc.type === "country" && code === country) regionOk = true;
41
- if (loc.type === "state" && code === stateCode) regionOk = true;
42
- if (regionOk) break;
43
- }
44
- if (!regionOk) return false;
45
-
46
- if (postcodes.length) {
47
- return postcodes.some((loc) => postcodeMatchesPattern(loc.code || "", addr.postcode || ""));
28
+ for (const region of regions) {
29
+ const code = (region.code || "").toUpperCase();
30
+ if (region.type === "continent" && continentOf(country)?.code === code) return true;
31
+ if (region.type === "country" && code === country) return true;
32
+ if (region.type === "state" && code === stateCode) return true;
48
33
  }
49
- return true;
34
+ return false;
50
35
  }
51
36
 
52
37
  /**
53
- * Pick the shipping zone for an address: first located zone (by `order` asc)
54
- * that matches, else the fallback zone (empty locations — "Rest of the world"),
55
- * else null.
38
+ * Pick the Shipping & Tax Location for an address: first regioned location
39
+ * (by `order` asc) that matches, else the fallback location (no regions —
40
+ * "Rest of the world"), else null.
56
41
  */
57
- export function matchZone(zones: any[], addr: ShipAddress): any | null {
58
- const sorted = [...(zones || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
59
- for (const zone of sorted) {
60
- if ((zone.locations || []).length && zoneMatches(zone, addr)) return zone;
42
+ export function matchLocation(locations: any[], addr: ShipAddress): any | null {
43
+ const sorted = [...(locations || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
44
+ for (const location of sorted) {
45
+ if ((location.regions || []).length && regionsMatch(location, addr)) return location;
61
46
  }
62
- return sorted.find((z) => !(z.locations || []).length) ?? null;
47
+ return sorted.find((l) => !(l.regions || []).length) ?? null;
63
48
  }
64
49
 
65
50
  export interface PricedCartForShipping {
66
- /** Lines with shipping_class_id + discounted totals (ex tax). */
67
- lines: Array<{
68
- shipping_class_id?: string;
69
- virtual?: boolean;
70
- quantity: number;
71
- subtotal: number;
72
- total: number;
73
- }>;
74
- itemsSubtotal: number; // pre-discount, ex tax
51
+ itemsSubtotal: number; // pre-discount, ex tax
75
52
  itemsSubtotalAfterDiscount: number; // post-discount, ex tax
76
53
  }
77
54
 
78
- export interface AvailableMethod {
79
- method: any;
55
+ export interface AvailableRate {
56
+ rate: any;
80
57
  cost: number;
81
58
  }
82
59
 
83
60
  /** Outcome of {@link resolveShippingSelection}. */
84
61
  export type ShippingSelectionState =
85
62
  | "not_needed" // virtual-only cart, or shipping disabled store-wide
86
- | "chosen" // the caller's method is offered for this address
87
- | "auto_selected" // exactly one method is offered — no choice to make
88
- | "choice_required" // several methods are offered and none is chosen yet
89
- | "none_available"; // nothing ships to this address (or no zone/method configured)
63
+ | "chosen" // the caller's rate is offered for this address
64
+ | "auto_selected" // exactly one rate is offered — no choice to make
65
+ | "choice_required" // several rates are offered and none is chosen yet
66
+ | "missing_address" // options can't be determined until an address is set (several locations defined)
67
+ | "none_available"; // nothing ships to this address (or no location/rate configured)
90
68
 
91
69
  export interface ShippingSelection {
92
- /** The ShippingZoneMethod id to price with; "" when there is nothing to apply. */
70
+ /** The shipping rate id to price with; "" when there is nothing to apply. */
93
71
  method_id: string;
94
72
  state: ShippingSelectionState;
95
73
  /** A previously stored choice is no longer offered (address or cart changed). */
@@ -97,19 +75,21 @@ export interface ShippingSelection {
97
75
  }
98
76
 
99
77
  /**
100
- * Decide which shipping method a **storefront** cart/checkout should use.
78
+ * Decide which shipping rate a **storefront** cart/checkout should use.
101
79
  *
102
80
  * Storefronts must not be able to place a shippable order with no shipping
103
81
  * line — that silently ships for free — so this collapses the decision to one
104
82
  * rule set the cart and checkout both follow:
105
83
  *
106
84
  * - virtual-only cart, or shipping disabled → nothing to do;
107
- * - the chosen method is still offered → keep it;
108
- * - exactly one method offered → **select it automatically**; a single option is
85
+ * - the chosen rate is still offered → keep it;
86
+ * - exactly one rate offered → **select it automatically**; a single option is
109
87
  * not a choice, and requiring a round trip for it is the trap that makes
110
88
  * storefronts forget shipping entirely;
111
89
  * - several offered, none chosen → the caller must ask the customer;
112
- * - none offered → the store does not ship to this address.
90
+ * - none offered, no address yet → the caller must collect an address before the
91
+ * options (and the shipping cost) can be calculated (`missing_address`);
92
+ * - none offered with an address → the store does not ship to this address.
113
93
  *
114
94
  * Deliberately *not* applied by the totals engine itself: admin orders are
115
95
  * priced through the same engine and must never grow a shipping charge the
@@ -120,13 +100,21 @@ export function resolveShippingSelection(input: {
120
100
  shippingEnabled?: boolean;
121
101
  chosenMethodId?: string;
122
102
  available: Array<{ id: string }>;
103
+ /** Whether a shipping address (country) is known. Defaults to true. */
104
+ hasAddress?: boolean;
123
105
  }): ShippingSelection {
124
106
  const chosen = String(input.chosenMethodId || "");
125
107
  if (!input.needsShipping || input.shippingEnabled === false) {
126
108
  return { method_id: "", state: "not_needed", stale: false };
127
109
  }
128
110
  const available = input.available ?? [];
129
- if (!available.length) return { method_id: "", state: "none_available", stale: !!chosen };
111
+ if (!available.length) {
112
+ return {
113
+ method_id: "",
114
+ state: input.hasAddress === false ? "missing_address" : "none_available",
115
+ stale: !!chosen,
116
+ };
117
+ }
130
118
  if (chosen && available.some((m) => m.id === chosen)) {
131
119
  return { method_id: chosen, state: "chosen", stale: false };
132
120
  }
@@ -136,80 +124,24 @@ export function resolveShippingSelection(input: {
136
124
  return { method_id: "", state: "choice_required", stale: !!chosen };
137
125
  }
138
126
 
139
- function flatRateCost(method: any, cart: PricedCartForShipping): number {
140
- const s = method.settings || {};
141
- let cost = Number(s.cost) || 0;
142
- const classCosts: any[] = s.class_costs || [];
143
- const noClassCost = Number(s.no_class_cost) || 0;
144
- const shippable = cart.lines.filter((l) => !l.virtual);
145
- const classesPresent = [...new Set(shippable.filter((l) => l.shipping_class_id).map((l) => l.shipping_class_id))];
146
- const hasClassless = shippable.some((l) => !l.shipping_class_id);
147
-
148
- const applicable: number[] = [];
149
- for (const clsId of classesPresent) {
150
- const entry = classCosts.find((c) => c.shipping_class_id === clsId);
151
- if (entry) applicable.push(Number(entry.cost) || 0);
152
- }
153
- if (hasClassless && noClassCost) applicable.push(noClassCost);
154
-
155
- if ((s.calculation_type || "class") === "class") {
156
- // "Charge shipping for each class individually"
157
- cost += applicable.reduce((a, b) => a + b, 0);
158
- } else if (applicable.length) {
159
- // "order": charge based on the most expensive class in the cart
160
- cost += Math.max(...applicable);
161
- }
162
- return round2(cost);
163
- }
164
-
165
- function freeShippingAvailable(method: any, cart: PricedCartForShipping, appliedCoupons: any[]): boolean {
166
- const s = method.settings || {};
167
- const requires = s.requires || "";
168
- if (!requires) return true;
169
- const hasCoupon = (appliedCoupons || []).some((c) => c?.free_shipping);
170
- const basis = s.ignore_discounts ? cart.itemsSubtotal : cart.itemsSubtotalAfterDiscount;
171
- const minOk = basis >= (Number(s.min_amount) || 0);
172
- switch (requires) {
173
- case "coupon": return hasCoupon;
174
- case "min_amount": return minOk;
175
- case "either": return hasCoupon || minOk;
176
- case "both": return hasCoupon && minOk;
177
- default: return true;
178
- }
179
- }
180
-
181
127
  /**
182
- * Compute the offer-able methods (with costs) for a matched zone.
183
- * `methods` = the zone's ShippingZoneMethod records (any order); disabled and
184
- * unmet-requirement methods are excluded. Free-shipping coupons only *enable*
185
- * the free_shipping method — they never zero out flat rates (intended behavior).
128
+ * The rates a matched location offers, with live costs: `free_over` zeroes a
129
+ * rate once the discounted items subtotal reaches it, and a free-shipping
130
+ * coupon zeroes every rate. Rates keep their configured order.
186
131
  */
187
- export function availableMethods(
188
- zone: any,
189
- methods: any[],
132
+ export function availableRates(
133
+ location: any,
190
134
  cart: PricedCartForShipping,
191
- _settings: Record<string, any>,
192
135
  appliedCoupons: any[] = [],
193
- ): AvailableMethod[] {
194
- const zoneMethods = (methods || [])
195
- .filter((m) => m.zone_id === zone?.id && m.enabled !== false)
196
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
197
- const out: AvailableMethod[] = [];
198
- for (const method of zoneMethods) {
199
- switch (method.method_id) {
200
- case "flat_rate":
201
- out.push({ method, cost: flatRateCost(method, cart) });
202
- break;
203
- case "free_shipping":
204
- if (freeShippingAvailable(method, cart, appliedCoupons)) out.push({ method, cost: 0 });
205
- break;
206
- case "local_pickup":
207
- out.push({ method, cost: round2(Number(method.settings?.cost) || 0) });
208
- break;
209
- default:
210
- // unknown custom method: offer at its configured flat cost
211
- out.push({ method, cost: round2(Number(method.settings?.cost) || 0) });
212
- }
213
- }
214
- return out;
136
+ ): AvailableRate[] {
137
+ const couponFree = (appliedCoupons || []).some((c) => c?.free_shipping);
138
+ return (location?.shipping_rates ?? [])
139
+ .filter((r: any) => r && r.id)
140
+ .map((rate: any) => {
141
+ const threshold = rate.free_over;
142
+ const thresholdMet = threshold != null && Number.isFinite(Number(threshold)) &&
143
+ cart.itemsSubtotalAfterDiscount >= Number(threshold);
144
+ const cost = couponFree || thresholdMet ? 0 : round2(Number(rate.cost) || 0);
145
+ return { rate, cost };
146
+ });
215
147
  }
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Tax-rate matching and application, following standard commerce tax semantics:
3
- * - rates match by tax class + location (empty field = wildcard)
4
- * - rates sort by priority asc; ONE rate applies per priority level
5
- * - non-compound rates apply on the base amount; compound rates apply on
6
- * (base + all previously accumulated tax), in priority order
2
+ * Tax resolution over Shipping & Tax Locations.
3
+ *
4
+ * A location carries named tax groups (default group "Products"); a product
5
+ * picks its group by name via `tax_group`. A group's tax is simply the sum of
6
+ * its rates — there are no priorities, no compounding, no per-rate location
7
+ * filters: the location IS the filter. Shipping tax is the location's own
8
+ * `shipping_tax` ({type: "percent"|"fixed", value}).
7
9
  */
8
10
  import { round2 } from "./money.ts";
9
11
 
@@ -14,81 +16,36 @@ export interface TaxAddress {
14
16
  city?: string;
15
17
  }
16
18
 
17
- /** Does `postcode` match `pattern`? Supports exact, `90*` wildcard, `1000...2000` numeric range. */
18
- export function postcodeMatchesPattern(pattern: string, postcode: string): boolean {
19
- const p = (pattern || "").trim().toUpperCase();
20
- const pc = (postcode || "").trim().toUpperCase();
21
- if (!p) return true; // empty pattern = wildcard
22
- if (!pc) return false;
23
- if (p.includes("...")) {
24
- const [lo, hi] = p.split("...").map((s) => parseInt(s.trim(), 10));
25
- const n = parseInt(pc, 10);
26
- return !Number.isNaN(lo) && !Number.isNaN(hi) && !Number.isNaN(n) && n >= lo && n <= hi;
27
- }
28
- if (p.includes("*")) {
29
- const re = new RegExp("^" + p.split("*").map(escapeRegex).join(".*") + "$");
30
- return re.test(pc);
31
- }
32
- return p === pc;
33
- }
34
-
35
- function escapeRegex(s: string): string {
36
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37
- }
19
+ export const DEFAULT_TAX_GROUP = "Products";
38
20
 
39
- function rateMatchesLocation(rate: any, addr: TaxAddress): boolean {
40
- const country = (addr.country || "").toUpperCase();
41
- const state = (addr.state || "").toUpperCase();
42
- const city = (addr.city || "").toLowerCase();
43
- if (rate.country && rate.country.toUpperCase() !== country) return false;
44
- if (rate.state && rate.state.toUpperCase() !== state) return false;
45
- const postcodes: string[] = rate.postcodes || [];
46
- if (postcodes.length && !postcodes.some((p: string) => postcodeMatchesPattern(p, addr.postcode || ""))) return false;
47
- const cities: string[] = rate.cities || [];
48
- if (cities.length && !cities.some((c: string) => (c || "").toLowerCase() === city)) return false;
49
- return true;
21
+ export interface TaxRate {
22
+ name?: string;
23
+ rate: number; // percentage, e.g. 18 for 18%
50
24
  }
51
25
 
52
26
  /**
53
- * Find the applicable rates for a tax class at an address.
54
- * Sorted by priority asc (menu_order breaks ties); one rate per priority.
27
+ * The rates a tax group applies at a location. Group names match
28
+ * case-insensitively; a missing group (or no location) means no tax.
55
29
  */
56
- export function matchTaxRates(allRates: any[], addr: TaxAddress, taxClass: string): any[] {
57
- const cls = taxClass || "standard";
58
- const candidates = (allRates || [])
59
- .filter((r) => (r.tax_class || "standard") === cls && rateMatchesLocation(r, addr))
60
- .sort((a, b) => (a.priority ?? 1) - (b.priority ?? 1) || (a.menu_order ?? 0) - (b.menu_order ?? 0));
61
- // only the first matching rate per priority level applies
62
- const byPriority = new Map<number, any>();
63
- for (const r of candidates) {
64
- const p = r.priority ?? 1;
65
- if (!byPriority.has(p)) byPriority.set(p, r);
66
- }
67
- return [...byPriority.values()];
30
+ export function taxRatesFor(location: any, groupName?: string): TaxRate[] {
31
+ const wanted = String(groupName || DEFAULT_TAX_GROUP).toLowerCase();
32
+ const group = (location?.tax_groups ?? []).find(
33
+ (g: any) => String(g?.name ?? "").toLowerCase() === wanted,
34
+ );
35
+ return (group?.rates ?? [])
36
+ .map((r: any) => ({ name: r?.name ?? "", rate: Number(r?.rate) || 0 }))
37
+ .filter((r: TaxRate) => r.rate > 0);
68
38
  }
69
39
 
70
40
  export interface AppliedRate {
71
- rate: any;
41
+ rate: TaxRate;
72
42
  amount: number;
73
43
  }
74
44
 
75
- /**
76
- * Apply matched rates (already priority-deduped, priority-ordered) to an
77
- * ex-tax amount. Non-compound rates each apply on the base; compound rates
78
- * apply on base + accumulated tax so far.
79
- */
80
- export function applyRates(amount: number, rates: any[]): AppliedRate[] {
45
+ /** Apply rates to an ex-tax amount. Every rate applies on the base. */
46
+ export function applyRates(amount: number, rates: TaxRate[]): AppliedRate[] {
81
47
  const base = Number(amount) || 0;
82
- const out: AppliedRate[] = [];
83
- let accumulated = 0;
84
- for (const rate of rates || []) {
85
- const pct = (Number(rate.rate) || 0) / 100;
86
- const taxable = rate.compound ? base + accumulated : base;
87
- const tax = round2(taxable * pct);
88
- accumulated += tax;
89
- out.push({ rate, amount: tax });
90
- }
91
- return out;
48
+ return (rates || []).map((rate) => ({ rate, amount: round2(base * ((Number(rate.rate) || 0) / 100)) }));
92
49
  }
93
50
 
94
51
  export interface InclusiveTaxResult {
@@ -98,39 +55,34 @@ export interface InclusiveTaxResult {
98
55
  }
99
56
 
100
57
  /**
101
- * Extract tax from a tax-INCLUSIVE gross amount (prices_include_tax mode),
102
- * mirroring standard inclusive-tax extraction: compound rates are unwound from the
103
- * outside in (last priority first), then the remaining amount is split across
104
- * the non-compound rates which all share the same net base.
58
+ * Extract tax from a tax-INCLUSIVE gross amount (prices_include_tax mode).
59
+ * All rates share the same net base: net = gross / (1 + sum of rates).
105
60
  */
106
- export function extractInclusiveTax(gross: number, rates: any[]): InclusiveTaxResult {
107
- let remaining = Number(gross) || 0;
108
- const taxesByRate = new Map<any, number>();
109
-
110
- // 1) unwind compound rates, highest priority (applied last) first
111
- const compound = (rates || []).filter((r) => r.compound).reverse();
112
- for (const rate of compound) {
113
- const pct = (Number(rate.rate) || 0) / 100;
114
- const tax = round2(remaining - remaining / (1 + pct));
115
- taxesByRate.set(rate, tax);
116
- remaining -= tax;
117
- }
118
-
119
- // 2) the non-compound rates all apply on the same net base
120
- const regular = (rates || []).filter((r) => !r.compound);
121
- const regularSum = regular.reduce((a, r) => a + (Number(r.rate) || 0) / 100, 0);
122
- const net = regularSum > 0 ? remaining / (1 + regularSum) : remaining;
123
- for (const rate of regular) {
124
- const pct = (Number(rate.rate) || 0) / 100;
125
- taxesByRate.set(rate, round2(net * pct));
126
- }
127
-
128
- const taxes: AppliedRate[] = (rates || []).map((rate) => ({ rate, amount: taxesByRate.get(rate) ?? 0 }));
61
+ export function extractInclusiveTax(gross: number, rates: TaxRate[]): InclusiveTaxResult {
62
+ const total = Number(gross) || 0;
63
+ const sum = (rates || []).reduce((a, r) => a + (Number(r.rate) || 0) / 100, 0);
64
+ const net = sum > 0 ? total / (1 + sum) : total;
65
+ const taxes: AppliedRate[] = (rates || []).map((rate) => ({
66
+ rate,
67
+ amount: round2(net * ((Number(rate.rate) || 0) / 100)),
68
+ }));
129
69
  const totalTax = round2(taxes.reduce((a, t) => a + t.amount, 0));
130
- return { net: round2((Number(gross) || 0) - totalTax), taxes, totalTax };
70
+ return { net: round2(total - totalTax), taxes, totalTax };
131
71
  }
132
72
 
133
73
  /** Sum of applied-rate amounts, rounded. */
134
74
  export function sumTax(applied: AppliedRate[]): number {
135
75
  return round2((applied || []).reduce((a, t) => a + t.amount, 0));
136
76
  }
77
+
78
+ /**
79
+ * The location's tax on a shipping cost: percent of the cost, or a fixed
80
+ * amount (charged once per order, regardless of the cost).
81
+ */
82
+ export function shippingTaxFor(location: any, shippingCost: number): number {
83
+ const cfg = location?.shipping_tax;
84
+ const value = Number(cfg?.value) || 0;
85
+ if (!cfg || value <= 0) return 0;
86
+ if (cfg.type === "fixed") return round2(value);
87
+ return round2((Number(shippingCost) || 0) * (value / 100));
88
+ }