@revenexx/cover 0.1.17 → 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.
|
|
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
|
-
|
|
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));
|
|
@@ -163,7 +163,10 @@ export default defineEventHandler(async (event) => {
|
|
|
163
163
|
// Live shipping: the chosen method must be a real rate for this
|
|
164
164
|
// buyer context; its price replaces the demo calculation's shipping
|
|
165
165
|
// line in the order totals and the amount the payment is created over.
|
|
166
|
-
|
|
166
|
+
// The chosen live shipping rate carries its own price AND tax_rate
|
|
167
|
+
// (resolved by the shipping app from markets.tax_classes). The order app
|
|
168
|
+
// taxes shipping with it — the BFF computes no tax itself.
|
|
169
|
+
let liveShippingRate: { price: number; tax_rate: number } | null = null;
|
|
167
170
|
if (liveShipping && body.deliveryMethod) {
|
|
168
171
|
const address = body.address as Record<string, unknown> | undefined;
|
|
169
172
|
try {
|
|
@@ -171,13 +174,12 @@ export default defineEventHandler(async (event) => {
|
|
|
171
174
|
orderValue: calculation.totals.find(row => row.key === "subtotal")?.amount
|
|
172
175
|
?? calculation.totals.find(row => row.key === "total")?.amount ?? 0,
|
|
173
176
|
country: String(address?.country ?? ""),
|
|
174
|
-
}) as unknown as { rates: Array<{ code: string; price: number }> };
|
|
177
|
+
}) as unknown as { rates: Array<{ code: string; price: number; tax_rate: number | null }> };
|
|
175
178
|
const rate = rates.find(r => r.code === body.deliveryMethod);
|
|
176
179
|
if (!rate) {
|
|
177
180
|
throw createError({ status: 422, message: `Delivery method '${body.deliveryMethod}' is not available for this order` });
|
|
178
181
|
}
|
|
179
|
-
|
|
180
|
-
liveShippingAdjustment = rate.price - mockShipping;
|
|
182
|
+
liveShippingRate = { price: rate.price, tax_rate: Number(rate.tax_rate ?? 0) };
|
|
181
183
|
}
|
|
182
184
|
catch (err) {
|
|
183
185
|
if (isError(err)) {
|
|
@@ -196,6 +198,9 @@ export default defineEventHandler(async (event) => {
|
|
|
196
198
|
// approvals domain is deliberately not part of OM phase 1.
|
|
197
199
|
const liveOrders = resolveOrderServiceKey(event) === "api" && blockingApprovals.length === 0;
|
|
198
200
|
let liveOrderUuid: string | null = null;
|
|
201
|
+
// The placed order's grand_total (orders app = source of truth) drives
|
|
202
|
+
// the payment amount, so payment and order can never diverge.
|
|
203
|
+
let liveOrderTotal: number | null = null;
|
|
199
204
|
if (liveOrders) {
|
|
200
205
|
const address = body.address as Record<string, unknown>;
|
|
201
206
|
const billing = address?.billingAddressSameAsShipping === false
|
|
@@ -213,6 +218,28 @@ export default defineEventHandler(async (event) => {
|
|
|
213
218
|
// order history, filtered strictly by contact_id).
|
|
214
219
|
const contactId = refs.contact_id ?? user?.contactId;
|
|
215
220
|
const organizationId = refs.organization_id ?? user?.organizationId;
|
|
221
|
+
|
|
222
|
+
// Per-item tax rate from the prices app (same resolve that prices
|
|
223
|
+
// the storefront), not a BFF constant — one bulk call. The orders
|
|
224
|
+
// app then taxes items + shipping and computes grand_total itself.
|
|
225
|
+
const orderItems = body.items as Array<Record<string, unknown>>;
|
|
226
|
+
const itemRate = new Map<string, number>();
|
|
227
|
+
try {
|
|
228
|
+
const { prices: resolved } = await useRevenexxSdk().prices.pricesResolve({
|
|
229
|
+
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
|
+
...(contactId ? { contactId } : {}),
|
|
231
|
+
...(organizationId ? { organizationId } : {}),
|
|
232
|
+
}) as unknown as { prices: Array<{ product_id: string | null; sku: string | null; tax_rate: number | null }> };
|
|
233
|
+
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);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (rateErr) {
|
|
240
|
+
getLogService().error("Item tax-rate resolution failed", apiErrorContext(rateErr));
|
|
241
|
+
}
|
|
242
|
+
|
|
216
243
|
const placed = await useRevenexxSdk().orders.ordersPlace({
|
|
217
244
|
...(contactId ? { contactId } : {}),
|
|
218
245
|
...(organizationId ? { organizationId } : {}),
|
|
@@ -222,20 +249,21 @@ export default defineEventHandler(async (event) => {
|
|
|
222
249
|
billingAddress: (billing ?? null) as object,
|
|
223
250
|
shippingAddress: (address ?? null) as object,
|
|
224
251
|
payment: { method: payment.method },
|
|
252
|
+
// Shipping price + tax_rate from the shipping app; the orders
|
|
253
|
+
// app taxes it and computes grand_total — the BFF sends NO
|
|
254
|
+
// grand_total (orders is the single source of truth).
|
|
225
255
|
shipping: {
|
|
226
256
|
method: body.deliveryMethod ?? "standard",
|
|
227
|
-
price: Math.max(0,
|
|
257
|
+
price: Math.max(0, liveShippingRate?.price ?? shippingRow?.amount ?? 0),
|
|
258
|
+
tax_rate: liveShippingRate?.tax_rate ?? 0,
|
|
228
259
|
},
|
|
229
|
-
|
|
230
|
-
// charges): the checkout calculation's total.
|
|
231
|
-
grandTotal: Math.max(0, Math.round(((totalRow?.amount ?? 0) + liveShippingAdjustment) * 100) / 100),
|
|
232
|
-
items: (body.items as Array<Record<string, unknown>>).map((item, index) => ({
|
|
260
|
+
items: orderItems.map((item, index) => ({
|
|
233
261
|
product_id: String(item.id),
|
|
234
262
|
sku: String(item.sku ?? "") || undefined,
|
|
235
263
|
name: String(item.name ?? item.sku ?? item.id),
|
|
236
264
|
quantity: calculation.lines[index]?.adjustedQuantity ?? Number(item.quantity),
|
|
237
265
|
unit_price: calculation.lines[index]?.unitPrice ?? Number(item.price ?? 0),
|
|
238
|
-
tax_rate:
|
|
266
|
+
tax_rate: itemRate.get(String(item.id)) ?? itemRate.get(String(item.sku ?? "")) ?? 0,
|
|
239
267
|
product: {
|
|
240
268
|
...(item.image ? { image: String(item.image) } : {}),
|
|
241
269
|
...(item.categorySlug ? { categorySlug: String(item.categorySlug) } : {}),
|
|
@@ -248,9 +276,10 @@ export default defineEventHandler(async (event) => {
|
|
|
248
276
|
...(body.partialDelivery !== undefined ? { partial_delivery: body.partialDelivery } : {}),
|
|
249
277
|
...(body.requestedDate ? { requested_date: body.requestedDate } : {}),
|
|
250
278
|
},
|
|
251
|
-
}) as unknown as { id: string; number: string };
|
|
279
|
+
}) as unknown as { id: string; number: string; grand_total?: number };
|
|
252
280
|
orderId = placed.number;
|
|
253
281
|
liveOrderUuid = placed.id;
|
|
282
|
+
liveOrderTotal = typeof placed.grand_total === "number" ? placed.grand_total : null;
|
|
254
283
|
}
|
|
255
284
|
catch (err) {
|
|
256
285
|
if (err instanceof RevenexxException && (err.code === 400 || err.code === 422)) {
|
|
@@ -276,7 +305,8 @@ export default defineEventHandler(async (event) => {
|
|
|
276
305
|
try {
|
|
277
306
|
const created = await useRevenexxSdk().payments.paymentsCreate({
|
|
278
307
|
methodCode: payment.method,
|
|
279
|
-
|
|
308
|
+
// Charge exactly what the orders app computed (incl. shipping tax).
|
|
309
|
+
amount: Math.max(0, Math.round((liveOrderTotal ?? totalRow?.amount ?? 0) * 100) / 100),
|
|
280
310
|
currency: calculation.currency,
|
|
281
311
|
country,
|
|
282
312
|
orderRef: orderId,
|
|
@@ -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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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;
|