@revenexx/cover 0.1.18 → 0.1.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/cover",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
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,73 @@
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
+
20
+ if (resolvePriceServiceKey(event) === "api" && body.items.length) {
21
+ try {
22
+ const { prices } = await useRevenexxSdk().prices.pricesResolve({
23
+ items: body.items.map(it => ({
24
+ product_id: String(it.id),
25
+ sku: it.sku ? String(it.sku) : undefined,
26
+ quantity: Number(it.quantity) || 1,
27
+ })) as Models.PriceResolveItem[],
28
+ }) as unknown as { prices: Array<{ product_id: string | null; sku: string | null; tax_rate: number | null }> };
29
+ const itemTaxRates: Record<string, number> = {};
30
+ for (const p of prices) {
31
+ if (p.tax_rate == null) continue;
32
+ if (p.product_id) itemTaxRates[p.product_id] = p.tax_rate;
33
+ if (p.sku) itemTaxRates[p.sku] = p.tax_rate;
34
+ }
35
+ if (Object.keys(itemTaxRates).length) {
36
+ overrides.itemTaxRates = itemTaxRates;
37
+ }
38
+ }
39
+ catch (err) {
40
+ getLogService().error("Cart item tax-rate resolution failed", apiErrorContext(err));
41
+ }
42
+ }
43
+
44
+ if (resolveShippingServiceKey(event) === "api") {
45
+ const method = body.deliveryMethod ?? "standard";
46
+ try {
47
+ const { rates } = await useRevenexxSdk().shipping.shippingRates({
48
+ orderValue: subtotal,
49
+ country: "",
50
+ }) as unknown as { rates: Array<{ code: string; price: number; tax_rate: number | null }> };
51
+ const rate = rates.find(r => r.code === method) ?? rates[0];
52
+ if (rate) {
53
+ overrides.shipping = { price: rate.price, taxRate: Number(rate.tax_rate ?? 0) };
54
+ }
55
+ }
56
+ catch (err) {
57
+ getLogService().error("Cart shipping-rate resolution failed", apiErrorContext(err));
58
+ }
59
+ }
60
+
61
+ return overrides;
62
+ }
2
63
 
3
64
  /**
4
65
  * Calculates the cart: per-line pricing, the ordered totals block, line
5
66
  * hints, orderability and triggered spending limits. Works for guests
6
67
  * (organization defaults) and logged-in users (role, limits, cost centers).
68
+ *
69
+ * Live mode enriches the totals with API-resolved item tax rates and the
70
+ * shipping fee so the summary equals the order the orders app will store.
7
71
  */
