@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.
@@ -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: defined by the market -->
112
- <div class="flex items-center justify-between text-sm">
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, currency and the offered locales the selector picks a market,
8
- * language stays an independent choice within the market's locales
9
- * (Smashing Magazine, "Designing A Better Language Selector"). Persisted
10
- * as a cookie so SSR renders the same selection.
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
- const currency = computed(() => market.value?.currency ?? "EUR");
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
- return { market, setMarket, region, currency, currencySymbol };
72
+ const setCurrency = (code: string): void => {
73
+ currencyCode.value = code;
74
+ };
75
+
76
+ return { market, setMarket, region, currencies, currency, defaultCurrency, currencySymbol, setCurrency };
45
77
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Storefront view of the markets app: a market bundles currency and
3
- * locales (language + country); the locale selector is driven by this.
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.18",
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
- return await getCartCalculationService().calculate(body, context, locale);
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
- }) as unknown as { prices: Array<{ product_id: string | null; sku: string | null; tax_rate: number | null }> };
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 == null) continue;
235
- if (p.product_id) itemRate.set(p.product_id, p.tax_rate);
236
- if (p.sku) itemRate.set(p.sku, p.tax_rate);
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-rate resolution failed", apiErrorContext(rateErr));
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,
@@ -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. Cached briefly the selector
35
- * renders on every page and markets change rarely.
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 { items: locales } = await sdk.markets.marketsLocalesList({ marketId: market.id }) as unknown as ApiListPage<ApiLocaleRow>;
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
- .sort((a, b) => a.position - b.position)
61
- .map((locale): ShopMarketLocale => ({
62
- code: locale.code,
63
- language: locale.language,
64
- country: locale.country,
65
- isDefault: locale.is_default,
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
- const unitPrice = labelCode ? 0 : tieredUnitPrice(item, quantity);
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
- const shipping = (request.deliveryMethod ?? "standard") === "express"
202
- ? EXPRESS_SHIPPING_FEE
203
- : subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_SHIPPING_FEE;
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
- const rate = normalizeRate(item.taxRate);
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
- const docCharges = minOrderCharge + shipping;
222
- if (docCharges > 0) {
223
- basePerRate.set(DEFAULT_TAX_RATE, (basePerRate.get(DEFAULT_TAX_RATE) ?? 0) + docCharges);
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) : [] };