washday-sdk 1.6.63 → 1.6.65

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.
@@ -69,3 +69,23 @@ export const getCFDIAddonStatus = function (data) {
69
69
  }
70
70
  });
71
71
  };
72
+ export const getAvailableSubscriptionPlans = function () {
73
+ return __awaiter(this, arguments, void 0, function* (data = {}) {
74
+ try {
75
+ const config = {
76
+ headers: { Authorization: `Bearer ${this.apiToken}` }
77
+ };
78
+ const params = new URLSearchParams();
79
+ if (data.companyId) {
80
+ params.set("companyId", data.companyId);
81
+ }
82
+ const query = params.toString();
83
+ const response = yield this.axiosInstance.get(`${GET_SET_BILLING}/subscription/available-plans${query ? `?${query}` : ""}`, config);
84
+ return response;
85
+ }
86
+ catch (error) {
87
+ console.error('Error fetching getAvailableSubscriptionPlans:', error);
88
+ throw error;
89
+ }
90
+ });
91
+ };
@@ -99,3 +99,33 @@ export const disableCFDIAddon = function (data) {
99
99
  }
100
100
  });
101
101
  };
102
+ export const previewSubscriptionPlanChange = function (data) {
103
+ return __awaiter(this, void 0, void 0, function* () {
104
+ try {
105
+ const config = {
106
+ headers: { Authorization: `Bearer ${this.apiToken}` }
107
+ };
108
+ const response = yield this.axiosInstance.post(`${GET_SET_BILLING}/subscription/change-plan/preview`, data, config);
109
+ return response;
110
+ }
111
+ catch (error) {
112
+ console.error('Error fetching previewSubscriptionPlanChange:', error);
113
+ throw error;
114
+ }
115
+ });
116
+ };
117
+ export const changeSubscriptionPlan = function (data) {
118
+ return __awaiter(this, void 0, void 0, function* () {
119
+ try {
120
+ const config = {
121
+ headers: { Authorization: `Bearer ${this.apiToken}` }
122
+ };
123
+ const response = yield this.axiosInstance.post(`${GET_SET_BILLING}/subscription/change-plan`, data, config);
124
+ return response;
125
+ }
126
+ catch (error) {
127
+ console.error('Error fetching changeSubscriptionPlan:', error);
128
+ throw error;
129
+ }
130
+ });
131
+ };
package/dist/api/index.js CHANGED
@@ -2,8 +2,8 @@ import { deleteCashierBoxById, deleteCashierBoxMovementById } from "./cashierbox
2
2
  import { getCashierBoxMovementsHistory, getCashierboxesById, getCashierboxesByStoreId } from "./cashierbox/get";
3
3
  import { addCashierBoxMovement, createCashierBox } from "./cashierbox/post";
4
4
  import { updateCashierBoxById, updateCashierBoxMovementById } from "./cashierbox/put";
5
- import { getCFDIAddonStatus, getCompanyById, getCompanyOrdersMetrics, getGarmentAttributeCatalogs } from "./companies/get";
6
- import { disableBillingOverage, disableCFDIAddon, enableBillingOverage, enableCFDIAddon, updateCFDIOrgLogo, updateCompanyTaxInfoCertificates } from "./companies/post";
5
+ import { getAvailableSubscriptionPlans, getCFDIAddonStatus, getCompanyById, getCompanyOrdersMetrics, getGarmentAttributeCatalogs } from "./companies/get";
6
+ import { changeSubscriptionPlan, disableBillingOverage, disableCFDIAddon, enableBillingOverage, enableCFDIAddon, previewSubscriptionPlanChange, updateCFDIOrgLogo, updateCompanyTaxInfoCertificates } from "./companies/post";
7
7
  import { updateCompanyById, updateCompanyLogoById, updateCompanySubscriptionBillingInfoById, updateCompanyTaxInfoById, updateGarmentAttributeCatalogsById } from "./companies/put";
8
8
  import { getCountries } from "./countries/get";
9
9
  import * as configEndpoints from "./config";
@@ -288,6 +288,9 @@ const WashdayClient = function WashdayClient(apiToken, env = 'PROD', clientId, c
288
288
  enableCFDIAddon: enableCFDIAddon,
289
289
  disableCFDIAddon: disableCFDIAddon,
290
290
  getCFDIAddonStatus: getCFDIAddonStatus,
291
+ getAvailableSubscriptionPlans: getAvailableSubscriptionPlans,
292
+ previewSubscriptionPlanChange: previewSubscriptionPlanChange,
293
+ changeSubscriptionPlan: changeSubscriptionPlan,
291
294
  });