8
72
  export default defineEventHandler(async (event) => {
9
73
  const body = await readBody<CartCalculationRequest>(event);
@@ -16,7 +80,21 @@ export default defineEventHandler(async (event) => {
16
80
 
17
81
  try {
18
82
  const context = await getB2BContextService(event).getContext(user?.$id ?? null, user?.role ?? null);
19
- return await getCartCalculationService().calculate(body, context, locale);
83
+ const service = getCartCalculationService();
84
+
85
+ // First pass establishes the subtotal the shipping app prices against;
86
+ // a second pass folds the live fee + tax rates into the totals. Mock
87
+ // mode skips the round-trips entirely.
88
+ const base = await service.calculate(body, context, locale);
89
+ if (resolvePriceServiceKey(event) !== "api" && resolveShippingServiceKey(event) !== "api") {
90
+ return base;
91
+ }
92
+ const subtotal = base.totals.find(row => row.key === "subtotal")?.amount ?? 0;
93
+ const overrides = await resolveLiveOverrides(event, body, subtotal);
94
+ if (!overrides.shipping && !overrides.itemTaxRates) {
95
+ return base;
96
+ }
97
+ return await service.calculate(body, context, locale, overrides);
20
98
  }
21
99
  catch (err) {
22
100
  getLogService().error("Service error: cart/calculate", toErrorContext(err));
@@ -1,6 +1,19 @@
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
+ /** Live shipping fee + its tax rate for the chosen method. */
12
+ shipping?: { price: number; taxRate: number };
13
+ /** Per-item tax rate (percent) keyed by product id or sku, from prices.resolve. */
14
+ itemTaxRates?: Record<string, number>;
15
+ }
16
+
4
17
  /**
5
18
  * Service contract for cart calculation. ERP-backed implementations send
6
19
  * the cart downstream and map the document response into the generic
@@ -13,5 +26,6 @@ export interface ICartCalculationService {
13
26
  request: CartCalculationRequest,
14
27
  context: B2BContext,
15
28
  locale: string,
29
+ overrides?: CartCalculationOverrides,
16
30
  ): Promise<CartCalculation>;
17
31
  }
@@ -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>) =>
@@ -198,9 +199,14 @@ export class MockCartCalculationService implements ICartCalculationService {
198
199
  });
199
200
  }
200
201
 
201
- const shipping = (request.deliveryMethod ?? "standard") === "express"
202
- ? EXPRESS_SHIPPING_FEE
203
- : subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_SHIPPING_FEE;
202
+ // Live mode: the shipping fee comes from the shipping app (resolved in
203
+ // the cart endpoint), so the summary matches the order. Mock mode keeps
204
+ // the demo profile's flat/free logic.
205
+ const shipping = overrides?.shipping
206
+ ? r2(overrides.shipping.price)
207
+ : (request.deliveryMethod ?? "standard") === "express"
208
+ ? EXPRESS_SHIPPING_FEE
209
+ : subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_SHIPPING_FEE;
204
210
  totals.push({ key: "shipping", label: t("calc.totals.shipping"), amount: shipping, alwaysVisible: true });
205
211
 
206
212
  // Taxable base per rate: line totals + line surcharges, discount applied
@@ -213,14 +219,23 @@ export class MockCartCalculationService implements ICartCalculationService {
213
219
  if (line.priceLabel) {
214
220
  continue;
215
221
  }
216
- const rate = normalizeRate(item.taxRate);
222
+ // Prefer the API-resolved rate (prices app, by id or sku); fall back
223
+ // to the rate carried on the cart item.
224
+ const liveRate = overrides?.itemTaxRates?.[item.id] ?? overrides?.itemTaxRates?.[item.sku ?? ""];
225
+ const rate = normalizeRate(liveRate ?? item.taxRate);
217
226
  const lineSurcharge = line.surcharges.reduce((s, c) => s + c.amountPerUnit * effectiveQty(line), 0);
218
227
  const base = (line.lineTotal * discountFactor) + lineSurcharge;
219
228
  basePerRate.set(rate, (basePerRate.get(rate) ?? 0) + base);
220
229
  }
221
- const docCharges = minOrderCharge + shipping;
222
- if (docCharges > 0) {
223
- basePerRate.set(DEFAULT_TAX_RATE, (basePerRate.get(DEFAULT_TAX_RATE) ?? 0) + docCharges);
230
+ // The min-order charge is a BFF construct taxed at the default rate;
231
+ // shipping (a Nebenleistung) is taxed at the rate the shipping app
232
+ // resolved for the method — in mock mode both fall to the default.
233
+ if (minOrderCharge > 0) {
234
+ basePerRate.set(DEFAULT_TAX_RATE, (basePerRate.get(DEFAULT_TAX_RATE) ?? 0) + minOrderCharge);
235
+ }
236
+ if (shipping > 0) {
237
+ const shippingRate = overrides?.shipping ? normalizeRate(overrides.shipping.taxRate) : DEFAULT_TAX_RATE;
238
+ basePerRate.set(shippingRate, (basePerRate.get(shippingRate) ?? 0) + shipping);
224
239
  }
225
240
 
226
241
  let taxSum = 0;