@revenexx/cover 0.1.18 → 0.1.20
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/app/components/layout/header/LocaleSwitcher.vue +34 -3
- package/app/composables/useLocalePreferences.ts +38 -6
- package/app/interfaces/market.ts +16 -3
- package/package.json +1 -1
- package/server/api/cart/calculate.post.ts +96 -1
- package/server/api/orders/index.post.ts +23 -8
- package/server/data/markets.json +8 -0
- package/server/interfaces/cartCalculation.ts +22 -0
- package/server/services/ApiMarketService.ts +30 -12
- package/server/services/MockCartCalculationService.ts +28 -10
- package/server/utils/currency.ts +20 -0
- package/server/utils/livePrices.ts +4 -0
|
@@ -11,7 +11,7 @@ const { icon } = useIcons();
|
|
|
11
11
|
const { t, locale, locales } = useI18n();
|
|
12
12
|
const switchLocalePath = useSwitchLocalePath();
|
|
13
13
|
const { markets } = useMarkets();
|
|
14
|
-
const { market, setMarket, region, currencySymbol } = useLocalePreferences(markets);
|
|
14
|
+
const { market, setMarket, region, currencies, currency, currencySymbol, setCurrency } = useLocalePreferences(markets);
|
|
15
15
|
|
|
16
16
|
const open = ref(false);
|
|
17
17
|
|
|
@@ -44,6 +44,9 @@ async function onLanguageChange(code: string): Promise<void> {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const currencyItems = computed(() =>
|
|
48
|
+
currencies.value.map(code => ({ code, name: code, active: code === currency.value })));
|
|
49
|
+
|
|
47
50
|
async function onMarketChange(code: string): Promise<void> {
|
|
48
51
|
setMarket(code);
|
|
49
52
|
// The market may not offer the current language — fall back to its first.
|
|
@@ -51,6 +54,18 @@ async function onMarketChange(code: string): Promise<void> {
|
|
|
51
54
|
if (fallback && !languageItems.value.some(item => item.active)) {
|
|
52
55
|
await navigateTo(fallback.to);
|
|
53
56
|
}
|
|
57
|
+
// The market may not trade in the current currency — re-resolve prices.
|
|
58
|
+
reloadNuxtApp({ ttl: 0 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function onCurrencyChange(code: string): void {
|
|
62
|
+
if (code === currency.value) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
setCurrency(code);
|
|
66
|
+
// Re-SSR so the catalog, cart and checkout all re-price in the new
|
|
67
|
+
// currency (prices come from the API, keyed by the currency cookie).
|
|
68
|
+
reloadNuxtApp({ ttl: 0 });
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
/** The trigger states the full current selection, e.g. "Deutschland (Deutsch) in €". */
|
|
@@ -108,8 +123,24 @@ const triggerLabel = computed(() =>
|
|
|
108
123
|
/>
|
|
109
124
|
</UFormField>
|
|
110
125
|
|
|
111
|
-
<!-- Currency:
|
|
112
|
-
<
|
|
126
|
+
<!-- Currency: chosen within the currencies the market trades in -->
|
|
127
|
+
<UFormField
|
|
128
|
+
v-if="currencyItems.length > 1"
|
|
129
|
+
:label="t('topbar.localePanel.currency')"
|
|
130
|
+
>
|
|
131
|
+
<USelect
|
|
132
|
+
:model-value="currency"
|
|
133
|
+
value-key="code"
|
|
134
|
+
label-key="name"
|
|
135
|
+
:items="currencyItems"
|
|
136
|
+
class="w-full"
|
|
137
|
+
@update:model-value="(code: string) => onCurrencyChange(code)"
|
|
138
|
+
/>
|
|
139
|
+
</UFormField>
|
|
140
|
+
<div
|
|
141
|
+
v-else
|
|
142
|
+
class="flex items-center justify-between text-sm"
|
|
143
|
+
>
|
|
113
144
|
<span class="text-muted">{{ t('topbar.localePanel.currency') }}</span>
|
|
114
145
|
<span class="font-medium">{{ currencySymbol }}</span>
|
|
115
146
|
</div>
|
|
@@ -4,10 +4,11 @@ import type { ShopMarket } from "../interfaces/market";
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Market-driven locale preferences: the shop's markets (markets app) carry
|
|
7
|
-
* region,
|
|
8
|
-
*
|
|
9
|
-
* (Smashing Magazine, "Designing A Better
|
|
10
|
-
* as
|
|
7
|
+
* region, the offered locales and the currencies the market trades in — the
|
|
8
|
+
* selector picks a market, while language and currency stay independent
|
|
9
|
+
* choices within the market's options (Smashing Magazine, "Designing A Better
|
|
10
|
+
* Language Selector"). All three persist as cookies so SSR renders the same
|
|
11
|
+
* selection and the BFF resolves prices in the chosen currency.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
const CURRENCY_SYMBOLS: Record<string, string> = {
|
|
@@ -22,6 +23,12 @@ export function useLocalePreferences(markets: Ref<ShopMarket[]>) {
|
|
|
22
23
|
default: () => "",
|
|
23
24
|
watch: true,
|
|
24
25
|
});
|
|
26
|
+
// Read server-side by the BFF (resolveSelectedCurrency) to price the
|
|
27
|
+
// catalog, cart and order in the buyer's currency.
|
|
28
|
+
const currencyCode = useCookie<string>("cover-currency", {
|
|
29
|
+
default: () => "",
|
|
30
|
+
watch: true,
|
|
31
|
+
});
|
|
25
32
|
|
|
26
33
|
const market = computed<ShopMarket | null>(() => {
|
|
27
34
|
if (!markets.value.length) {
|
|
@@ -38,8 +45,33 @@ export function useLocalePreferences(markets: Ref<ShopMarket[]>) {
|
|
|
38
45
|
};
|
|
39
46
|
|
|
40
47
|
const region = computed(() => market.value?.name ?? "");
|
|
41
|
-
|
|
48
|
+
|
|
49
|
+
/** The currencies the active market offers (falls back to its base currency). */
|
|
50
|
+
const currencies = computed<string[]>(() => {
|
|
51
|
+
const offered = market.value?.currencies?.map(c => c.code) ?? [];
|
|
52
|
+
if (offered.length) {
|
|
53
|
+
return offered;
|
|
54
|
+
}
|
|
55
|
+
return market.value?.currency ? [market.value.currency] : ["EUR"];
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** The market's default currency — initial choice and fallback. */
|
|
59
|
+
const defaultCurrency = computed(() => {
|
|
60
|
+
const flagged = market.value?.currencies?.find(c => c.isDefault)?.code;
|
|
61
|
+
return flagged ?? market.value?.currency ?? currencies.value[0] ?? "EUR";
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** The active currency: the cookie choice if the market offers it, else the default. */
|
|
65
|
+
const currency = computed(() => {
|
|
66
|
+
const chosen = currencyCode.value;
|
|
67
|
+
return chosen && currencies.value.includes(chosen) ? chosen : defaultCurrency.value;
|
|
68
|
+
});
|
|
69
|
+
|
|
42
70
|
const currencySymbol = computed(() => CURRENCY_SYMBOLS[currency.value] ?? currency.value);
|
|
43
71
|
|
|
44
|
-
|
|
72
|
+
const setCurrency = (code: string): void => {
|
|
73
|
+
currencyCode.value = code;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return { market, setMarket, region, currencies, currency, defaultCurrency, currencySymbol, setCurrency };
|
|
45
77
|
}
|
package/app/interfaces/market.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Storefront view of the markets app: a market bundles currency
|
|
3
|
-
* locales (language + country)
|
|
2
|
+
* Storefront view of the markets app: a market bundles its base currency,
|
|
3
|
+
* locales (language + country) and the currencies it trades in; the locale
|
|
4
|
+
* selector is driven by this.
|
|
4
5
|
*/
|
|
5
6
|
export interface ShopMarketLocale {
|
|
6
7
|
/** BCP-47 code, e.g. "de-DE". */
|
|
@@ -12,13 +13,25 @@ export interface ShopMarketLocale {
|
|
|
12
13
|
isDefault: boolean;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
export interface ShopMarketCurrency {
|
|
17
|
+
/** ISO 4217 code, e.g. "EUR". */
|
|
18
|
+
code: string;
|
|
19
|
+
isDefault: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
export interface ShopMarket {
|
|
16
23
|
id: string;
|
|
17
24
|
/** Stable market code, e.g. "de". */
|
|
18
25
|
code: string;
|
|
19
26
|
name: string;
|
|
20
|
-
/** ISO 4217, e.g. "EUR". */
|
|
27
|
+
/** ISO 4217 base currency, e.g. "EUR" — the fallback when none is chosen. */
|
|
21
28
|
currency: string;
|
|
22
29
|
isDefault: boolean;
|
|
23
30
|
locales: ShopMarketLocale[];
|
|
31
|
+
/**
|
|
32
|
+
* The currencies the market trades in (loosely configured in the markets
|
|
33
|
+
* app). Empty falls back to the single base `currency`; the selector lets
|
|
34
|
+
* the buyer pick one and prices resolve in it.
|
|
35
|
+
*/
|
|
36
|
+
currencies: ShopMarketCurrency[];
|
|
24
37
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revenexx/cover",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "Cover \u2014 revenexx design system for Nuxt. Distributed as a Nuxt layer: generic UI components, theming tokens and stores shared by the demo shop, custom storefronts and the Blokkli theme.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./nuxt.config.ts",
|
|
@@ -1,9 +1,90 @@
|
|
|
1
|
+
import type { H3Event } from "h3";
|
|
2
|
+
import type { Models } from "@revenexx/sdk";
|
|
3
|
+
|
|
1
4
|
import type { CartCalculationRequest } from "../../../app/interfaces/cart-calculation";
|
|
5
|
+
import type { CartCalculationOverrides } from "../../interfaces/cartCalculation";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the pricing inputs the BFF must not invent itself — per-item tax
|
|
9
|
+
* rates (prices app) and the shipping fee + its tax rate (shipping app) — so
|
|
10
|
+
* the cart/checkout summary matches the totals the orders app will store.
|
|
11
|
+
* Best-effort: any failure leaves the demo profile to fill in (mock fees).
|
|
12
|
+
*/
|
|
13
|
+
async function resolveLiveOverrides(
|
|
14
|
+
event: H3Event,
|
|
15
|
+
body: CartCalculationRequest,
|
|
16
|
+
subtotal: number,
|
|
17
|
+
): Promise<CartCalculationOverrides> {
|
|
18
|
+
const overrides: CartCalculationOverrides = {};
|
|
19
|
+
const currency = resolveSelectedCurrency(event);
|
|
20
|
+
if (currency) {
|
|
21
|
+
overrides.currency = currency;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (resolvePriceServiceKey(event) === "api" && body.items.length) {
|
|
25
|
+
try {
|
|
26
|
+
const { prices } = await useRevenexxSdk().prices.pricesResolve({
|
|
27
|
+
items: body.items.map(it => ({
|
|
28
|
+
product_id: String(it.id),
|
|
29
|
+
sku: it.sku ? String(it.sku) : undefined,
|
|
30
|
+
quantity: Number(it.quantity) || 1,
|
|
31
|
+
})) as Models.PriceResolveItem[],
|
|
32
|
+
...(currency ? { currency } : {}),
|
|
33
|
+
}) as unknown as { prices: Array<{ product_id: string | null; sku: string | null; tax_rate: number | null; on_request: boolean; unit_price: number | null }> };
|
|
34
|
+
const itemTaxRates: Record<string, number> = {};
|
|
35
|
+
const itemUnitPrices: Record<string, number> = {};
|
|
36
|
+
for (const p of prices) {
|
|
37
|
+
if (p.tax_rate != null) {
|
|
38
|
+
if (p.product_id) itemTaxRates[p.product_id] = p.tax_rate;
|
|
39
|
+
if (p.sku) itemTaxRates[p.sku] = p.tax_rate;
|
|
40
|
+
}
|
|
41
|
+
// Re-price in the resolved currency; on-request items keep the
|
|
42
|
+
// client price (the cart shows them as quote positions).
|
|
43
|
+
if (!p.on_request && p.unit_price != null) {
|
|
44
|
+
if (p.product_id) itemUnitPrices[p.product_id] = p.unit_price;
|
|
45
|
+
if (p.sku) itemUnitPrices[p.sku] = p.unit_price;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (Object.keys(itemTaxRates).length) {
|
|
49
|
+
overrides.itemTaxRates = itemTaxRates;
|
|
50
|
+
}
|
|
51
|
+
if (Object.keys(itemUnitPrices).length) {
|
|
52
|
+
overrides.itemUnitPrices = itemUnitPrices;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
getLogService().error("Cart item price/tax resolution failed", apiErrorContext(err));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (resolveShippingServiceKey(event) === "api") {
|
|
61
|
+
const method = body.deliveryMethod ?? "standard";
|
|
62
|
+
try {
|
|
63
|
+
const { rates } = await useRevenexxSdk().shipping.shippingRates({
|
|
64
|
+
orderValue: subtotal,
|
|
65
|
+
country: "",
|
|
66
|
+
...(currency ? { currency } : {}),
|
|
67
|
+
}) as unknown as { rates: Array<{ code: string; price: number; tax_rate: number | null }> };
|
|
68
|
+
const rate = rates.find(r => r.code === method) ?? rates[0];
|
|
69
|
+
if (rate) {
|
|
70
|
+
overrides.shipping = { price: rate.price, taxRate: Number(rate.tax_rate ?? 0) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
getLogService().error("Cart shipping-rate resolution failed", apiErrorContext(err));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return overrides;
|
|
79
|
+
}
|
|
2
80
|
|
|
3
81
|
/**
|
|
4
82
|
* Calculates the cart: per-line pricing, the ordered totals block, line
|
|
5
83
|
* hints, orderability and triggered spending limits. Works for guests
|
|
6
84
|
* (organization defaults) and logged-in users (role, limits, cost centers).
|
|
85
|
+
*
|
|
86
|
+
* Live mode enriches the totals with API-resolved item tax rates and the
|
|
87
|
+
* shipping fee so the summary equals the order the orders app will store.
|
|
7
88
|
*/
|
|
8
89
|
export default defineEventHandler(async (event) => {
|
|
9
90
|
const body = await readBody<CartCalculationRequest>(event);
|
|
@@ -16,7 +97,21 @@ export default defineEventHandler(async (event) => {
|
|
|
16
97
|
|
|
17
98
|
try {
|
|
18
99
|
const context = await getB2BContextService(event).getContext(user?.$id ?? null, user?.role ?? null);
|
|
19
|
-
|
|
100
|
+
const service = getCartCalculationService();
|
|
101
|
+
|
|
102
|
+
// First pass establishes the subtotal the shipping app prices against;
|
|
103
|
+
// a second pass folds the live fee + tax rates into the totals. Mock
|
|
104
|
+
// mode skips the round-trips entirely.
|
|
105
|
+
const base = await service.calculate(body, context, locale);
|
|
106
|
+
if (resolvePriceServiceKey(event) !== "api" && resolveShippingServiceKey(event) !== "api") {
|
|
107
|
+
return base;
|
|
108
|
+
}
|
|
109
|
+
const subtotal = base.totals.find(row => row.key === "subtotal")?.amount ?? 0;
|
|
110
|
+
const overrides = await resolveLiveOverrides(event, body, subtotal);
|
|
111
|
+
if (!overrides.shipping && !overrides.itemTaxRates && !overrides.itemUnitPrices && !overrides.currency) {
|
|
112
|
+
return base;
|
|
113
|
+
}
|
|
114
|
+
return await service.calculate(body, context, locale, overrides);
|
|
20
115
|
}
|
|
21
116
|
catch (err) {
|
|
22
117
|
getLogService().error("Service error: cart/calculate", toErrorContext(err));
|
|
@@ -128,6 +128,11 @@ export default defineEventHandler(async (event) => {
|
|
|
128
128
|
throw createError({ status: 422, message: addressError });
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// The buyer's chosen currency (locale selector cookie) — forwarded to
|
|
132
|
+
// every resolve so the order is priced, shipped and charged in one
|
|
133
|
+
// currency. Undefined → the apps fall back to their EUR price lists.
|
|
134
|
+
const selectedCurrency = resolveSelectedCurrency(event);
|
|
135
|
+
|
|
131
136
|
const liveShipping = resolveShippingServiceKey(event) === "api";
|
|
132
137
|
if (
|
|
133
138
|
!liveShipping
|
|
@@ -174,6 +179,7 @@ export default defineEventHandler(async (event) => {
|
|
|
174
179
|
orderValue: calculation.totals.find(row => row.key === "subtotal")?.amount
|
|
175
180
|
?? calculation.totals.find(row => row.key === "total")?.amount ?? 0,
|
|
176
181
|
country: String(address?.country ?? ""),
|
|
182
|
+
...(selectedCurrency ? { currency: selectedCurrency } : {}),
|
|
177
183
|
}) as unknown as { rates: Array<{ code: string; price: number; tax_rate: number | null }> };
|
|
178
184
|
const rate = rates.find(r => r.code === body.deliveryMethod);
|
|
179
185
|
if (!rate) {
|
|
@@ -224,26 +230,35 @@ export default defineEventHandler(async (event) => {
|
|
|
224
230
|
// app then taxes items + shipping and computes grand_total itself.
|
|
225
231
|
const orderItems = body.items as Array<Record<string, unknown>>;
|
|
226
232
|
const itemRate = new Map<string, number>();
|
|
233
|
+
const itemPrice = new Map<string, number>();
|
|
227
234
|
try {
|
|
228
235
|
const { prices: resolved } = await useRevenexxSdk().prices.pricesResolve({
|
|
229
236
|
items: orderItems.map(it => ({ product_id: String(it.id), sku: it.sku ? String(it.sku) : undefined, quantity: Number(it.quantity) || 1 })) as Models.PriceResolveItem[],
|
|
230
237
|
...(contactId ? { contactId } : {}),
|
|
231
238
|
...(organizationId ? { organizationId } : {}),
|
|
232
|
-
|
|
239
|
+
...(selectedCurrency ? { currency: selectedCurrency } : {}),
|
|
240
|
+
}) as unknown as { prices: Array<{ product_id: string | null; sku: string | null; tax_rate: number | null; on_request: boolean; unit_price: number | null }> };
|
|
233
241
|
for (const p of resolved) {
|
|
234
|
-
if (p.tax_rate
|
|
235
|
-
|
|
236
|
-
|
|
242
|
+
if (p.tax_rate != null) {
|
|
243
|
+
if (p.product_id) itemRate.set(p.product_id, p.tax_rate);
|
|
244
|
+
if (p.sku) itemRate.set(p.sku, p.tax_rate);
|
|
245
|
+
}
|
|
246
|
+
// Re-price in the resolved currency (the orders app is the SoR
|
|
247
|
+
// for totals, but it must be fed prices in the buyer's currency).
|
|
248
|
+
if (!p.on_request && p.unit_price != null) {
|
|
249
|
+
if (p.product_id) itemPrice.set(p.product_id, p.unit_price);
|
|
250
|
+
if (p.sku) itemPrice.set(p.sku, p.unit_price);
|
|
251
|
+
}
|
|
237
252
|
}
|
|
238
253
|
}
|
|
239
254
|
catch (rateErr) {
|
|
240
|
-
getLogService().error("Item tax
|
|
255
|
+
getLogService().error("Item price/tax resolution failed", apiErrorContext(rateErr));
|
|
241
256
|
}
|
|
242
257
|
|
|
243
258
|
const placed = await useRevenexxSdk().orders.ordersPlace({
|
|
244
259
|
...(contactId ? { contactId } : {}),
|
|
245
260
|
...(organizationId ? { organizationId } : {}),
|
|
246
|
-
currency: calculation.currency,
|
|
261
|
+
currency: selectedCurrency ?? calculation.currency,
|
|
247
262
|
...(body.orderNumber ? { customerOrderNumber: body.orderNumber } : {}),
|
|
248
263
|
buyer: user ? { name: user.name, email: user.email } : { email: body.contactEmail ?? null },
|
|
249
264
|
billingAddress: (billing ?? null) as object,
|
|
@@ -262,7 +277,7 @@ export default defineEventHandler(async (event) => {
|
|
|
262
277
|
sku: String(item.sku ?? "") || undefined,
|
|
263
278
|
name: String(item.name ?? item.sku ?? item.id),
|
|
264
279
|
quantity: calculation.lines[index]?.adjustedQuantity ?? Number(item.quantity),
|
|
265
|
-
unit_price: calculation.lines[index]?.unitPrice ?? Number(item.price ?? 0),
|
|
280
|
+
unit_price: itemPrice.get(String(item.id)) ?? itemPrice.get(String(item.sku ?? "")) ?? calculation.lines[index]?.unitPrice ?? Number(item.price ?? 0),
|
|
266
281
|
tax_rate: itemRate.get(String(item.id)) ?? itemRate.get(String(item.sku ?? "")) ?? 0,
|
|
267
282
|
product: {
|
|
268
283
|
...(item.image ? { image: String(item.image) } : {}),
|
|
@@ -307,7 +322,7 @@ export default defineEventHandler(async (event) => {
|
|
|
307
322
|
methodCode: payment.method,
|
|
308
323
|
// Charge exactly what the orders app computed (incl. shipping tax).
|
|
309
324
|
amount: Math.max(0, Math.round((liveOrderTotal ?? totalRow?.amount ?? 0) * 100) / 100),
|
|
310
|
-
currency: calculation.currency,
|
|
325
|
+
currency: selectedCurrency ?? calculation.currency,
|
|
311
326
|
country,
|
|
312
327
|
orderRef: orderId,
|
|
313
328
|
idempotencyKey: body.checkoutSessionToken,
|
package/server/data/markets.json
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
"locales": [
|
|
9
9
|
{ "code": "de-DE", "language": "de", "country": "DE", "isDefault": true },
|
|
10
10
|
{ "code": "en-GB", "language": "en", "country": "GB", "isDefault": false }
|
|
11
|
+
],
|
|
12
|
+
"currencies": [
|
|
13
|
+
{ "code": "EUR", "isDefault": true },
|
|
14
|
+
{ "code": "USD", "isDefault": false }
|
|
11
15
|
]
|
|
12
16
|
},
|
|
13
17
|
{
|
|
@@ -19,6 +23,10 @@
|
|
|
19
23
|
"locales": [
|
|
20
24
|
{ "code": "de-CH", "language": "de", "country": "CH", "isDefault": true },
|
|
21
25
|
{ "code": "en-GB", "language": "en", "country": "GB", "isDefault": false }
|
|
26
|
+
],
|
|
27
|
+
"currencies": [
|
|
28
|
+
{ "code": "CHF", "isDefault": true },
|
|
29
|
+
{ "code": "EUR", "isDefault": false }
|
|
22
30
|
]
|
|
23
31
|
}
|
|
24
32
|
]
|
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
import type { B2BContext } from "../../app/interfaces/b2b";
|
|
2
2
|
import type { CartCalculation, CartCalculationRequest } from "../../app/interfaces/cart-calculation";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Server-resolved pricing inputs the BFF must NOT compute itself — they come
|
|
6
|
+
* from the API (prices + shipping apps) so the cart/checkout summary matches
|
|
7
|
+
* the totals the orders app will store. Resolved per request in the cart
|
|
8
|
+
* endpoint; absent in mock mode (then the demo profile fills in).
|
|
9
|
+
*/
|
|
10
|
+
export interface CartCalculationOverrides {
|
|
11
|
+
/** The currency prices/shipping resolved in — becomes the cart currency. */
|
|
12
|
+
currency?: string;
|
|
13
|
+
/** Live shipping fee + its tax rate for the chosen method. */
|
|
14
|
+
shipping?: { price: number; taxRate: number };
|
|
15
|
+
/** Per-item tax rate (percent) keyed by product id or sku, from prices.resolve. */
|
|
16
|
+
itemTaxRates?: Record<string, number>;
|
|
17
|
+
/**
|
|
18
|
+
* Per-item unit price (in the resolved currency) keyed by product id or
|
|
19
|
+
* sku. Re-prices the cart in the chosen currency instead of trusting the
|
|
20
|
+
* client-supplied price — the dumb BFF reads prices from the prices app.
|
|
21
|
+
*/
|
|
22
|
+
itemUnitPrices?: Record<string, number>;
|
|
23
|
+
}
|
|
24
|
+
|
|
4
25
|
/**
|
|
5
26
|
* Service contract for cart calculation. ERP-backed implementations send
|
|
6
27
|
* the cart downstream and map the document response into the generic
|
|
@@ -13,5 +34,6 @@ export interface ICartCalculationService {
|
|
|
13
34
|
request: CartCalculationRequest,
|
|
14
35
|
context: B2BContext,
|
|
15
36
|
locale: string,
|
|
37
|
+
overrides?: CartCalculationOverrides,
|
|
16
38
|
): Promise<CartCalculation>;
|
|
17
39
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ShopMarket, ShopMarketLocale } from "../../app/interfaces/market";
|
|
1
|
+
import type { ShopMarket, ShopMarketCurrency, ShopMarketLocale } from "../../app/interfaces/market";
|
|
2
2
|
import type { IMarketService } from "../interfaces/market";
|
|
3
3
|
|
|
4
4
|
interface ApiMarketRow {
|
|
@@ -19,6 +19,19 @@ interface ApiLocaleRow {
|
|
|
19
19
|
position: number;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
interface ApiCurrencyRow {
|
|
23
|
+
code: string;
|
|
24
|
+
is_default: boolean;
|
|
25
|
+
position: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The markets app's /context response. Typed locally because the pinned SDK
|
|
29
|
+
// (0.0.5) predates the `currencies` field — at runtime the app returns it.
|
|
30
|
+
interface ApiMarketContext {
|
|
31
|
+
locales?: ApiLocaleRow[];
|
|
32
|
+
currencies?: ApiCurrencyRow[];
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
interface ApiListPage<T> {
|
|
23
36
|
items: T[];
|
|
24
37
|
}
|
|
@@ -31,8 +44,9 @@ const cache = new Map<string, { markets: ShopMarket[]; loadedAt: number }>();
|
|
|
31
44
|
|
|
32
45
|
/**
|
|
33
46
|
* Live markets via the public revenexx API (markets app):
|
|
34
|
-
* GET /v1/markets + each market's locales
|
|
35
|
-
* renders on every page and markets
|
|
47
|
+
* GET /v1/markets + each market's /context (locales + currencies in one
|
|
48
|
+
* call). Cached briefly — the selector renders on every page and markets
|
|
49
|
+
* change rarely.
|
|
36
50
|
*/
|
|
37
51
|
export class ApiMarketService implements IMarketService {
|
|
38
52
|
async listMarkets(): Promise<ShopMarket[]> {
|
|
@@ -49,21 +63,25 @@ export class ApiMarketService implements IMarketService {
|
|
|
49
63
|
.sort((a, b) => a.position - b.position || a.code.localeCompare(b.code));
|
|
50
64
|
|
|
51
65
|
const markets = await Promise.all(active.map(async (market): Promise<ShopMarket> => {
|
|
52
|
-
const
|
|
66
|
+
const context = await sdk.markets.marketsContext({ id: market.id }) as unknown as ApiMarketContext;
|
|
67
|
+
const locales = (context.locales ?? []).slice().sort((a, b) => a.position - b.position);
|
|
68
|
+
const currencies = (context.currencies ?? []).slice().sort((a, b) => a.position - b.position);
|
|
53
69
|
return {
|
|
54
70
|
id: market.id,
|
|
55
71
|
code: market.code,
|
|
56
72
|
name: market.name,
|
|
57
73
|
currency: market.currency,
|
|
58
74
|
isDefault: market.is_default,
|
|
59
|
-
locales: locales
|
|
60
|
-
.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
locales: locales.map((locale): ShopMarketLocale => ({
|
|
76
|
+
code: locale.code,
|
|
77
|
+
language: locale.language,
|
|
78
|
+
country: locale.country,
|
|
79
|
+
isDefault: locale.is_default,
|
|
80
|
+
})),
|
|
81
|
+
currencies: currencies.map((currency): ShopMarketCurrency => ({
|
|
82
|
+
code: currency.code,
|
|
83
|
+
isDefault: currency.is_default,
|
|
84
|
+
})),
|
|
67
85
|
};
|
|
68
86
|
}));
|
|
69
87
|
|
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
} from "../../app/interfaces/cart-calculation";
|
|
11
11
|
import type { CartItem } from "../../app/interfaces/cart-item";
|
|
12
12
|
|
|
13
|
-
import type { ICartCalculationService } from "../interfaces/cartCalculation";
|
|
13
|
+
import type { CartCalculationOverrides, ICartCalculationService } from "../interfaces/cartCalculation";
|
|
14
14
|
|
|
15
15
|
/* ------------------------------------------------------------------ */
|
|
16
16
|
/* Demo rule tables — stand-ins for ERP master data */
|
|
@@ -86,6 +86,7 @@ export class MockCartCalculationService implements ICartCalculationService {
|
|
|
86
86
|
request: CartCalculationRequest,
|
|
87
87
|
context: B2BContext,
|
|
88
88
|
locale: string,
|
|
89
|
+
overrides?: CartCalculationOverrides,
|
|
89
90
|
): Promise<CartCalculation> {
|
|
90
91
|
await preloadLocaleNamespaces(locale, ["cart"]);
|
|
91
92
|
const t = (key: string, params?: Record<string, string | number>) =>
|
|
@@ -121,7 +122,10 @@ export class MockCartCalculationService implements ICartCalculationService {
|
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
const labelCode = PRICE_LABELS[item.id];
|
|
124
|
-
|
|
125
|
+
// Live mode re-prices in the resolved currency (prices app); mock
|
|
126
|
+
// mode uses the cart item's tier price.
|
|
127
|
+
const liveUnitPrice = overrides?.itemUnitPrices?.[item.id] ?? overrides?.itemUnitPrices?.[item.sku ?? ""];
|
|
128
|
+
const unitPrice = labelCode ? 0 : (liveUnitPrice ?? tieredUnitPrice(item, quantity));
|
|
125
129
|
const lineTotal = r2(unitPrice * quantity);
|
|
126
130
|
|
|
127
131
|
const surcharges = !labelCode && SURCHARGE_SKU_PREFIXES.some(p => (item.sku ?? "").startsWith(p))
|
|
@@ -198,9 +202,14 @@ export class MockCartCalculationService implements ICartCalculationService {
|
|
|
198
202
|
});
|
|
199
203
|
}
|
|
200
204
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
205
|
+
// Live mode: the shipping fee comes from the shipping app (resolved in
|
|
206
|
+
// the cart endpoint), so the summary matches the order. Mock mode keeps
|
|
207
|
+
// the demo profile's flat/free logic.
|
|
208
|
+
const shipping = overrides?.shipping
|
|
209
|
+
? r2(overrides.shipping.price)
|
|
210
|
+
: (request.deliveryMethod ?? "standard") === "express"
|
|
211
|
+
? EXPRESS_SHIPPING_FEE
|
|
212
|
+
: subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_SHIPPING_FEE;
|
|
204
213
|
totals.push({ key: "shipping", label: t("calc.totals.shipping"), amount: shipping, alwaysVisible: true });
|
|
205
214
|
|
|
206
215
|
// Taxable base per rate: line totals + line surcharges, discount applied
|
|
@@ -213,14 +222,23 @@ export class MockCartCalculationService implements ICartCalculationService {
|
|
|
213
222
|
if (line.priceLabel) {
|
|
214
223
|
continue;
|
|
215
224
|
}
|
|
216
|
-
|
|
225
|
+
// Prefer the API-resolved rate (prices app, by id or sku); fall back
|
|
226
|
+
// to the rate carried on the cart item.
|
|
227
|
+
const liveRate = overrides?.itemTaxRates?.[item.id] ?? overrides?.itemTaxRates?.[item.sku ?? ""];
|
|
228
|
+
const rate = normalizeRate(liveRate ?? item.taxRate);
|
|
217
229
|
const lineSurcharge = line.surcharges.reduce((s, c) => s + c.amountPerUnit * effectiveQty(line), 0);
|
|
218
230
|
const base = (line.lineTotal * discountFactor) + lineSurcharge;
|
|
219
231
|
basePerRate.set(rate, (basePerRate.get(rate) ?? 0) + base);
|
|
220
232
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
233
|
+
// The min-order charge is a BFF construct taxed at the default rate;
|
|
234
|
+
// shipping (a Nebenleistung) is taxed at the rate the shipping app
|
|
235
|
+
// resolved for the method — in mock mode both fall to the default.
|
|
236
|
+
if (minOrderCharge > 0) {
|
|
237
|
+
basePerRate.set(DEFAULT_TAX_RATE, (basePerRate.get(DEFAULT_TAX_RATE) ?? 0) + minOrderCharge);
|
|
238
|
+
}
|
|
239
|
+
if (shipping > 0) {
|
|
240
|
+
const shippingRate = overrides?.shipping ? normalizeRate(overrides.shipping.taxRate) : DEFAULT_TAX_RATE;
|
|
241
|
+
basePerRate.set(shippingRate, (basePerRate.get(shippingRate) ?? 0) + shipping);
|
|
224
242
|
}
|
|
225
243
|
|
|
226
244
|
let taxSum = 0;
|
|
@@ -290,7 +308,7 @@ export class MockCartCalculationService implements ICartCalculationService {
|
|
|
290
308
|
];
|
|
291
309
|
|
|
292
310
|
return {
|
|
293
|
-
currency: "EUR",
|
|
311
|
+
currency: overrides?.currency ?? "EUR",
|
|
294
312
|
lines,
|
|
295
313
|
totals,
|
|
296
314
|
orderable,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { H3Event } from "h3";
|
|
2
|
+
|
|
3
|
+
/** Cookie the storefront's locale selector persists the chosen currency in. */
|
|
4
|
+
export const CURRENCY_COOKIE_NAME = "cover-currency";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The buyer's chosen currency for server-side price resolution. Read from the
|
|
8
|
+
* cookie the locale selector sets — the BFF forwards it to the prices and
|
|
9
|
+
* shipping apps so the catalog, cart and order all resolve in one currency.
|
|
10
|
+
* Undefined → the prices app falls back to the price list's currency (EUR),
|
|
11
|
+
* so the shop works before any choice is made.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveSelectedCurrency(event: H3Event): string | undefined {
|
|
14
|
+
const raw = getCookie(event, CURRENCY_COOKIE_NAME);
|
|
15
|
+
if (!raw) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const code = raw.trim().toUpperCase();
|
|
19
|
+
return /^[A-Z]{3}$/.test(code) ? code : undefined;
|
|
20
|
+
}
|
|
@@ -62,9 +62,11 @@ export async function enrichProductsWithLivePrices(event: H3Event, products: Pro
|
|
|
62
62
|
if (products.length === 0) {
|
|
63
63
|
return products;
|
|
64
64
|
}
|
|
65
|
+
const currency = resolveSelectedCurrency(event);
|
|
65
66
|
const { prices } = await useRevenexxSdk().prices.pricesResolve({
|
|
66
67
|
items: products.map(p => ({ product_id: p.id, sku: p.sku, quantity: 1 })) as Models.PriceResolveItem[],
|
|
67
68
|
...priceContext(event),
|
|
69
|
+
...(currency ? { currency } : {}),
|
|
68
70
|
}) as unknown as { prices: ResolvedPrice[] };
|
|
69
71
|
|
|
70
72
|
const byKey = new Map<string, ResolvedPrice>();
|
|
@@ -85,9 +87,11 @@ export async function enrichDetailWithLivePrices(event: H3Event, detail: Product
|
|
|
85
87
|
if (!product) {
|
|
86
88
|
return detail;
|
|
87
89
|
}
|
|
90
|
+
const currency = resolveSelectedCurrency(event);
|
|
88
91
|
const { prices } = await useRevenexxSdk().prices.pricesResolve({
|
|
89
92
|
items: [{ product_id: product.id, sku: product.sku, quantity: 1 }] as Models.PriceResolveItem[],
|
|
90
93
|
...priceContext(event),
|
|
94
|
+
...(currency ? { currency } : {}),
|
|
91
95
|
}) as unknown as { prices: ResolvedPrice[] };
|
|
92
96
|
const resolved = prices[0];
|
|
93
97
|
return { ...detail, prices: resolved ? toPriceMap(resolved) : [] };
|