@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.
- package/README.md +12 -12
- 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 +5 -6
- 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
|
@@ -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
|
-
|
|
44
|
-
|
|
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
|
|
3
|
-
* -
|
|
4
|
-
* -
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - a
|
|
8
|
-
*
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
34
|
+
return false;
|
|
50
35
|
}
|
|
51
36
|
|
|
52
37
|
/**
|
|
53
|
-
* Pick the
|
|
54
|
-
* that matches, else the fallback
|
|
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
|
|
58
|
-
const sorted = [...(
|
|
59
|
-
for (const
|
|
60
|
-
if ((
|
|
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((
|
|
47
|
+
return sorted.find((l) => !(l.regions || []).length) ?? null;
|
|
63
48
|
}
|
|
64
49
|
|
|
65
50
|
export interface PricedCartForShipping {
|
|
66
|
-
|
|
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
|
|
79
|
-
|
|
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
|
|
87
|
-
| "auto_selected" // exactly one
|
|
88
|
-
| "choice_required" // several
|
|
89
|
-
| "
|
|
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
|
|
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
|
|
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
|
|
108
|
-
* - exactly one
|
|
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
|
|
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)
|
|
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
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
|
188
|
-
|
|
189
|
-
methods: any[],
|
|
132
|
+
export function availableRates(
|
|
133
|
+
location: any,
|
|
190
134
|
cart: PricedCartForShipping,
|
|
191
|
-
_settings: Record<string, any>,
|
|
192
135
|
appliedCoupons: any[] = [],
|
|
193
|
-
):
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
*
|
|
54
|
-
*
|
|
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
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
41
|
+
rate: TaxRate;
|
|
72
42
|
amount: number;
|
|
73
43
|
}
|
|
74
44
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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:
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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(
|
|
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
|
+
}
|