@zauru-sdk/common 6.7.0 → 6.8.1

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/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [6.8.1](https://github.com/intuitiva/zauru-typescript-sdk/compare/@zauru-sdk/common@6.8.0...@zauru-sdk/common@6.8.1) (2026-09-19)
7
+
8
+ **Note:** Version bump only for package @zauru-sdk/common
9
+
10
+
11
+
12
+
13
+
14
+ # [6.8.0](https://github.com/intuitiva/zauru-typescript-sdk/compare/@zauru-sdk/common@6.7.0...@zauru-sdk/common@6.8.0) (2026-09-18)
15
+
16
+
17
+ ### Features
18
+
19
+ * descuento sucesivo en porcentajes de rechazo ([c437bbd](https://github.com/intuitiva/zauru-typescript-sdk/commit/c437bbd6a1305fd417c4ac55f8e07eb244f2fe3d))
20
+
21
+
22
+
23
+
24
+
6
25
  # [6.7.0](https://github.com/intuitiva/zauru-typescript-sdk/compare/@zauru-sdk/common@6.6.0...@zauru-sdk/common@6.7.0) (2026-09-18)
7
26
 
8
27
 
package/dist/common.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import "moment-timezone";
2
- import type { PayeeGraphQL, AxiosUtilsResponse, BasketSchema, SelectFieldOption, JsonMemoType, CalculatePurchaseOrderFinancialsInput, CalculatePurchaseOrderFinancialsResult } from "@zauru-sdk/types";
2
+ import type { PayeeGraphQL, AxiosUtilsResponse, BasketSchema, SelectFieldOption, JsonMemoType, CalculatePurchaseOrderFinancialsInput, CalculatePurchaseOrderFinancialsResult, RejectionPercentageLayers } from "@zauru-sdk/types";
3
3
  export declare const DESTINOS_MUESTRA_OPTIONS: SelectFieldOption[];
4
4
  export declare const parseJsonMemo: (memo?: string | object) => JsonMemoType;
5
5
  export declare const stringifyJsonMemo: (memo: JsonMemoType) => string;
@@ -12,20 +12,30 @@ export declare const getRejectionPercentage: (source: string | {
12
12
  memo?: string | object;
13
13
  } | JsonMemoType | undefined) => number;
14
14
  export declare const setRejectionPercentage: (memo: string | object | undefined, percentage: number) => string;
15
+ /**
16
+ * User rejection layers (before automatic rules). Legacy memos without
17
+ * `rejectionLayers` use the stored origin as additive and no successive rates.
18
+ */
19
+ export declare const resolveRejectionPercentageLayers: (memo?: string | object | JsonMemoType, fallbackAdditive?: number) => RejectionPercentageLayers;
20
+ export declare const getRejectionPercentageLayers: (source: string | {
21
+ memo?: string | object;
22
+ } | JsonMemoType | undefined) => RejectionPercentageLayers;
23
+ export declare const hasSuccessiveRejectionPercentage: (source: string | {
24
+ memo?: string | object;
25
+ } | JsonMemoType | undefined) => boolean;
15
26
  /**
16
27
  * Rejection % of origin stored in the memo, ignoring the adjustment rules
17
28
  * already applied on top of it.
18
29
  */
19
30
  export declare const resolveRejectionPercentageBase: (memo?: string | object) => number;
20
31
  /**
21
- * Header money for a purchase order: subtotal = qty × unit_cost,
22
- * discount = subtotal × rejectionPercentage / 100.
32
+ * Header money for a purchase order: subtotal = qty × unit_cost.
33
+ * Additive rates are summed first; successive rates then apply to the remainder.
23
34
  * Does not change unit cost. purchase_orders.discount is the monetary column.
24
35
  *
25
- * If `rules` is given, the rejection % is resolved back to the origin stored in
26
- * the memo and the rules are stacked on top of it before calculating the money.
27
- * `rejectionPercentage` in the result is the % that was actually charged and
28
- * `rejectionCalculations` is the trace to store in the memo.
36
+ * User layers (before rules) are returned as `rejectionLayers` for the memo.
37
+ * Automatic rules add percentage points on the additive block only.
38
+ * `rejectionPercentage` in the result is the effective % that was charged.
29
39
  */
30
40
  export declare const calculatePurchaseOrderFinancials: (input: CalculatePurchaseOrderFinancialsInput) => CalculatePurchaseOrderFinancialsResult;
31
41
  /**
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.parsedObject = exports.sortByProperty = exports.labFormPatter = exports.labServicePattern = exports.getRandomNum = exports.CURRENCY_PREFIX = exports.truncateDecimals = exports.ZAURU_REGEX = exports.priceToText = exports.arrayToObject = exports.isNumeric = exports.toFixedIfNeeded = exports.formatTimeToTimePicker = exports.formatDateToDatePicker = exports.getFormattedDate = exports.parsedBaculoFormValue = exports.getPayeeInfoOptions = exports.getPayeeInfoIdOptions = exports.getPayeeFormated = exports.getDateAfterDays = exports.getTimePickerCurrentTime = exports.obtenerFechaActualConZonaHoraria = exports.getDatePickerCurrentDate = exports.stringDateToParsedUTCDate = exports.localDateToUSDate = exports.getStringFullDate = exports.getTodayMinutesDifference = exports.getTodayDaysDifference = exports.getStringDate = exports.getZauruDateByText = exports.getNewDateByFormat = exports.getFechaJuliana = exports.getBasketsSchema = exports.calculatePurchaseOrderFinancials = exports.resolveRejectionPercentageBase = exports.setRejectionPercentage = exports.getRejectionPercentage = exports.mergeJsonMemo = exports.stringifyJsonMemo = exports.parseJsonMemo = exports.DESTINOS_MUESTRA_OPTIONS = void 0;
6
+ exports.parsedObject = exports.sortByProperty = exports.labFormPatter = exports.labServicePattern = exports.getRandomNum = exports.CURRENCY_PREFIX = exports.truncateDecimals = exports.ZAURU_REGEX = exports.priceToText = exports.arrayToObject = exports.isNumeric = exports.toFixedIfNeeded = exports.formatTimeToTimePicker = exports.formatDateToDatePicker = exports.getFormattedDate = exports.parsedBaculoFormValue = exports.getPayeeInfoOptions = exports.getPayeeInfoIdOptions = exports.getPayeeFormated = exports.getDateAfterDays = exports.getTimePickerCurrentTime = exports.obtenerFechaActualConZonaHoraria = exports.getDatePickerCurrentDate = exports.stringDateToParsedUTCDate = exports.localDateToUSDate = exports.getStringFullDate = exports.getTodayMinutesDifference = exports.getTodayDaysDifference = exports.getStringDate = exports.getZauruDateByText = exports.getNewDateByFormat = exports.getFechaJuliana = exports.getBasketsSchema = exports.calculatePurchaseOrderFinancials = exports.resolveRejectionPercentageBase = exports.hasSuccessiveRejectionPercentage = exports.getRejectionPercentageLayers = exports.resolveRejectionPercentageLayers = exports.setRejectionPercentage = exports.getRejectionPercentage = exports.mergeJsonMemo = exports.stringifyJsonMemo = exports.parseJsonMemo = exports.DESTINOS_MUESTRA_OPTIONS = void 0;
7
7
  exports.generateClientUUID = generateClientUUID;
8
8
  exports.extractValueBetweenTags = extractValueBetweenTags;
9
9
  exports.isJsonArray = isJsonArray;
@@ -81,11 +81,44 @@ const getRejectionPercentage = (source) => {
81
81
  exports.getRejectionPercentage = getRejectionPercentage;
82
82
  const setRejectionPercentage = (memo, percentage) => (0, exports.mergeJsonMemo)(memo, { rejectionPercentage: percentage });
83
83
  exports.setRejectionPercentage = setRejectionPercentage;
84
+ const memoFromRejectionSource = (source) => {
85
+ if (source == null || source === "")
86
+ return {};
87
+ if (typeof source === "string")
88
+ return (0, exports.parseJsonMemo)(source);
89
+ if (typeof source !== "object")
90
+ return {};
91
+ const record = source;
92
+ if (record.memo != null && record.memo !== "") {
93
+ return (0, exports.parseJsonMemo)(record.memo);
94
+ }
95
+ return (0, exports.parseJsonMemo)(record);
96
+ };
97
+ /**
98
+ * User rejection layers (before automatic rules). Legacy memos without
99
+ * `rejectionLayers` use the stored origin as additive and no successive rates.
100
+ */
101
+ const resolveRejectionPercentageLayers = (memo, fallbackAdditive) => {
102
+ const parsed = (0, exports.parseJsonMemo)(memo);
103
+ if (parsed.rejectionLayers) {
104
+ return (0, rejectionPercentageAdjustment_js_1.normalizeRejectionPercentageLayers)(parsed.rejectionLayers);
105
+ }
106
+ const origin = (0, rejectionPercentageAdjustment_js_1.resolveRejectionPercentageOrigin)(fallbackAdditive ?? (0, exports.getRejectionPercentage)(parsed), parsed.rejectionCalculations);
107
+ return (0, rejectionPercentageAdjustment_js_1.normalizeRejectionPercentageLayers)({
108
+ additive: origin,
109
+ successive: [],
110
+ });
111
+ };
112
+ exports.resolveRejectionPercentageLayers = resolveRejectionPercentageLayers;
113
+ const getRejectionPercentageLayers = (source) => (0, exports.resolveRejectionPercentageLayers)(memoFromRejectionSource(source));
114
+ exports.getRejectionPercentageLayers = getRejectionPercentageLayers;
115
+ const hasSuccessiveRejectionPercentage = (source) => (0, rejectionPercentageAdjustment_js_1.hasSuccessiveRejectionLayers)((0, exports.getRejectionPercentageLayers)(source));
116
+ exports.hasSuccessiveRejectionPercentage = hasSuccessiveRejectionPercentage;
84
117
  /**
85
118
  * Rejection % of origin stored in the memo, ignoring the adjustment rules
86
119
  * already applied on top of it.
87
120
  */
88
- const resolveRejectionPercentageBase = (memo) => (0, rejectionPercentageAdjustment_js_1.resolveRejectionPercentageOrigin)((0, exports.getRejectionPercentage)(memo), (0, exports.parseJsonMemo)(memo).rejectionCalculations);
121
+ const resolveRejectionPercentageBase = (memo) => (0, exports.resolveRejectionPercentageLayers)(memo).additive;
89
122
  exports.resolveRejectionPercentageBase = resolveRejectionPercentageBase;
90
123
  const roundMoney = (value) => Math.round((value + Number.EPSILON) * 100) / 100;
91
124
  const lineQuantity = (detail) => {
@@ -99,14 +132,13 @@ const lineQuantity = (detail) => {
99
132
  0);
100
133
  };
101
134
  /**
102
- * Header money for a purchase order: subtotal = qty × unit_cost,
103
- * discount = subtotal × rejectionPercentage / 100.
135
+ * Header money for a purchase order: subtotal = qty × unit_cost.
136
+ * Additive rates are summed first; successive rates then apply to the remainder.
104
137
  * Does not change unit cost. purchase_orders.discount is the monetary column.
105
138
  *
106
- * If `rules` is given, the rejection % is resolved back to the origin stored in
107
- * the memo and the rules are stacked on top of it before calculating the money.
108
- * `rejectionPercentage` in the result is the % that was actually charged and
109
- * `rejectionCalculations` is the trace to store in the memo.
139
+ * User layers (before rules) are returned as `rejectionLayers` for the memo.
140
+ * Automatic rules add percentage points on the additive block only.
141
+ * `rejectionPercentage` in the result is the effective % that was charged.
110
142
  */
111
143
  const calculatePurchaseOrderFinancials = (input) => {
112
144
  const details = input.details ?? [];
@@ -115,17 +147,28 @@ const calculatePurchaseOrderFinancials = (input) => {
115
147
  return sum + lineQuantity(detail) * unitCost;
116
148
  }, 0));
117
149
  const safeSubtotal = Number.isFinite(subtotal) ? subtotal : 0;
118
- const passedPercentage = toFiniteNumber(input.rejectionPercentage) ?? 0;
150
+ const parsedMemo = (0, exports.parseJsonMemo)(input.memo);
151
+ let userLayers = input.rejectionLayers
152
+ ? (0, rejectionPercentageAdjustment_js_1.normalizeRejectionPercentageLayers)(input.rejectionLayers)
153
+ : (0, exports.resolveRejectionPercentageLayers)(parsedMemo, toFiniteNumber(input.rejectionPercentage));
154
+ if (input.rejectionApplication) {
155
+ userLayers = (0, rejectionPercentageAdjustment_js_1.applyRejectionPercentageLayers)(userLayers, input.rejectionApplication);
156
+ }
119
157
  const rejectionCalculations = input.rules
120
- ? (0, rejectionPercentageAdjustment_js_1.applyRejectionPercentageAdjustmentRules)((0, rejectionPercentageAdjustment_js_1.resolveRejectionPercentageOrigin)(passedPercentage, (0, exports.parseJsonMemo)(input.memo).rejectionCalculations), input.rules, input.ctx ?? { itemIds: [] })
158
+ ? (0, rejectionPercentageAdjustment_js_1.applyRejectionPercentageAdjustmentRules)(userLayers.additive, input.rules, input.ctx ?? { itemIds: [] })
121
159
  : undefined;
122
- const rejectionPercentage = rejectionCalculations?.finalPercentage ?? passedPercentage;
160
+ const chargedLayers = {
161
+ additive: rejectionCalculations?.finalPercentage ?? userLayers.additive,
162
+ successive: userLayers.successive,
163
+ };
164
+ const rejectionPercentage = (0, rejectionPercentageAdjustment_js_1.computeEffectiveRejectionPercentage)(chargedLayers);
123
165
  return {
124
166
  subtotal: safeSubtotal,
125
- discount: rejectionPercentage > 0 && safeSubtotal > 0
167
+ discount: rejectionPercentage !== 0 && safeSubtotal > 0
126
168
  ? roundMoney(safeSubtotal * (rejectionPercentage / 100))
127
169
  : 0,
128
170
  rejectionPercentage,
171
+ rejectionLayers: userLayers,
129
172
  rejectionCalculations,
130
173
  };
131
174
  };
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.applyRejectionPercentageAdjustmentRules = exports.isAutomaticRejectionRuleHistoryType = exports.formatAutomaticRejectionRuleHistoryDescription = exports.getNewlyAppliedRejectionRuleSteps = exports.rejectionRuleStepKey = exports.resolveRejectionPercentageOrigin = exports.formatRejectionPercentageAdjustmentDescription = exports.rejectionPercentageAdjustmentRuleMatches = exports.filterActiveRejectionPercentageAdjustmentRules = exports.REJECTION_PERCENTAGE_RULE_HISTORY_TYPE = exports.REJECTION_PERCENTAGE_ADJUSTMENT_RULES_TABLE_VAR = void 0;
3
+ exports.applyRejectionPercentageAdjustmentRules = exports.isAutomaticRejectionRuleHistoryType = exports.formatAutomaticRejectionRuleHistoryDescription = exports.getNewlyAppliedRejectionRuleSteps = exports.rejectionRuleStepKey = exports.resolveRejectionPercentageOrigin = exports.formatRejectionPercentageAdjustmentDescription = exports.hasSuccessiveRejectionLayers = exports.computeEffectiveRejectionPercentage = exports.applyRejectionPercentageLayers = exports.normalizeRejectionPercentageLayers = exports.rejectionPercentageAdjustmentRuleMatches = exports.filterActiveRejectionPercentageAdjustmentRules = exports.REJECTION_PERCENTAGE_RULE_HISTORY_TYPE = exports.REJECTION_PERCENTAGE_ADJUSTMENT_RULES_TABLE_VAR = void 0;
4
4
  exports.REJECTION_PERCENTAGE_ADJUSTMENT_RULES_TABLE_VAR = "rejection_percentage_adjustment_rules_web_app_table_id";
5
5
  exports.REJECTION_PERCENTAGE_RULE_HISTORY_TYPE = "rechazo_regla_automatica";
6
6
  const defaultFilters = () => ({
@@ -57,6 +57,58 @@ const roundPercentage = (value, digits = 4) => {
57
57
  return Math.round((value + Number.EPSILON) * factor) / factor;
58
58
  };
59
59
  const clampPercentage = (value) => Math.min(100, Math.max(0, value));
60
+ const toLayerNumber = (value) => {
61
+ const parsed = Number(value);
62
+ return Number.isFinite(parsed) ? parsed : 0;
63
+ };
64
+ const normalizeRejectionPercentageLayers = (layers) => ({
65
+ additive: clampPercentage(roundPercentage(toLayerNumber(layers?.additive))),
66
+ successive: Array.isArray(layers?.successive)
67
+ ? layers.successive.map((value) => toLayerNumber(value))
68
+ : [],
69
+ });
70
+ exports.normalizeRejectionPercentageLayers = normalizeRejectionPercentageLayers;
71
+ const applyRejectionPercentageLayers = (layers, application) => {
72
+ const current = (0, exports.normalizeRejectionPercentageLayers)(layers);
73
+ const value = toLayerNumber(application.value);
74
+ switch (application.mode) {
75
+ case "add":
76
+ return {
77
+ additive: clampPercentage(roundPercentage(current.additive + value)),
78
+ successive: [...current.successive],
79
+ };
80
+ case "replace":
81
+ return {
82
+ additive: clampPercentage(roundPercentage(value)),
83
+ successive: [],
84
+ };
85
+ case "successive":
86
+ return {
87
+ additive: current.additive,
88
+ successive: [...current.successive, value],
89
+ };
90
+ default: {
91
+ const _exhaustive = application.mode;
92
+ return _exhaustive;
93
+ }
94
+ }
95
+ };
96
+ exports.applyRejectionPercentageLayers = applyRejectionPercentageLayers;
97
+ /**
98
+ * Effective rejection % after additive then each successive rate on the remainder.
99
+ * 10% then 10% successive → 19, not 20.
100
+ */
101
+ const computeEffectiveRejectionPercentage = (layers, digits = 2) => {
102
+ const normalized = (0, exports.normalizeRejectionPercentageLayers)(layers);
103
+ let remainingFactor = 1 - normalized.additive / 100;
104
+ for (const rate of normalized.successive) {
105
+ remainingFactor *= 1 - rate / 100;
106
+ }
107
+ return roundPercentage((1 - remainingFactor) * 100, digits);
108
+ };
109
+ exports.computeEffectiveRejectionPercentage = computeEffectiveRejectionPercentage;
110
+ const hasSuccessiveRejectionLayers = (layers) => Array.isArray(layers?.successive) && layers.successive.length > 0;
111
+ exports.hasSuccessiveRejectionLayers = hasSuccessiveRejectionLayers;
60
112
  const formatPercentage = (value) => Number.isInteger(value) ? String(value) : String(roundPercentage(value));
61
113
  const formatFinalPercentage = (value) => roundPercentage(value, 2).toFixed(2);
62
114
  const formatRuleEffect = (step) => {
@@ -1,8 +1,16 @@
1
- import type { RejectionCalculationMemo, RejectionPercentageAdjustmentContext, RejectionPercentageAdjustmentResult, RejectionPercentageAdjustmentRule, RejectionPercentageAdjustmentStep, WebAppRowGraphQL } from "@zauru-sdk/types";
1
+ import type { RejectionCalculationMemo, RejectionPercentageAdjustmentContext, RejectionPercentageAdjustmentResult, RejectionPercentageAdjustmentRule, RejectionPercentageAdjustmentStep, RejectionPercentageApplication, RejectionPercentageLayers, WebAppRowGraphQL } from "@zauru-sdk/types";
2
2
  export declare const REJECTION_PERCENTAGE_ADJUSTMENT_RULES_TABLE_VAR = "rejection_percentage_adjustment_rules_web_app_table_id";
3
3
  export declare const REJECTION_PERCENTAGE_RULE_HISTORY_TYPE: "rechazo_regla_automatica";
4
4
  export declare const filterActiveRejectionPercentageAdjustmentRules: (rules?: WebAppRowGraphQL<RejectionPercentageAdjustmentRule>[]) => WebAppRowGraphQL<RejectionPercentageAdjustmentRule>[];
5
5
  export declare const rejectionPercentageAdjustmentRuleMatches: (rule: RejectionPercentageAdjustmentRule, ctx: RejectionPercentageAdjustmentContext) => boolean;
6
+ export declare const normalizeRejectionPercentageLayers: (layers?: Partial<RejectionPercentageLayers> | null) => RejectionPercentageLayers;
7
+ export declare const applyRejectionPercentageLayers: (layers: RejectionPercentageLayers, application: RejectionPercentageApplication) => RejectionPercentageLayers;
8
+ /**
9
+ * Effective rejection % after additive then each successive rate on the remainder.
10
+ * 10% then 10% successive → 19, not 20.
11
+ */
12
+ export declare const computeEffectiveRejectionPercentage: (layers: RejectionPercentageLayers, digits?: number) => number;
13
+ export declare const hasSuccessiveRejectionLayers: (layers?: Partial<RejectionPercentageLayers> | null) => boolean;
6
14
  export declare const formatRejectionPercentageAdjustmentDescription: (result: Pick<RejectionPercentageAdjustmentResult, "basePercentage" | "finalPercentage" | "steps">, baseLabel?: string) => string;
7
15
  /**
8
16
  * Callers may pass the origin % or the previously stored final %. With the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zauru-sdk/common",
3
- "version": "6.7.0",
3
+ "version": "6.8.1",
4
4
  "description": "Funciones de uso común en el desarrollo de webapps en Zauru",
5
5
  "main": "./dist/esm/index.js",
6
6
  "module": "./dist/esm/index.js",
@@ -21,9 +21,9 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@remix-run/node": "^2.8.1",
24
- "@zauru-sdk/types": "^6.17.0",
24
+ "@zauru-sdk/types": "^6.19.0",
25
25
  "moment": "^2.30.1",
26
26
  "moment-timezone": "^0.5.45"
27
27
  },
28
- "gitHead": "d3483aa3b55a9a692f955e6a56d13f33b70db1c4"
28
+ "gitHead": "7dd259a98a24d4aba27e749c085eda117c0d657d"
29
29
  }
package/src/common.ts CHANGED
@@ -10,10 +10,15 @@ import type {
10
10
  CalculatePurchaseOrderFinancialsInput,
11
11
  CalculatePurchaseOrderFinancialsResult,
12
12
  PurchaseOrderFinancialDetail,
13
+ RejectionPercentageLayers,
13
14
  } from "@zauru-sdk/types";
14
15
  import { MONTHS } from "@zauru-sdk/types";
15
16
  import {
16
17
  applyRejectionPercentageAdjustmentRules,
18
+ applyRejectionPercentageLayers,
19
+ computeEffectiveRejectionPercentage,
20
+ hasSuccessiveRejectionLayers,
21
+ normalizeRejectionPercentageLayers,
17
22
  resolveRejectionPercentageOrigin,
18
23
  } from "./rejectionPercentageAdjustment.js";
19
24
 
@@ -89,17 +94,58 @@ export const setRejectionPercentage = (
89
94
  percentage: number,
90
95
  ): string => mergeJsonMemo(memo, { rejectionPercentage: percentage });
91
96
 
97
+ const memoFromRejectionSource = (
98
+ source: string | { memo?: string | object } | JsonMemoType | undefined,
99
+ ): JsonMemoType => {
100
+ if (source == null || source === "") return {};
101
+ if (typeof source === "string") return parseJsonMemo(source);
102
+ if (typeof source !== "object") return {};
103
+ const record = source as { memo?: string | object } & JsonMemoType;
104
+ if (record.memo != null && record.memo !== "") {
105
+ return parseJsonMemo(record.memo);
106
+ }
107
+ return parseJsonMemo(record);
108
+ };
109
+
110
+ /**
111
+ * User rejection layers (before automatic rules). Legacy memos without
112
+ * `rejectionLayers` use the stored origin as additive and no successive rates.
113
+ */
114
+ export const resolveRejectionPercentageLayers = (
115
+ memo?: string | object | JsonMemoType,
116
+ fallbackAdditive?: number,
117
+ ): RejectionPercentageLayers => {
118
+ const parsed = parseJsonMemo(memo);
119
+ if (parsed.rejectionLayers) {
120
+ return normalizeRejectionPercentageLayers(parsed.rejectionLayers);
121
+ }
122
+
123
+ const origin = resolveRejectionPercentageOrigin(
124
+ fallbackAdditive ?? getRejectionPercentage(parsed),
125
+ parsed.rejectionCalculations,
126
+ );
127
+ return normalizeRejectionPercentageLayers({
128
+ additive: origin,
129
+ successive: [],
130
+ });
131
+ };
132
+
133
+ export const getRejectionPercentageLayers = (
134
+ source: string | { memo?: string | object } | JsonMemoType | undefined,
135
+ ): RejectionPercentageLayers =>
136
+ resolveRejectionPercentageLayers(memoFromRejectionSource(source));
137
+
138
+ export const hasSuccessiveRejectionPercentage = (
139
+ source: string | { memo?: string | object } | JsonMemoType | undefined,
140
+ ): boolean => hasSuccessiveRejectionLayers(getRejectionPercentageLayers(source));
141
+
92
142
  /**
93
143
  * Rejection % of origin stored in the memo, ignoring the adjustment rules
94
144
  * already applied on top of it.
95
145
  */
96
146
  export const resolveRejectionPercentageBase = (
97
147
  memo?: string | object,
98
- ): number =>
99
- resolveRejectionPercentageOrigin(
100
- getRejectionPercentage(memo),
101
- parseJsonMemo(memo).rejectionCalculations,
102
- );
148
+ ): number => resolveRejectionPercentageLayers(memo).additive;
103
149
 
104
150
  const roundMoney = (value: number): number =>
105
151
  Math.round((value + Number.EPSILON) * 100) / 100;
@@ -119,14 +165,13 @@ const lineQuantity = (detail: PurchaseOrderFinancialDetail): number => {
119
165
  };
120
166
 
121
167
  /**
122
- * Header money for a purchase order: subtotal = qty × unit_cost,
123
- * discount = subtotal × rejectionPercentage / 100.
168
+ * Header money for a purchase order: subtotal = qty × unit_cost.
169
+ * Additive rates are summed first; successive rates then apply to the remainder.
124
170
  * Does not change unit cost. purchase_orders.discount is the monetary column.
125
171
  *
126
- * If `rules` is given, the rejection % is resolved back to the origin stored in
127
- * the memo and the rules are stacked on top of it before calculating the money.
128
- * `rejectionPercentage` in the result is the % that was actually charged and
129
- * `rejectionCalculations` is the trace to store in the memo.
172
+ * User layers (before rules) are returned as `rejectionLayers` for the memo.
173
+ * Automatic rules add percentage points on the additive block only.
174
+ * `rejectionPercentage` in the result is the effective % that was charged.
130
175
  */
131
176
  export const calculatePurchaseOrderFinancials = (
132
177
  input: CalculatePurchaseOrderFinancialsInput,
@@ -140,28 +185,45 @@ export const calculatePurchaseOrderFinancials = (
140
185
  );
141
186
  const safeSubtotal = Number.isFinite(subtotal) ? subtotal : 0;
142
187
 
143
- const passedPercentage = toFiniteNumber(input.rejectionPercentage) ?? 0;
188
+ const parsedMemo = parseJsonMemo(input.memo);
189
+ let userLayers = input.rejectionLayers
190
+ ? normalizeRejectionPercentageLayers(input.rejectionLayers)
191
+ : resolveRejectionPercentageLayers(
192
+ parsedMemo,
193
+ toFiniteNumber(input.rejectionPercentage),
194
+ );
195
+
196
+ if (input.rejectionApplication) {
197
+ userLayers = applyRejectionPercentageLayers(
198
+ userLayers,
199
+ input.rejectionApplication,
200
+ );
201
+ }
202
+
144
203
  const rejectionCalculations = input.rules
145
204
  ? applyRejectionPercentageAdjustmentRules(
146
- resolveRejectionPercentageOrigin(
147
- passedPercentage,
148
- parseJsonMemo(input.memo).rejectionCalculations,
149
- ),
205
+ userLayers.additive,
150
206
  input.rules,
151
207
  input.ctx ?? { itemIds: [] },
152
208
  )
153
209
  : undefined;
154
210
 
211
+ const chargedLayers = {
212
+ additive:
213
+ rejectionCalculations?.finalPercentage ?? userLayers.additive,
214
+ successive: userLayers.successive,
215
+ };
155
216
  const rejectionPercentage =
156
- rejectionCalculations?.finalPercentage ?? passedPercentage;
217
+ computeEffectiveRejectionPercentage(chargedLayers);
157
218
 
158
219
  return {
159
220
  subtotal: safeSubtotal,
160
221
  discount:
161
- rejectionPercentage > 0 && safeSubtotal > 0
222
+ rejectionPercentage !== 0 && safeSubtotal > 0
162
223
  ? roundMoney(safeSubtotal * (rejectionPercentage / 100))
163
224
  : 0,
164
225
  rejectionPercentage,
226
+ rejectionLayers: userLayers,
165
227
  rejectionCalculations,
166
228
  };
167
229
  };
@@ -6,6 +6,8 @@ import type {
6
6
  RejectionPercentageAdjustmentResult,
7
7
  RejectionPercentageAdjustmentRule,
8
8
  RejectionPercentageAdjustmentStep,
9
+ RejectionPercentageApplication,
10
+ RejectionPercentageLayers,
9
11
  WebAppRowGraphQL,
10
12
  } from "@zauru-sdk/types";
11
13
 
@@ -111,6 +113,71 @@ const roundPercentage = (value: number, digits = 4) => {
111
113
 
112
114
  const clampPercentage = (value: number) => Math.min(100, Math.max(0, value));
113
115
 
116
+ const toLayerNumber = (value: unknown): number => {
117
+ const parsed = Number(value);
118
+ return Number.isFinite(parsed) ? parsed : 0;
119
+ };
120
+
121
+ export const normalizeRejectionPercentageLayers = (
122
+ layers?: Partial<RejectionPercentageLayers> | null,
123
+ ): RejectionPercentageLayers => ({
124
+ additive: clampPercentage(roundPercentage(toLayerNumber(layers?.additive))),
125
+ successive: Array.isArray(layers?.successive)
126
+ ? layers.successive.map((value) => toLayerNumber(value))
127
+ : [],
128
+ });
129
+
130
+ export const applyRejectionPercentageLayers = (
131
+ layers: RejectionPercentageLayers,
132
+ application: RejectionPercentageApplication,
133
+ ): RejectionPercentageLayers => {
134
+ const current = normalizeRejectionPercentageLayers(layers);
135
+ const value = toLayerNumber(application.value);
136
+
137
+ switch (application.mode) {
138
+ case "add":
139
+ return {
140
+ additive: clampPercentage(roundPercentage(current.additive + value)),
141
+ successive: [...current.successive],
142
+ };
143
+ case "replace":
144
+ return {
145
+ additive: clampPercentage(roundPercentage(value)),
146
+ successive: [],
147
+ };
148
+ case "successive":
149
+ return {
150
+ additive: current.additive,
151
+ successive: [...current.successive, value],
152
+ };
153
+ default: {
154
+ const _exhaustive: never = application.mode;
155
+ return _exhaustive;
156
+ }
157
+ }
158
+ };
159
+
160
+ /**
161
+ * Effective rejection % after additive then each successive rate on the remainder.
162
+ * 10% then 10% successive → 19, not 20.
163
+ */
164
+ export const computeEffectiveRejectionPercentage = (
165
+ layers: RejectionPercentageLayers,
166
+ digits = 2,
167
+ ): number => {
168
+ const normalized = normalizeRejectionPercentageLayers(layers);
169
+ let remainingFactor = 1 - normalized.additive / 100;
170
+ for (const rate of normalized.successive) {
171
+ remainingFactor *= 1 - rate / 100;
172
+ }
173
+ return roundPercentage((1 - remainingFactor) * 100, digits);
174
+ };
175
+
176
+ export const hasSuccessiveRejectionLayers = (
177
+ layers?: Partial<RejectionPercentageLayers> | null,
178
+ ): boolean =>
179
+ Array.isArray(layers?.successive) && layers.successive.length > 0;
180
+
114
181
  const formatPercentage = (value: number) =>
115
182
  Number.isInteger(value) ? String(value) : String(roundPercentage(value));
116
183