292
295
  this.stripe = bindMethods(this, {
293
296
  createCreateSuscriptionCheckoutSession: createCreateSuscriptionCheckoutSession,
@@ -46,7 +46,7 @@ export const calculateOrderTotal = (order, selectedCustomer, storeSettings, hasS
46
46
  : calculateTotalTaxesIncluded;
47
47
  const productTableImports = productTableCalculator(order, selectedCustomer, storeSettings, storeDiscounts, appliedOrderDiscounts, discountCodeObj);
48
48
  // === PRODUCT LINE TOTALS ===
49
- const { totalQuantity, totalImportWithDiscount, totalImportWithoutDiscount, totalImporTaxes, totalDiscountAmount, totalSubtotalAmount } = getProductLineTotals(productTableImports);
49
+ const { totalQuantity, totalImportWithDiscount, totalImportWithoutDiscount, totalImporTaxes, totalDiscountAmount, totalSubtotalAmount, taxBreakdown } = getProductLineTotals(productTableImports);
50
50
  // === DISCOUNT CODE (monetario tipo NUMBER que se aplica una sola vez) ===
51
51
  let discountCodeAmount = 0;
52
52
  if (discountCodeObj &&
@@ -68,7 +68,7 @@ export const calculateOrderTotal = (order, selectedCustomer, storeSettings, hasS
68
68
  creditApplied -
69
69
  redeemPointsDiscount).toFixed(2);
70
70
  // === RETURN FINAL OBJECT ===
71
- return Object.assign(Object.assign({}, order), { totalQuantity, customerDiscount: order.discountCode ? 0 : (_a = selectedCustomer === null || selectedCustomer === void 0 ? void 0 : selectedCustomer.customer) === null || _a === void 0 ? void 0 : _a.discount, productTotal: totalImportWithDiscount, taxesTotal: totalImporTaxes, total: orderTotal, creditApplied, redeemPointsApplied: redeemPointsDiscount, productTotalWithoutDiscount: totalImportWithoutDiscount, totalDiscountAmount: totalDiscountAmount + discountCodeAmount, shippingServiceTotal: shippingCost, appliedOrderDiscounts, subtotal: totalSubtotalAmount });
71
+ return Object.assign(Object.assign({}, order), { totalQuantity, customerDiscount: order.discountCode ? 0 : (_a = selectedCustomer === null || selectedCustomer === void 0 ? void 0 : selectedCustomer.customer) === null || _a === void 0 ? void 0 : _a.discount, productTotal: totalImportWithDiscount, taxesTotal: totalImporTaxes, taxBreakdown, total: orderTotal, creditApplied, redeemPointsApplied: redeemPointsDiscount, productTotalWithoutDiscount: totalImportWithoutDiscount, totalDiscountAmount: totalDiscountAmount + discountCodeAmount, shippingServiceTotal: shippingCost, appliedOrderDiscounts, subtotal: totalSubtotalAmount });
72
72
  }
73
73
  catch (error) {
74
74
  throw error;
@@ -1,5 +1,5 @@
1
1
  import { DiscountCodeTypes } from "../../enum";
2
- import { getProductTaxesPercentage } from "./helpers";
2
+ import { calculateProductTaxBreakdown, getProductTaxesPercentage } from "./helpers";
3
3
  const getNormalizedId = (value) => {
4
4
  if (value === null || value === undefined) {
5
5
  return "";
@@ -106,6 +106,11 @@ export const calculateTotalTaxesIncluded = (order, selectedCustomer, storeSettin
106
106
  const productLineTotalWithDiscount = +(productNetTotalWithDiscount * taxFactor).toFixed(2);
107
107
  // Calcular el total de impuestos como la diferencia entre bruto y neto con descuento
108
108
  const totalTaxesApplied = +(productLineTotalWithDiscount - productNetTotalWithDiscount).toFixed(2);
109
+ const taxBreakdown = calculateProductTaxBreakdown({
110
+ product: current,
111
+ store: storeSettings,
112
+ lineTaxAmount: totalTaxesApplied,
113
+ });
109
114
  const lineDiscount = +(discountAmountPerUnit * qty).toFixed(2);
110
115
  return {
111
116
  product: current,
@@ -113,6 +118,7 @@ export const calculateTotalTaxesIncluded = (order, selectedCustomer, storeSettin
113
118
  productLineImportTotal, // Total bruto sin descuento (incluye extra y IVA)
114
119
  productLineTotalWithDiscount, // Total bruto con descuento aplicado sobre el precio ajustado
115
120
  productLineTaxesTotal: totalTaxesApplied,
121
+ taxBreakdown,
116
122
  lineDiscountAmount: lineDiscount,
117
123
  productLineSubtotal: productNetTotalWithoutDiscount // Subtotal neto (sin IVA)
118
124
  };
@@ -1,5 +1,5 @@
1
1
  import { BuyAndGetConditionsTypes, DiscountCodeTypes } from "../../enum";
2
- import { getProductTaxesPercentage } from "./helpers";
2
+ import { calculateProductTaxBreakdown, getProductTaxesPercentage } from "./helpers";
3
3
  const getNormalizedId = (value) => {
4
4
  if (value === null || value === undefined) {
5
5
  return "";
@@ -91,6 +91,11 @@ export const calculateTotalTaxesOverPrice = (order, selectedCustomer, storeSetti
91
91
  const taxes = subtotal * taxPercentage;
92
92
  // Resultados finales:
93
93
  const totalTaxesApplied = +taxes.toFixed(2);
94
+ const taxBreakdown = calculateProductTaxBreakdown({
95
+ product: current,
96
+ store: storeSettings,
97
+ lineTaxAmount: totalTaxesApplied,
98
+ });
94
99
  const lineDiscount = +(productDiscount * qty).toFixed(2);
95
100
  // Precio total con descuento, reaplicando impuestos
96
101
  const prodLinePriceWithDiscount = +((subtotal * (1 + taxPercentage))).toFixed(2);
@@ -102,6 +107,7 @@ export const calculateTotalTaxesOverPrice = (order, selectedCustomer, storeSetti
102
107
  // Total final con descuento, con impuestos incluidos
103
108
  productLineTotalWithDiscount: prodLinePriceWithDiscount,
104
109
  productLineTaxesTotal: totalTaxesApplied,
110
+ taxBreakdown,
105
111
  lineDiscountAmount: lineDiscount,
106
112
  // Subtotal neto (sin impuestos) con descuento aplicado
107
113
  productLineSubtotal: subtotal
@@ -84,6 +84,103 @@ export const getProductTaxesPercentage = (productObj, store) => {
84
84
  const tax3 = getTaxValue(store.taxThree, productObj.taxExemptThree);
85
85
  return tax1 + tax2 + tax3;
86
86
  };
87
+ const TAX_SLOTS = [
88
+ { key: "taxOne", taxField: "taxOne", exemptField: "taxExemptOne" },
89
+ { key: "taxTwo", taxField: "taxTwo", exemptField: "taxExemptTwo" },
90
+ { key: "taxThree", taxField: "taxThree", exemptField: "taxExemptThree" },
91
+ ];
92
+ const round2 = (value) => Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
93
+ const getApplicableTaxesForProduct = (productObj, store) => {
94
+ return TAX_SLOTS.reduce((acc, slot) => {
95
+ var _a, _b;
96
+ if (productObj[slot.exemptField]) {
97
+ return acc;
98
+ }
99
+ const tax = store[slot.taxField];
100
+ const rate = Number((_a = tax === null || tax === void 0 ? void 0 : tax.value) !== null && _a !== void 0 ? _a : 0);
101
+ if (!Number.isFinite(rate) || rate <= 0) {
102
+ return acc;
103
+ }
104
+ acc.push({
105
+ key: slot.key,
106
+ name: String((_b = tax === null || tax === void 0 ? void 0 : tax.name) !== null && _b !== void 0 ? _b : slot.key),
107
+ rate,
108
+ weight: Math.round(rate * 100),
109
+ });
110
+ return acc;
111
+ }, []);
112
+ };
113
+ const allocateByWeights = (total, weights) => {
114
+ if (weights.length === 0)
115
+ return [];
116
+ if (total <= 0)
117
+ return new Array(weights.length).fill(0);
118
+ const safeWeights = weights.map((weight) => Math.max(0, Math.trunc(weight)));
119
+ const sum = safeWeights.reduce((acc, weight) => acc + weight, 0);
120
+ if (sum <= 0)
121
+ return new Array(weights.length).fill(0);
122
+ const raw = safeWeights.map((weight) => (total * weight) / sum);
123
+ const floors = raw.map((amount) => Math.floor(amount));
124
+ let remaining = total - floors.reduce((acc, amount) => acc + amount, 0);
125
+ const order = raw
126
+ .map((amount, index) => ({ index, frac: amount - Math.floor(amount) }))
127
+ .sort((a, b) => b.frac - a.frac);
128
+ const out = floors.slice();
129
+ for (let index = 0; index < order.length && remaining > 0; index += 1) {
130
+ out[order[index].index] += 1;
131
+ remaining -= 1;
132
+ }
133
+ return out;
134
+ };
135
+ export const aggregateTaxBreakdown = (items) => {
136
+ const byKey = new Map();
137
+ items.forEach((item) => {
138
+ if (!item || Number(item.amount || 0) <= 0) {
139
+ return;
140
+ }
141
+ const existing = byKey.get(item.key);
142
+ if (existing) {
143
+ existing.amount = round2(existing.amount + item.amount);
144
+ return;
145
+ }
146
+ byKey.set(item.key, Object.assign(Object.assign({}, item), { amount: round2(item.amount) }));
147
+ });
148
+ return TAX_SLOTS.map((slot) => byKey.get(slot.key)).filter((item) => Boolean(item && item.amount > 0));
149
+ };
150
+ export const normalizeTaxBreakdownToTotal = (items, total) => {
151
+ const target = round2(total);
152
+ if (target <= 0) {
153
+ return [];
154
+ }
155
+ const aggregated = aggregateTaxBreakdown(items);
156
+ if (aggregated.length === 0) {
157
+ return [];
158
+ }
159
+ const sum = round2(aggregated.reduce((acc, item) => acc + item.amount, 0));
160
+ const diff = round2(target - sum);
161
+ if (diff !== 0) {
162
+ const lastIndex = aggregated.length - 1;
163
+ aggregated[lastIndex] = Object.assign(Object.assign({}, aggregated[lastIndex]), { amount: round2(aggregated[lastIndex].amount + diff) });
164
+ }
165
+ return aggregated.filter((item) => item.amount > 0);
166
+ };
167
+ export const calculateProductTaxBreakdown = ({ product, store, lineTaxAmount, }) => {
168
+ const applicableTaxes = getApplicableTaxesForProduct(product, store);
169
+ const target = round2(lineTaxAmount);
170
+ if (target <= 0 || applicableTaxes.length === 0) {
171
+ return [];
172
+ }
173
+ const amounts = allocateByWeights(Math.round(target * 100), applicableTaxes.map((tax) => tax.weight)).map((amountCents) => round2(amountCents / 100));
174
+ return normalizeTaxBreakdownToTotal(applicableTaxes.map((tax, index) => {
175
+ var _a;
176
+ return ({
177
+ key: tax.key,
178
+ name: tax.name,
179
+ rate: tax.rate,
180
+ amount: (_a = amounts[index]) !== null && _a !== void 0 ? _a : 0,
181
+ });
182
+ }), target);
183
+ };
87
184
  export const getProductLineTotals = (productLinesTotals) => {
88
185
  const totalQuantity = +productLinesTotals.reduce((acum, line) => acum + line.qty, 0).toFixed(2);
89
186
  const totalImportWithDiscount = +productLinesTotals
@@ -101,13 +198,15 @@ export const getProductLineTotals = (productLinesTotals) => {
101
198
  const totalSubtotalAmount = +productLinesTotals
102
199
  .reduce((acum, line) => acum + line.productLineSubtotal, 0)
103
200
  .toFixed(2);
201
+ const taxBreakdown = normalizeTaxBreakdownToTotal(productLinesTotals.flatMap((line) => line.taxBreakdown || []), totalImporTaxes);
104
202
  return {
105
203
  totalQuantity,
106
204
  totalImportWithDiscount,
107
205
  totalImportWithoutDiscount,
108
206
  totalImporTaxes,
109
207
  totalDiscountAmount,
110
- totalSubtotalAmount
208
+ totalSubtotalAmount,
209
+ taxBreakdown
111
210
  };
112
211
  };
113
212
  export const getShippingCost = (discountObject, requireShippingService, store) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "washday-sdk",
3
- "version": "1.6.63",
3
+ "version": "1.6.65",
4
4
  "description": "Washday utilities functions and API",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -1,5 +1,8 @@
1
1
  import { WashdayClientInstance } from "../../interfaces/Api";
2
- import { GetCFDIAddonStatusRequest } from "../../interfaces/Company";
2
+ import {
3
+ GetAvailableSubscriptionPlansRequest,
4
+ GetCFDIAddonStatusRequest,
5
+ } from "../../interfaces/Company";
3
6
 
4
7
  const GET_SET_COMPANIES = 'api/company';
5
8
  const GET_SET_BILLING = 'api/billing';
@@ -58,3 +61,27 @@ export const getCFDIAddonStatus = async function (
58
61
  throw error;
59
62
  }
60
63
  };
64
+
65
+ export const getAvailableSubscriptionPlans = async function (
66
+ this: WashdayClientInstance,
67
+ data: GetAvailableSubscriptionPlansRequest = {},
68
+ ): Promise<any> {
69
+ try {
70
+ const config = {
71
+ headers: { Authorization: `Bearer ${this.apiToken}` }
72
+ };
73
+ const params = new URLSearchParams();
74
+ if (data.companyId) {
75
+ params.set("companyId", data.companyId);
76
+ }
77
+ const query = params.toString();
78
+ const response = await this.axiosInstance.get(
79
+ `${GET_SET_BILLING}/subscription/available-plans${query ? `?${query}` : ""}`,
80
+ config,
81
+ );
82
+ return response;
83
+ } catch (error) {
84
+ console.error('Error fetching getAvailableSubscriptionPlans:', error);
85
+ throw error;
86
+ }
87
+ };
@@ -1,5 +1,9 @@
1
1
  import { WashdayClientInstance } from "../../interfaces/Api";
2
- import { CFDIAddonToggleRequest } from "../../interfaces/Company";
2
+ import {
3
+ ApplySubscriptionPlanChangeRequest,
4
+ CFDIAddonToggleRequest,
5
+ PreviewSubscriptionPlanChangeRequest,
6
+ } from "../../interfaces/Company";
3
7
  import axiosInstance from "../axiosInstance";
4
8
  const GET_SET_COMPANIES = 'api/company';
5
9
  const GET_SET_BILLING = 'api/billing';
@@ -91,3 +95,35 @@ export const disableCFDIAddon = async function (
91
95
  throw error;
92
96
  }
93
97
  }
98
+
99
+ export const previewSubscriptionPlanChange = async function (
100
+ this: WashdayClientInstance,
101
+ data: PreviewSubscriptionPlanChangeRequest
102
+ ): Promise<any> {
103
+ try {
104
+ const config = {
105
+ headers: { Authorization: `Bearer ${this.apiToken}` }
106
+ };
107
+ const response = await this.axiosInstance.post(`${GET_SET_BILLING}/subscription/change-plan/preview`, data, config);
108
+ return response;
109
+ } catch (error) {
110
+ console.error('Error fetching previewSubscriptionPlanChange:', error);
111
+ throw error;
112
+ }
113
+ }
114
+
115
+ export const changeSubscriptionPlan = async function (
116
+ this: WashdayClientInstance,
117
+ data: ApplySubscriptionPlanChangeRequest
118
+ ): Promise<any> {
119
+ try {
120
+ const config = {
121
+ headers: { Authorization: `Bearer ${this.apiToken}` }
122
+ };
123
+ const response = await this.axiosInstance.post(`${GET_SET_BILLING}/subscription/change-plan`, data, config);
124
+ return response;
125
+ } catch (error) {
126
+ console.error('Error fetching changeSubscriptionPlan:', error);
127
+ throw error;
128
+ }
129
+ }
package/src/api/index.ts CHANGED
@@ -3,8 +3,8 @@ import { deleteCashierBoxById, deleteCashierBoxMovementById } from "./cashierbox
3
3
  import { getCashierBoxMovementsHistory, getCashierboxesById, getCashierboxesByStoreId } from "./cashierbox/get";
4
4
  import { addCashierBoxMovement, createCashierBox } from "./cashierbox/post";
5
5
  import { updateCashierBoxById, updateCashierBoxMovementById } from "./cashierbox/put";
6
- import { getCFDIAddonStatus, getCompanyById, getCompanyOrdersMetrics, getGarmentAttributeCatalogs } from "./companies/get";
7
- import { disableBillingOverage, disableCFDIAddon, enableBillingOverage, enableCFDIAddon, updateCFDIOrgLogo, updateCompanyTaxInfoCertificates } from "./companies/post";
6
+ import { getAvailableSubscriptionPlans, getCFDIAddonStatus, getCompanyById, getCompanyOrdersMetrics, getGarmentAttributeCatalogs } from "./companies/get";
7
+ import { changeSubscriptionPlan, disableBillingOverage, disableCFDIAddon, enableBillingOverage, enableCFDIAddon, previewSubscriptionPlanChange, updateCFDIOrgLogo, updateCompanyTaxInfoCertificates } from "./companies/post";
8
8
  import { updateCompanyById, updateCompanyLogoById, updateCompanySubscriptionBillingInfoById, updateCompanyTaxInfoById, updateGarmentAttributeCatalogsById } from "./companies/put";
9
9
  import { getCountries } from "./countries/get";
10
10
  import * as configEndpoints from "./config";
@@ -295,6 +295,9 @@ const WashdayClient: WashdayClientConstructor = function WashdayClient(this: Was
295
295
  enableCFDIAddon: enableCFDIAddon,
296
296
  disableCFDIAddon: disableCFDIAddon,
297
297
  getCFDIAddonStatus: getCFDIAddonStatus,
298
+ getAvailableSubscriptionPlans: getAvailableSubscriptionPlans,
299
+ previewSubscriptionPlanChange: previewSubscriptionPlanChange,
300
+ changeSubscriptionPlan: changeSubscriptionPlan,
298
301
  });
299
302
  this.stripe = bindMethods(this, {
300
303
  createCreateSuscriptionCheckoutSession: createCreateSuscriptionCheckoutSession,
package/src/index.ts CHANGED
@@ -8,6 +8,13 @@ export * from './interfaces/Customer';
8
8
  export * from './interfaces/DomainUrls';
9
9
  export * from './interfaces/GoogleMaps';
10
10
  export * from './interfaces/Attendance';
11
+ export type {
12
+ IOrderPricingSnapshot,
13
+ IOrderPricingSnapshotLine,
14
+ IOrderPricingSnapshotTotals,
15
+ ITaxBreakdownItem,
16
+ TaxBreakdownKey,
17
+ } from './interfaces/Order';
11
18
  export type { StartMPOAuthFlowRequest } from './api/integrations/post';
12
19
  export type {
13
20
  CreateCFDISubscriptionCheckoutSessionRequest,
@@ -2,8 +2,8 @@ import { deleteCashierBoxById, deleteCashierBoxMovementById } from "../api/cashi
2
2
  import { getCashierBoxMovementsHistory, getCashierboxesById, getCashierboxesByStoreId } from "../api/cashierbox/get";
3
3
  import { addCashierBoxMovement, createCashierBox } from "../api/cashierbox/post";
4
4
  import { updateCashierBoxById, updateCashierBoxMovementById } from "../api/cashierbox/put";
5
- import { getCFDIAddonStatus, getCompanyById, getCompanyOrdersMetrics, getGarmentAttributeCatalogs } from "../api/companies/get";
6
- import { disableBillingOverage, disableCFDIAddon, enableBillingOverage, enableCFDIAddon, updateCFDIOrgLogo, updateCompanyTaxInfoCertificates } from "../api/companies/post";
5
+ import { getAvailableSubscriptionPlans, getCFDIAddonStatus, getCompanyById, getCompanyOrdersMetrics, getGarmentAttributeCatalogs } from "../api/companies/get";
6
+ import { changeSubscriptionPlan, disableBillingOverage, disableCFDIAddon, enableBillingOverage, enableCFDIAddon, previewSubscriptionPlanChange, updateCFDIOrgLogo, updateCompanyTaxInfoCertificates } from "../api/companies/post";
7
7
  import { updateCompanyById, updateCompanyLogoById, updateCompanySubscriptionBillingInfoById, updateCompanyTaxInfoById, updateGarmentAttributeCatalogsById } from "../api/companies/put";
8
8
  import { getCountries } from "../api/countries/get";
9
9
  import * as configEndpoints from "../api/config";
@@ -280,6 +280,9 @@ export interface WashdayClientInstance {
280
280
  enableCFDIAddon: typeof enableCFDIAddon;
281
281
  disableCFDIAddon: typeof disableCFDIAddon;
282
282
  getCFDIAddonStatus: typeof getCFDIAddonStatus;
283
+ getAvailableSubscriptionPlans: typeof getAvailableSubscriptionPlans;
284
+ previewSubscriptionPlanChange: typeof previewSubscriptionPlanChange;
285
+ changeSubscriptionPlan: typeof changeSubscriptionPlan;
283
286
  };
284
287
  stripe: {
285
288
  createCreateSuscriptionCheckoutSession: typeof createCreateSuscriptionCheckoutSession;
@@ -54,7 +54,98 @@ export interface GetCFDIAddonStatusRequest {
54
54
  companyId: string;
55
55
  }
56
56
 
57
+ export type SubscriptionPlanChangePlan = "basic" | "standard" | "premium";
58
+ export type SubscriptionPlanChangeDirection = "upgrade" | "downgrade" | "same_plan";
59
+ export type SubscriptionPlanChangeBehavior =
60
+ | "immediate_prorated_charge"
61
+ | "contact_support"
62
+ | "no_change";
63
+ export type SubscriptionPlanChangeAddon = "cfdi" | "orders_overage";
64
+
65
+ export interface GetAvailableSubscriptionPlansRequest {
66
+ companyId?: string;
67
+ }
68
+
69
+ export interface AvailableSubscriptionPlan {
70
+ plan: SubscriptionPlanChangePlan;
71
+ priceId: string;
72
+ amount: number;
73
+ currency: string;
74
+ interval: string | null;
75
+ displayPrice: string;
76
+ }
77
+
78
+ export interface AvailableSubscriptionPlansResponse {
79
+ currentPlan: SubscriptionPlanChangePlan;
80
+ currentPlanPriceId: string;
81
+ country: string;
82
+ currency: string;
83
+ plans: AvailableSubscriptionPlan[];
84
+ }
85
+
86
+ export interface PreviewSubscriptionPlanChangeRequest {
87
+ companyId?: string;
88
+ targetPlan: SubscriptionPlanChangePlan;
89
+ }
90
+
91
+ export interface PlanChangePreviewLine {
92
+ description: string;
93
+ amount: number;
94
+ currency: string;
95
+ amountFormatted?: string;
96
+ proration?: boolean;
97
+ priceId?: string;
98
+ type?: "credit" | "charge" | "tax" | "addon" | "plan";
99
+ }
100
+
101
+ export interface PlanChangePreviewResponse {
102
+ status: "preview_ready" | "downgrade_not_supported" | "no_change";
103
+ currentPlan: SubscriptionPlanChangePlan;
104
+ targetPlan: SubscriptionPlanChangePlan;
105
+ direction: SubscriptionPlanChangeDirection;
106
+ behavior: SubscriptionPlanChangeBehavior;
107
+ prorationDate?: number;
108
+ currency: string;
109
+ amountDueNow: number;
110
+ amountDueNowFormatted?: string;
111
+ subtotal?: number;
112
+ tax?: number | null;
113
+ total?: number;
114
+ currentPeriodEnd?: string;
115
+ nextBillingDate?: string;
116
+ currentPlanPriceId?: string;
117
+ targetPlanPriceId: string;
118
+ preservedAddons: SubscriptionPlanChangeAddon[];
119
+ lines: PlanChangePreviewLine[];
120
+ message: string;
121
+ }
122
+
123
+ export interface ApplySubscriptionPlanChangeRequest {
124
+ companyId?: string;
125
+ targetPlan: SubscriptionPlanChangePlan;
126
+ expectedCurrentPlanPriceId: string;
127
+ expectedTargetPlanPriceId: string;
128
+ expectedProrationDate: number;
129
+ previewIdempotencyKey?: string;
130
+ idempotencyKey?: string;
131
+ }
132
+
133
+ export interface ApplySubscriptionPlanChangeResponse {
134
+ status: "applied" | "requires_payment_action" | "payment_failed" | "pending_update" | "no_change";
135
+ currentPlan: SubscriptionPlanChangePlan;
136
+ targetPlan: SubscriptionPlanChangePlan;
137
+ subscriptionId: string;
138
+ invoiceId?: string;
139
+ hostedInvoiceUrl?: string;
140
+ paymentIntentClientSecret?: string;
141
+ amountDueNow?: number;
142
+ currency?: string;
143
+ currentPeriodEnd?: string;
144
+ message: string;
145
+ }
146
+
57
147
  export interface IStripeSettings {
148
+ planPriceId?: string | null;
58
149
  stripeCFDISubID: string | null;
59
150
  stripeCFDISubItemID: string | null;
60
151
  cfdiBillingMode?: CFDIBillingMode;
@@ -46,6 +46,61 @@ export interface IOrderPaymentLines {
46
46
  facturapiPaymentInvoiceID?: string | null,
47
47
  }
48
48
 
49
+ export type TaxBreakdownKey = "taxOne" | "taxTwo" | "taxThree";
50
+
51
+ export interface ITaxBreakdownItem {
52
+ key: TaxBreakdownKey;
53
+ name: string;
54
+ rate: number;
55
+ amount: number;
56
+ }
57
+
58
+ export interface IOrderPricingSnapshotLine {
59
+ lineId: string;
60
+ quantity: number;
61
+ netBeforeDiscount: number;
62
+ netDiscount: number;
63
+ netAfterDiscount: number;
64
+ tax: number;
65
+ taxBreakdown?: ITaxBreakdownItem[];
66
+ grossBeforeDiscount: number;
67
+ gross: number;
68
+ }
69
+
70
+ export interface IOrderPricingSnapshotTotals {
71
+ totalQuantity: number;
72
+ productTotal: number;
73
+ productTotalWithoutDiscount: number;
74
+ taxesTotal: number;
75
+ taxBreakdown?: ITaxBreakdownItem[];
76
+ shippingServiceTotal: number;
77
+ orderLevelDiscountGross?: number;
78
+ discountTotalGrossLines?: number;
79
+ totalDiscountGross?: number;
80
+ subtotalBeforeDiscountNet: number;
81
+ subtotalNetAfterDiscount: number;
82
+ discountTotalNet: number;
83
+ creditApplied: number;
84
+ redeemPointsApplied: number;
85
+ total: number;
86
+ }
87
+
88
+ export interface IOrderPricingSnapshot {
89
+ version: string;
90
+ engine: string;
91
+ engineVersion?: string;
92
+ calculatedAt: Date;
93
+ currency: string;
94
+ taxesType: "included" | "over_price";
95
+ express: boolean;
96
+ discountCodeId?: string;
97
+ redeemPointsAsPaymentAmount: number;
98
+ totalsCents: IOrderPricingSnapshotTotals;
99
+ lines: IOrderPricingSnapshotLine[];
100
+ appliedOrderDiscounts: Record<string, number>;
101
+ meta?: Record<string, any>;
102
+ }
103
+
49
104
  export interface IOrderPhaseCompletion {
50
105
  source: RouteOrderCompletionSource,
51
106
  confirmedAt?: Date | null,
@@ -73,6 +128,7 @@ export interface IOrder {
73
128
  taxThree?: ITaxConfig,
74
129
  creditApplied: number,
75
130
  taxesTotal: number,
131
+ taxBreakdown?: ITaxBreakdownItem[],
76
132
  total: number,
77
133
  totalQuantity: number,
78
134
  productTotal: number,
@@ -110,6 +166,7 @@ export interface IOrder {
110
166
  appliedDiscountCodes: Array<string> | null,
111
167
  paymentLines: Array<IOrderPaymentLines> | Array<string> | any,
112
168
  facturapiInvoiceID?: string | null,
169
+ pricingSnapshot?: IOrderPricingSnapshot | null,
113
170
  }
114
171
 
115
172
 
@@ -2,6 +2,7 @@ import { IStore } from "./Store"
2
2
  import { IStoreImage } from "./StoreImage"
3
3
  import { IUser } from "./User"
4
4
  import { OrderProductLineStatus } from "../enum"
5
+ import type { ITaxBreakdownItem } from "./Order"
5
6
 
6
7
  export interface ProductLineTotals {
7
8
  product: IOrderProduct,
@@ -9,6 +10,7 @@ export interface ProductLineTotals {
9
10
  productLineImportTotal: number,
10
11
  productLineTotalWithDiscount: number,
11
12
  productLineTaxesTotal: number,
13
+ taxBreakdown?: ITaxBreakdownItem[],
12
14
  lineDiscountAmount: number
13
15
  productLineSubtotal: number
14
16
  }
@@ -75,7 +75,8 @@ export const calculateOrderTotal = (
75
75
  totalImportWithoutDiscount,
76
76
  totalImporTaxes,
77
77
  totalDiscountAmount,
78
- totalSubtotalAmount
78
+ totalSubtotalAmount,
79
+ taxBreakdown
79
80
  } = getProductLineTotals(productTableImports);
80
81
 
81
82
  // === DISCOUNT CODE (monetario tipo NUMBER que se aplica una sola vez) ===
@@ -127,6 +128,7 @@ export const calculateOrderTotal = (
127
128
  customerDiscount: order.discountCode ? 0 : selectedCustomer?.customer?.discount,
128
129
  productTotal: totalImportWithDiscount,
129
130
  taxesTotal: totalImporTaxes,
131
+ taxBreakdown,
130
132
  total: orderTotal,
131
133
  creditApplied,
132
134
  redeemPointsApplied: redeemPointsDiscount, // ✅ NUEVO CAMPO
@@ -1,6 +1,6 @@
1
1
  import { DiscountCodeTypes } from "../../enum";
2
2
  import { IOrderProduct } from "../../interfaces/Product";
3
- import { getProductTaxesPercentage } from "./helpers";
3
+ import { calculateProductTaxBreakdown, getProductTaxesPercentage } from "./helpers";
4
4
 
5
5
  const getNormalizedId = (value: any): string => {
6
6
  if (value === null || value === undefined) {
@@ -124,6 +124,11 @@ export const calculateTotalTaxesIncluded = (
124
124
 
125
125
  // Calcular el total de impuestos como la diferencia entre bruto y neto con descuento
126
126
  const totalTaxesApplied = +(productLineTotalWithDiscount - productNetTotalWithDiscount).toFixed(2);
127
+ const taxBreakdown = calculateProductTaxBreakdown({
128
+ product: current,
129
+ store: storeSettings,
130
+ lineTaxAmount: totalTaxesApplied,
131
+ });
127
132
 
128
133
  const lineDiscount = +(discountAmountPerUnit * qty).toFixed(2);
129
134
 
@@ -133,6 +138,7 @@ export const calculateTotalTaxesIncluded = (
133
138
  productLineImportTotal, // Total bruto sin descuento (incluye extra y IVA)
134
139
  productLineTotalWithDiscount, // Total bruto con descuento aplicado sobre el precio ajustado
135
140
  productLineTaxesTotal: totalTaxesApplied,
141
+ taxBreakdown,
136
142
  lineDiscountAmount: lineDiscount,
137
143
  productLineSubtotal: productNetTotalWithoutDiscount // Subtotal neto (sin IVA)
138
144
  };
@@ -1,5 +1,5 @@
1
1
  import { BuyAndGetConditionsTypes, DiscountCodeTypes } from "../../enum";
2
- import { getProductTaxesPercentage } from "./helpers";
2
+ import { calculateProductTaxBreakdown, getProductTaxesPercentage } from "./helpers";
3
3
 
4
4
  const getNormalizedId = (value: any): string => {
5
5
  if (value === null || value === undefined) {
@@ -107,6 +107,11 @@ export const calculateTotalTaxesOverPrice = (
107
107
 
108
108
  // Resultados finales:
109
109
  const totalTaxesApplied = +taxes.toFixed(2);
110
+ const taxBreakdown = calculateProductTaxBreakdown({
111
+ product: current,
112
+ store: storeSettings,
113
+ lineTaxAmount: totalTaxesApplied,
114
+ });
110
115
  const lineDiscount = +(productDiscount * qty).toFixed(2);
111
116
 
112
117
  // Precio total con descuento, reaplicando impuestos
@@ -120,6 +125,7 @@ export const calculateTotalTaxesOverPrice = (
120
125
  // Total final con descuento, con impuestos incluidos
121
126
  productLineTotalWithDiscount: prodLinePriceWithDiscount,
122
127
  productLineTaxesTotal: totalTaxesApplied,
128
+ taxBreakdown,
123
129
  lineDiscountAmount: lineDiscount,
124
130
  // Subtotal neto (sin impuestos) con descuento aplicado
125
131
  productLineSubtotal: subtotal
@@ -2,6 +2,7 @@ import { BuyAndGetConditionsTypes, DiscountCodeTypes } from "../../enum";
2
2
  import { ICustomer } from "../../interfaces/Customer";
3
3
  import { IOrderProduct, ProductLineTotals } from "../../interfaces/Product";
4
4
  import { IStore, ITaxConfig } from "../../interfaces/Store";
5
+ import type { ITaxBreakdownItem, TaxBreakdownKey } from "../../interfaces/Order";
5
6
 
6
7
  const getNormalizedId = (value: any): string => {
7
8
  if (value === null || value === undefined) {
@@ -105,6 +106,154 @@ export const getProductTaxesPercentage = (productObj: IOrderProduct, store: ISto
105
106
  return tax1 + tax2 + tax3;
106
107
  };
107
108
 
109
+ const TAX_SLOTS: Array<{
110
+ key: TaxBreakdownKey;
111
+ taxField: "taxOne" | "taxTwo" | "taxThree";
112
+ exemptField: "taxExemptOne" | "taxExemptTwo" | "taxExemptThree";
113
+ }> = [
114
+ { key: "taxOne", taxField: "taxOne", exemptField: "taxExemptOne" },
115
+ { key: "taxTwo", taxField: "taxTwo", exemptField: "taxExemptTwo" },
116
+ { key: "taxThree", taxField: "taxThree", exemptField: "taxExemptThree" },
117
+ ];
118
+
119
+ type ApplicableTax = {
120
+ key: TaxBreakdownKey;
121
+ name: string;
122
+ rate: number;
123
+ weight: number;
124
+ };
125
+
126
+ const round2 = (value: number): number =>
127
+ Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
128
+
129
+ const getApplicableTaxesForProduct = (
130
+ productObj: IOrderProduct,
131
+ store: IStore
132
+ ): ApplicableTax[] => {
133
+ return TAX_SLOTS.reduce<ApplicableTax[]>((acc, slot) => {
134
+ if (productObj[slot.exemptField]) {
135
+ return acc;
136
+ }
137
+
138
+ const tax = store[slot.taxField] as ITaxConfig | undefined;
139
+ const rate = Number(tax?.value ?? 0);
140
+ if (!Number.isFinite(rate) || rate <= 0) {
141
+ return acc;
142
+ }
143
+
144
+ acc.push({
145
+ key: slot.key,
146
+ name: String(tax?.name ?? slot.key),
147
+ rate,
148
+ weight: Math.round(rate * 100),
149
+ });
150
+ return acc;
151
+ }, []);
152
+ };
153
+
154
+ const allocateByWeights = (total: number, weights: number[]): number[] => {
155
+ if (weights.length === 0) return [];
156
+ if (total <= 0) return new Array(weights.length).fill(0);
157
+
158
+ const safeWeights = weights.map((weight) => Math.max(0, Math.trunc(weight)));
159
+ const sum = safeWeights.reduce((acc, weight) => acc + weight, 0);
160
+ if (sum <= 0) return new Array(weights.length).fill(0);
161
+
162
+ const raw = safeWeights.map((weight) => (total * weight) / sum);
163
+ const floors = raw.map((amount) => Math.floor(amount));
164
+ let remaining = total - floors.reduce((acc, amount) => acc + amount, 0);
165
+ const order = raw
166
+ .map((amount, index) => ({ index, frac: amount - Math.floor(amount) }))
167
+ .sort((a, b) => b.frac - a.frac);
168
+
169
+ const out = floors.slice();
170
+ for (let index = 0; index < order.length && remaining > 0; index += 1) {
171
+ out[order[index].index] += 1;
172
+ remaining -= 1;
173
+ }
174
+ return out;
175
+ };
176
+
177
+ export const aggregateTaxBreakdown = (
178
+ items: ITaxBreakdownItem[]
179
+ ): ITaxBreakdownItem[] => {
180
+ const byKey = new Map<TaxBreakdownKey, ITaxBreakdownItem>();
181
+
182
+ items.forEach((item) => {
183
+ if (!item || Number(item.amount || 0) <= 0) {
184
+ return;
185
+ }
186
+ const existing = byKey.get(item.key);
187
+ if (existing) {
188
+ existing.amount = round2(existing.amount + item.amount);
189
+ return;
190
+ }
191
+ byKey.set(item.key, { ...item, amount: round2(item.amount) });
192
+ });
193
+
194
+ return TAX_SLOTS.map((slot) => byKey.get(slot.key)).filter(
195
+ (item): item is ITaxBreakdownItem => Boolean(item && item.amount > 0)
196
+ );
197
+ };
198
+
199
+ export const normalizeTaxBreakdownToTotal = (
200
+ items: ITaxBreakdownItem[],
201
+ total: number
202
+ ): ITaxBreakdownItem[] => {
203
+ const target = round2(total);
204
+ if (target <= 0) {
205
+ return [];
206
+ }
207
+
208
+ const aggregated = aggregateTaxBreakdown(items);
209
+ if (aggregated.length === 0) {
210
+ return [];
211
+ }
212
+
213
+ const sum = round2(aggregated.reduce((acc, item) => acc + item.amount, 0));
214
+ const diff = round2(target - sum);
215
+ if (diff !== 0) {
216
+ const lastIndex = aggregated.length - 1;
217
+ aggregated[lastIndex] = {
218
+ ...aggregated[lastIndex],
219
+ amount: round2(aggregated[lastIndex].amount + diff),
220
+ };
221
+ }
222
+
223
+ return aggregated.filter((item) => item.amount > 0);
224
+ };
225
+
226
+ export const calculateProductTaxBreakdown = ({
227
+ product,
228
+ store,
229
+ lineTaxAmount,
230
+ }: {
231
+ product: IOrderProduct;
232
+ store: IStore;
233
+ lineTaxAmount: number;
234
+ }): ITaxBreakdownItem[] => {
235
+ const applicableTaxes = getApplicableTaxesForProduct(product, store);
236
+ const target = round2(lineTaxAmount);
237
+ if (target <= 0 || applicableTaxes.length === 0) {
238
+ return [];
239
+ }
240
+
241
+ const amounts = allocateByWeights(
242
+ Math.round(target * 100),
243
+ applicableTaxes.map((tax) => tax.weight)
244
+ ).map((amountCents) => round2(amountCents / 100));
245
+
246
+ return normalizeTaxBreakdownToTotal(
247
+ applicableTaxes.map((tax, index) => ({
248
+ key: tax.key,
249
+ name: tax.name,
250
+ rate: tax.rate,
251
+ amount: amounts[index] ?? 0,
252
+ })),
253
+ target
254
+ );
255
+ };
256
+
108
257
  export const getProductLineTotals = (productLinesTotals: ProductLineTotals[]) => {
109
258
  const totalQuantity = +productLinesTotals.reduce((acum, line) => acum + line.qty, 0).toFixed(2);
110
259
  const totalImportWithDiscount = +productLinesTotals
@@ -122,6 +271,10 @@ export const getProductLineTotals = (productLinesTotals: ProductLineTotals[]) =>
122
271
  const totalSubtotalAmount = +productLinesTotals
123
272
  .reduce((acum, line) => acum + line.productLineSubtotal, 0)
124
273
  .toFixed(2);
274
+ const taxBreakdown = normalizeTaxBreakdownToTotal(
275
+ productLinesTotals.flatMap((line) => line.taxBreakdown || []),
276
+ totalImporTaxes
277
+ );
125
278
 
126
279
  return {
127
280
  totalQuantity,
@@ -129,7 +282,8 @@ export const getProductLineTotals = (productLinesTotals: ProductLineTotals[]) =>
129
282
  totalImportWithoutDiscount,
130
283
  totalImporTaxes,
131
284
  totalDiscountAmount,
132
- totalSubtotalAmount
285
+ totalSubtotalAmount,
286
+ taxBreakdown
133
287
  };
134
288
  };
135
289
 
@@ -0,0 +1,92 @@
1
+ import { getAvailableSubscriptionPlans } from "../src/api/companies/get";
2
+ import {
3
+ changeSubscriptionPlan,
4
+ previewSubscriptionPlanChange,
5
+ } from "../src/api/companies/post";
6
+ import type {
7
+ ApplySubscriptionPlanChangeRequest,
8
+ PlanChangePreviewResponse,
9
+ } from "../src/interfaces/Company";
10
+
11
+ describe("subscription plan change company endpoints", () => {
12
+ it("fetches available plans through the billing endpoint", async () => {
13
+ const get = jest.fn().mockResolvedValue({ data: { data: { plans: [] } } });
14
+ const client = {
15
+ apiToken: "token-1",
16
+ axiosInstance: { get },
17
+ } as any;
18
+
19
+ await getAvailableSubscriptionPlans.call(client, { companyId: "company-1" });
20
+
21
+ expect(get).toHaveBeenCalledWith(
22
+ "api/billing/subscription/available-plans?companyId=company-1",
23
+ { headers: { Authorization: "Bearer token-1" } },
24
+ );
25
+ });
26
+
27
+ it("fetches available plans without sending an undefined companyId", async () => {
28
+ const get = jest.fn().mockResolvedValue({ data: { data: { plans: [] } } });
29
+ const client = {
30
+ apiToken: "token-1",
31
+ axiosInstance: { get },
32
+ } as any;
33
+
34
+ await getAvailableSubscriptionPlans.call(client, {});
35
+
36
+ expect(get).toHaveBeenCalledWith(
37
+ "api/billing/subscription/available-plans",
38
+ { headers: { Authorization: "Bearer token-1" } },
39
+ );
40
+ });
41
+
42
+ it("previews a subscription plan change through the billing endpoint", async () => {
43
+ const post = jest.fn().mockResolvedValue({
44
+ data: {
45
+ data: {
46
+ status: "preview_ready",
47
+ prorationDate: 1770000000,
48
+ } satisfies Partial<PlanChangePreviewResponse>,
49
+ },
50
+ });
51
+ const client = {
52
+ apiToken: "token-1",
53
+ axiosInstance: { post },
54
+ } as any;
55
+
56
+ await previewSubscriptionPlanChange.call(client, {
57
+ companyId: "company-1",
58
+ targetPlan: "premium",
59
+ });
60
+
61
+ expect(post).toHaveBeenCalledWith(
62
+ "api/billing/subscription/change-plan/preview",
63
+ { companyId: "company-1", targetPlan: "premium" },
64
+ { headers: { Authorization: "Bearer token-1" } },
65
+ );
66
+ });
67
+
68
+ it("applies a subscription plan change with expected preview fields", async () => {
69
+ const post = jest.fn().mockResolvedValue({ data: { data: { status: "applied" } } });
70
+ const client = {
71
+ apiToken: "token-1",
72
+ axiosInstance: { post },
73
+ } as any;
74
+ const payload: ApplySubscriptionPlanChangeRequest = {
75
+ companyId: "company-1",
76
+ targetPlan: "premium",
77
+ expectedCurrentPlanPriceId: "price_standard",
78
+ expectedTargetPlanPriceId: "price_premium",
79
+ expectedProrationDate: 1770000000,
80
+ previewIdempotencyKey: "preview-1",
81
+ idempotencyKey: "apply-1",
82
+ };
83
+
84
+ await changeSubscriptionPlan.call(client, payload);
85
+
86
+ expect(post).toHaveBeenCalledWith(
87
+ "api/billing/subscription/change-plan",
88
+ payload,
89
+ { headers: { Authorization: "Bearer token-1" } },
90
+ );
91
+ });
92
+ });
@@ -0,0 +1,147 @@
1
+ import { utils } from "../src";
2
+
3
+ const selectedCustomer = {
4
+ customer: {
5
+ credit: 0,
6
+ discount: 0,
7
+ },
8
+ };
9
+
10
+ describe("calculateOrderTotal taxBreakdown", () => {
11
+ it("over_price adds taxBreakdown without changing existing totals", () => {
12
+ const result = utils.calculateOrderTotal(
13
+ {
14
+ express: false,
15
+ products: [
16
+ { _id: "p1", qty: 1, quantity: 1, price: 100, expressPrice: 100, extraAmount: 0 },
17
+ {
18
+ _id: "p2",
19
+ qty: 1,
20
+ quantity: 1,
21
+ price: 100,
22
+ expressPrice: 100,
23
+ extraAmount: 0,
24
+ taxExemptOne: true,
25
+ },
26
+ ],
27
+ buyAndGetProducts: [],
28
+ discountCode: null,
29
+ },
30
+ selectedCustomer as any,
31
+ {
32
+ taxesType: "over_price",
33
+ taxOne: { name: "IVA", value: 16 },
34
+ taxTwo: { name: "Local", value: 3 },
35
+ orderPageConfig: { shippingServiceCost: 0 },
36
+ } as any,
37
+ false,
38
+ [],
39
+ null,
40
+ 0
41
+ );
42
+
43
+ expect(result.taxesTotal).toBe(22);
44
+ expect(result.total).toBe(222);
45
+ expect(result.taxBreakdown).toEqual([
46
+ { key: "taxOne", name: "IVA", rate: 16, amount: 16 },
47
+ { key: "taxTwo", name: "Local", rate: 3, amount: 6 },
48
+ ]);
49
+ });
50
+
51
+ it("included distributes extracted tax across all configured taxes", () => {
52
+ const result = utils.calculateOrderTotal(
53
+ {
54
+ express: false,
55
+ products: [{ _id: "p", qty: 1, quantity: 1, price: 120, expressPrice: 120, extraAmount: 0 }],
56
+ buyAndGetProducts: [],
57
+ discountCode: null,
58
+ },
59
+ selectedCustomer as any,
60
+ {
61
+ taxesType: "included",
62
+ taxOne: { name: "IVA", value: 16 },
63
+ taxTwo: { name: "Local", value: 3 },
64
+ taxThree: { name: "Eco", value: 1 },
65
+ orderPageConfig: { shippingServiceCost: 0 },
66
+ } as any,
67
+ false,
68
+ [],
69
+ null,
70
+ 0
71
+ );
72
+
73
+ expect(result.taxesTotal).toBe(20);
74
+ expect(result.total).toBe(120);
75
+ expect(result.taxBreakdown).toEqual([
76
+ { key: "taxOne", name: "IVA", rate: 16, amount: 16 },
77
+ { key: "taxTwo", name: "Local", rate: 3, amount: 3 },
78
+ { key: "taxThree", name: "Eco", rate: 1, amount: 1 },
79
+ ]);
80
+ });
81
+
82
+ it("returns empty taxBreakdown when no tax amount remains after a free item promo", () => {
83
+ const result = utils.calculateOrderTotal(
84
+ {
85
+ express: false,
86
+ products: [
87
+ {
88
+ _id: "p",
89
+ qty: 1,
90
+ quantity: 1,
91
+ price: 100,
92
+ expressPrice: 100,
93
+ extraAmount: 0,
94
+ isBuyAndGetProduct: true,
95
+ },
96
+ ],
97
+ buyAndGetProducts: [],
98
+ discountCode: "discount-1",
99
+ },
100
+ selectedCustomer as any,
101
+ {
102
+ taxesType: "over_price",
103
+ taxOne: { name: "IVA", value: 16 },
104
+ orderPageConfig: { shippingServiceCost: 0 },
105
+ } as any,
106
+ false,
107
+ [],
108
+ {
109
+ type: "buyXGetY",
110
+ buyAndGetConditions: [{ getDiscountType: "free", discountValue: 0 }],
111
+ },
112
+ 0
113
+ );
114
+
115
+ expect(result.taxesTotal).toBe(0);
116
+ expect(result.taxBreakdown).toEqual([]);
117
+ });
118
+
119
+ it("over_price preserves one-cent rounded total tax across low-value multi-tax lines", () => {
120
+ const result = utils.calculateOrderTotal(
121
+ {
122
+ express: false,
123
+ products: [{ _id: "p", qty: 1, quantity: 1, price: 0.17, expressPrice: 0.17, extraAmount: 0 }],
124
+ buyAndGetProducts: [],
125
+ discountCode: null,
126
+ },
127
+ selectedCustomer as any,
128
+ {
129
+ taxesType: "over_price",
130
+ taxOne: { name: "T1", value: 1 },
131
+ taxTwo: { name: "T2", value: 1 },
132
+ taxThree: { name: "T3", value: 1 },
133
+ orderPageConfig: { shippingServiceCost: 0 },
134
+ } as any,
135
+ false,
136
+ [],
137
+ null,
138
+ 0
139
+ );
140
+
141
+ const breakdownTotal = result.taxBreakdown.reduce((sum: number, item: any) => sum + item.amount, 0);
142
+
143
+ expect(result.taxesTotal).toBe(0.01);
144
+ expect(result.taxBreakdown.length).toBeGreaterThan(0);
145
+ expect(+breakdownTotal.toFixed(2)).toBe(result.taxesTotal);
146
+ });
147
+ });