@zeniai/client-epic-state 5.0.90-betaAR1 → 5.0.90-betaAR3

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.
@@ -52,6 +52,12 @@ export const getReviewPageBillDetail = (state, billId) => {
52
52
  let recurringConfig;
53
53
  let billPayInfo;
54
54
  let isBillMarkedForRetry = false;
55
+ // Bill-level accounting class id. Approval Rules 3.0 'department'
56
+ // criteria match against this. Sourced from BillTransaction so
57
+ // any selector that needs it (e.g. creator-as-approver lookup)
58
+ // can read it off the view rather than reaching into
59
+ // 'state.billTransactionState' itself.
60
+ let classId;
55
61
  const zeniAssistRetryStatus = billId != null
56
62
  ? billDetailViewState.billDetailById[key].zeniAssist.retryStatus
57
63
  : undefined;
@@ -62,6 +68,13 @@ export const getReviewPageBillDetail = (state, billId) => {
62
68
  transaction?.billPayInfo.isCollectToAccountInfoFromVendorContact ?? false;
63
69
  isRecurring = transaction?.isRecurring ?? false;
64
70
  billPayInfo = transaction?.billPayInfo;
71
+ // Normalise empty-string to undefined so downstream readers
72
+ // treat "no class" uniformly regardless of whether the wire
73
+ // used '' or omitted the field.
74
+ classId =
75
+ transaction?.classId != null && transaction.classId !== ''
76
+ ? transaction.classId
77
+ : undefined;
65
78
  if (isRecurring &&
66
79
  transaction?.recurringBillConfigId != null &&
67
80
  transaction.recurringBillInstance != null) {
@@ -178,5 +191,6 @@ export const getReviewPageBillDetail = (state, billId) => {
178
191
  zeniAssistRetryStatus,
179
192
  billPayInfo,
180
193
  outsideZeniPaymentInfo,
194
+ classId,
181
195
  };
182
196
  };
@@ -165,13 +165,23 @@ const billPaySetupApproverView = createSlice({
165
165
  reorderBillPayApprovalRules: {
166
166
  reducer(draft, action) {
167
167
  const { approvalRuleIds } = action.payload;
168
+ const wasReorderInFlight = draft.reorderStatus.fetchState === 'In-Progress';
168
169
  draft.reorderStatus = { fetchState: 'In-Progress', error: undefined };
169
170
  // Stash the pre-reorder order so 'Failure' can restore it
170
171
  // synchronously. The epic's refetch on failure remains as a
171
172
  // safety net (covers cases where the server's truth has
172
173
  // drifted from the local snapshot), but the snappy UX path
173
174
  // is the in-memory rollback.
174
- draft.reorderRollbackIds = draft.approvalRuleIds;
175
+ //
176
+ // Only capture a fresh snapshot when no reorder is already in
177
+ // flight. If a second drag fires before the first PUT lands,
178
+ // the existing snapshot already holds the last server-backed
179
+ // order; overwriting it with the first reorder's optimistic
180
+ // result would mean a Failure rolls back to an uncommitted
181
+ // intermediate order instead of the last good list.
182
+ if (!wasReorderInFlight) {
183
+ draft.reorderRollbackIds = draft.approvalRuleIds;
184
+ }
175
185
  // Optimistic: render new order immediately. On failure, the
176
186
  // reducer restores 'reorderRollbackIds' and the epic refetch
177
187
  // reconciles any drift.
@@ -91,19 +91,15 @@ export const toApprovalChangableInfoPayload = (approverViewUpdateData) => {
91
91
  return payload;
92
92
  };
93
93
  /**
94
- * V1 wire mapper — used when 'approval_rule_v3_config' resolves to OFF
95
- * for the current tenant. Emits the legacy snake_case array shape that
96
- * '/approval-rules' historically consumed:
97
- * criteria: [{type: 'range', range_entity: 'currency', min, max,
98
- * currency_code, currency_symbol}]
94
+ * V1 wire mapper — used when `approval_rule_v3_config` resolves to OFF
95
+ * for the current tenant. Translates the form-side amount criteria into
96
+ * the legacy `{rangeType, rangeEntity, range: {min, max}}` shape. The
97
+ * V1 backend does not understand vendor / department criteria or the
98
+ * rule-level 3.0 fields, so those are dropped on the wire.
99
99
  *
100
- * Open-ended semantics are preserved on the wire: an absent 'min' or
101
- * 'max' is sent as 'null' rather than synthesised to '0' or
102
- * 'MAX_SAFE_INTEGER', so a 'greater_than' / 'less_than' rule stays
103
- * one-sided after rollback rather than collapsing into a closed range.
104
- *
105
- * The V1 backend does not understand vendor / department criteria or
106
- * the rule-level 3.0 fields, so those are dropped on the wire.
100
+ * The form-side amount slot is always read as a `range`-style criteria
101
+ * here: V1 schemas only supported ranges, so an absent `min` or `max`
102
+ * falls back to `0` / `Number.MAX_SAFE_INTEGER`.
107
103
  */
108
104
  export const toApprovalChangableInfoPayloadV1 = (approverViewUpdateData) => {
109
105
  const amount = approverViewUpdateData.criteria?.amount;
@@ -129,18 +125,25 @@ export const toApprovalChangableInfoPayloadV1 = (approverViewUpdateData) => {
129
125
  };
130
126
  });
131
127
  const isUpdate = approverViewUpdateData.approvalRuleId != null;
132
- const criteriaEntry = {
133
- type: 'range',
134
- range_entity: 'currency',
135
- min: minAmount?.amount ?? null,
136
- max: maxAmount?.amount ?? null,
137
- currency_code: currencyCode,
138
- currency_symbol: currencySymbol,
139
- };
140
128
  const payload = {
141
129
  steps,
142
130
  is_applicable_on_pending_approval_entity: approverViewUpdateData.isApplicableOnPendingApprovalEntity ?? true,
143
- criteria: [criteriaEntry],
131
+ criteria: {
132
+ rangeType: 'range',
133
+ rangeEntity: 'currency',
134
+ range: {
135
+ min: {
136
+ amount: minAmount?.amount ?? 0,
137
+ currencyCode,
138
+ currencySymbol,
139
+ },
140
+ max: {
141
+ amount: maxAmount?.amount ?? Number.MAX_SAFE_INTEGER,
142
+ currencyCode,
143
+ currencySymbol,
144
+ },
145
+ },
146
+ },
144
147
  };
145
148
  if (isUpdate) {
146
149
  payload.approval_rule_id = approverViewUpdateData.approvalRuleId;
@@ -357,6 +357,14 @@ export const getEditBillDetail = (state, isVendorRecommendationFeatureEnabled, i
357
357
  isDeleted,
358
358
  showAutofill,
359
359
  addBillAutoFields,
360
+ // Bill-level accounting class. Approval Rules 3.0 'department'
361
+ // criteria match against this. Lives on BillTransaction; not on
362
+ // any sub-section. Empty-string is normalised to 'undefined'
363
+ // so downstream readers treat "no class" uniformly regardless
364
+ // of whether the wire used '' or omitted the field.
365
+ classId: transaction?.classId != null && transaction.classId !== ''
366
+ ? transaction.classId
367
+ : undefined,
360
368
  billStage,
361
369
  deleteStatus,
362
370
  billActivities,
@@ -681,29 +689,22 @@ export const getDefaultWithdrawFromAccount = (state, depositAccounts, companyId,
681
689
  };
682
690
  export const checkIfCreatorIsApprover = (billAmount, approvalRules, signedInUser, billVendorId, billDepartmentId) => {
683
691
  let isCreatorAlsoApprover = false;
684
- // Approval Rules 3.0 — a rule is applicable when every criterion it
685
- // carries matches the bill. Amount uses inclusive bounds ('gte'/'lte'
686
- // on the wire), and either bound may be absent for a one-sided
687
- // 'greater_than' or 'less_than' rule. Vendor / department criteria
688
- // narrow the rule by entity; an absent criterion means 'no narrowing
689
- // on this axis'. Rules without an amount criterion at all are not
690
- // considered here, preserving the pre-3.0 behaviour where this
691
- // selector only ever matched amount-bearing rules.
692
+ // Approval Rules 3.0 — a rule applies to a bill only when all of
693
+ // its present criteria are satisfied. Criteria not declared on
694
+ // the rule are treated as "no narrowing on this axis".
695
+ //
696
+ // Strict-match semantics for vendor / department: if the rule
697
+ // narrows by an axis but the bill has no value on that axis, the
698
+ // rule does NOT apply (an absent bill-side value can't satisfy
699
+ // either 'is' or 'is_not'). Amount is always present on a rule
700
+ // (the form enforces this), but bounds may be one-sided.
692
701
  const approvalRule = approvalRules.find((rule) => {
693
- const amountCriteria = getAmountCriteria(rule.criteria);
694
- if (amountCriteria == null) {
695
- return false;
696
- }
697
- const { min, max } = amountCriteria;
698
- const matchesMin = min == null || billAmount >= min.amount;
699
- const matchesMax = max == null || billAmount <= max.amount;
700
- if (!matchesMin || !matchesMax) {
701
- return false;
702
- }
703
702
  const vendorCriteria = getVendorCriteria(rule.criteria);
704
703
  if (vendorCriteria != null) {
705
- const includesVendor = billVendorId != null &&
706
- vendorCriteria.vendorIds.includes(billVendorId);
704
+ if (billVendorId == null) {
705
+ return false;
706
+ }
707
+ const includesVendor = vendorCriteria.vendorIds.includes(billVendorId);
707
708
  if (vendorCriteria.operator === 'is' && !includesVendor) {
708
709
  return false;
709
710
  }
@@ -713,8 +714,10 @@ export const checkIfCreatorIsApprover = (billAmount, approvalRules, signedInUser
713
714
  }
714
715
  const departmentCriteria = getDepartmentCriteria(rule.criteria);
715
716
  if (departmentCriteria != null) {
716
- const includesDepartment = billDepartmentId != null &&
717
- departmentCriteria.departmentIds.includes(billDepartmentId);
717
+ if (billDepartmentId == null) {
718
+ return false;
719
+ }
720
+ const includesDepartment = departmentCriteria.departmentIds.includes(billDepartmentId);
718
721
  if (departmentCriteria.operator === 'is' && !includesDepartment) {
719
722
  return false;
720
723
  }
@@ -722,7 +725,21 @@ export const checkIfCreatorIsApprover = (billAmount, approvalRules, signedInUser
722
725
  return false;
723
726
  }
724
727
  }
725
- return true;
728
+ const amountCriteria = getAmountCriteria(rule.criteria);
729
+ if (amountCriteria == null) {
730
+ // The form guarantees an amount criterion on every rule, so
731
+ // this branch is defensive against backend drift. Keep
732
+ // pre-3.0 behaviour: a rule that doesn't declare amount
733
+ // doesn't participate here.
734
+ return false;
735
+ }
736
+ // Bounds use the wire's inclusive 'gte' / 'lte' semantics, and
737
+ // either side may be absent for a one-sided 'greater_than' /
738
+ // 'less_than' rule.
739
+ const { min, max } = amountCriteria;
740
+ const matchesMin = min == null || billAmount >= min.amount;
741
+ const matchesMax = max == null || billAmount <= max.amount;
742
+ return matchesMin && matchesMax;
726
743
  });
727
744
  if (approvalRule != null) {
728
745
  const requireApprovalSteps = approvalRule.steps.filter((step) => step.action === 'require_approval');
@@ -182,10 +182,15 @@ const remiSetupApproverView = createSlice({
182
182
  reorderRemiApprovalRules: {
183
183
  reducer(draft, action) {
184
184
  const { approvalRuleIds } = action.payload;
185
+ const wasReorderInFlight = draft.reorderStatus.fetchState === 'In-Progress';
185
186
  draft.reorderStatus = { fetchState: 'In-Progress', error: undefined };
186
187
  // Stash the pre-reorder order for synchronous rollback on
187
- // 'Failure'. See the BillPay reducer for the rationale.
188
- draft.reorderRollbackIds = draft.approvalRuleIds;
188
+ // 'Failure'. See the BillPay reducer for the rationale,
189
+ // including the in-flight guard: a second drag before the
190
+ // first PUT lands must not overwrite the snapshot.
191
+ if (!wasReorderInFlight) {
192
+ draft.reorderRollbackIds = draft.approvalRuleIds;
193
+ }
189
194
  draft.approvalRuleIds = approvalRuleIds;
190
195
  },
191
196
  prepare(approvalRuleIds, useV3 = false) {
@@ -24,6 +24,13 @@ export interface BillPayReviewSelectorView extends SelectorView {
24
24
  vendor: Vendor | undefined;
25
25
  zeniAssistRetryStatus: FetchStateAndError | undefined;
26
26
  billPayInfo?: BillPayInfo;
27
+ /**
28
+ * Bill-level accounting class id. Approval Rules 3.0 'department'
29
+ * criteria match against this — readers needing it (e.g. the
30
+ * creator-as-approver lookup) should read it off the view rather
31
+ * than digging through 'state.billTransactionState'.
32
+ */
33
+ classId?: ID;
27
34
  invoiceNumber?: string;
28
35
  isBillMarkedForRetry?: boolean;
29
36
  outsideZeniPaymentInfo?: OutsideZeniPaymentLocalData;
@@ -59,6 +59,12 @@ const getReviewPageBillDetail = (state, billId) => {
59
59
  let recurringConfig;
60
60
  let billPayInfo;
61
61
  let isBillMarkedForRetry = false;
62
+ // Bill-level accounting class id. Approval Rules 3.0 'department'
63
+ // criteria match against this. Sourced from BillTransaction so
64
+ // any selector that needs it (e.g. creator-as-approver lookup)
65
+ // can read it off the view rather than reaching into
66
+ // 'state.billTransactionState' itself.
67
+ let classId;
62
68
  const zeniAssistRetryStatus = billId != null
63
69
  ? billDetailViewState.billDetailById[key].zeniAssist.retryStatus
64
70
  : undefined;
@@ -69,6 +75,13 @@ const getReviewPageBillDetail = (state, billId) => {
69
75
  transaction?.billPayInfo.isCollectToAccountInfoFromVendorContact ?? false;
70
76
  isRecurring = transaction?.isRecurring ?? false;
71
77
  billPayInfo = transaction?.billPayInfo;
78
+ // Normalise empty-string to undefined so downstream readers
79
+ // treat "no class" uniformly regardless of whether the wire
80
+ // used '' or omitted the field.
81
+ classId =
82
+ transaction?.classId != null && transaction.classId !== ''
83
+ ? transaction.classId
84
+ : undefined;
72
85
  if (isRecurring &&
73
86
  transaction?.recurringBillConfigId != null &&
74
87
  transaction.recurringBillInstance != null) {
@@ -185,6 +198,7 @@ const getReviewPageBillDetail = (state, billId) => {
185
198
  zeniAssistRetryStatus,
186
199
  billPayInfo,
187
200
  outsideZeniPaymentInfo,
201
+ classId,
188
202
  };
189
203
  };
190
204
  exports.getReviewPageBillDetail = getReviewPageBillDetail;
@@ -169,13 +169,23 @@ const billPaySetupApproverView = (0, toolkit_1.createSlice)({
169
169
  reorderBillPayApprovalRules: {
170
170
  reducer(draft, action) {
171
171
  const { approvalRuleIds } = action.payload;
172
+ const wasReorderInFlight = draft.reorderStatus.fetchState === 'In-Progress';
172
173
  draft.reorderStatus = { fetchState: 'In-Progress', error: undefined };
173
174
  // Stash the pre-reorder order so 'Failure' can restore it
174
175
  // synchronously. The epic's refetch on failure remains as a
175
176
  // safety net (covers cases where the server's truth has
176
177
  // drifted from the local snapshot), but the snappy UX path
177
178
  // is the in-memory rollback.
178
- draft.reorderRollbackIds = draft.approvalRuleIds;
179
+ //
180
+ // Only capture a fresh snapshot when no reorder is already in
181
+ // flight. If a second drag fires before the first PUT lands,
182
+ // the existing snapshot already holds the last server-backed
183
+ // order; overwriting it with the first reorder's optimistic
184
+ // result would mean a Failure rolls back to an uncommitted
185
+ // intermediate order instead of the last good list.
186
+ if (!wasReorderInFlight) {
187
+ draft.reorderRollbackIds = draft.approvalRuleIds;
188
+ }
179
189
  // Optimistic: render new order immediately. On failure, the
180
190
  // reducer restores 'reorderRollbackIds' and the epic refetch
181
191
  // reconciles any drift.
@@ -30,48 +30,42 @@ interface ApprovalUpdatableInfoPayload {
30
30
  }
31
31
  /**
32
32
  * V1 / pre-3.0 wire shape. Kept so the rollback flag (gated by
33
- * 'approval_rule_v3_config' dynamic config) can target the original
34
- * '/approval-rules' endpoint with the legacy snake_case array of range
35
- * entries. None of the 3.0 rule-level fields are sent.
36
- *
37
- * The wire historically expected:
38
- * criteria: [{type, range_entity, min, max, currency_code, currency_symbol}]
39
- * with 'min' and 'max' as scalar numbers, and 'null' representing an
40
- * absent bound for one-sided rules. See 'LegacyAmountRangeCriterionPayload'
41
- * in '../../../../../entity/approvalRule/approvalRulePayload.ts' for the
42
- * matching read-side shape.
33
+ * `approval_rule_v3_config` dynamic config) can target the original
34
+ * `/approval-rules` endpoint with the legacy `{rangeType, rangeEntity, range}`
35
+ * criteria. None of the 3.0 rule-level fields are sent.
43
36
  */
44
- interface ApprovalUpdatableInfoPayloadV1CriterionEntry {
45
- currency_code: string;
46
- currency_symbol: string;
47
- /** Upper bound. 'null' for a 'greater_than' rule. */
48
- max: number | null;
49
- /** Lower bound. 'null' for a 'less_than' rule. */
50
- min: number | null;
51
- range_entity: 'currency';
52
- type: 'range';
53
- }
54
37
  interface ApprovalUpdatableInfoPayloadV1 {
55
- criteria: ApprovalUpdatableInfoPayloadV1CriterionEntry[];
38
+ criteria: {
39
+ range: {
40
+ max: {
41
+ amount: number;
42
+ currencyCode: string;
43
+ currencySymbol: string;
44
+ };
45
+ min: {
46
+ amount: number;
47
+ currencyCode: string;
48
+ currencySymbol: string;
49
+ };
50
+ };
51
+ rangeEntity: 'currency';
52
+ rangeType: 'range';
53
+ };
56
54
  is_applicable_on_pending_approval_entity: boolean;
57
55
  steps: ApproverViewStepPayload[];
58
56
  approval_rule_id?: ID;
59
57
  }
60
58
  export declare const toApprovalChangableInfoPayload: (approverViewUpdateData: ApprovalRuleUpdateData | ApprovalRuleCreateData) => Partial<ApprovalUpdatableInfoPayload>;
61
59
  /**
62
- * V1 wire mapper — used when 'approval_rule_v3_config' resolves to OFF
63
- * for the current tenant. Emits the legacy snake_case array shape that
64
- * '/approval-rules' historically consumed:
65
- * criteria: [{type: 'range', range_entity: 'currency', min, max,
66
- * currency_code, currency_symbol}]
67
- *
68
- * Open-ended semantics are preserved on the wire: an absent 'min' or
69
- * 'max' is sent as 'null' rather than synthesised to '0' or
70
- * 'MAX_SAFE_INTEGER', so a 'greater_than' / 'less_than' rule stays
71
- * one-sided after rollback rather than collapsing into a closed range.
60
+ * V1 wire mapper — used when `approval_rule_v3_config` resolves to OFF
61
+ * for the current tenant. Translates the form-side amount criteria into
62
+ * the legacy `{rangeType, rangeEntity, range: {min, max}}` shape. The
63
+ * V1 backend does not understand vendor / department criteria or the
64
+ * rule-level 3.0 fields, so those are dropped on the wire.
72
65
  *
73
- * The V1 backend does not understand vendor / department criteria or
74
- * the rule-level 3.0 fields, so those are dropped on the wire.
66
+ * The form-side amount slot is always read as a `range`-style criteria
67
+ * here: V1 schemas only supported ranges, so an absent `min` or `max`
68
+ * falls back to `0` / `Number.MAX_SAFE_INTEGER`.
75
69
  */
76
70
  export declare const toApprovalChangableInfoPayloadV1: (approverViewUpdateData: ApprovalRuleUpdateData | ApprovalRuleCreateData) => Partial<ApprovalUpdatableInfoPayloadV1>;
77
71
  export {};
@@ -95,19 +95,15 @@ const toApprovalChangableInfoPayload = (approverViewUpdateData) => {
95
95
  };
96
96
  exports.toApprovalChangableInfoPayload = toApprovalChangableInfoPayload;
97
97
  /**
98
- * V1 wire mapper — used when 'approval_rule_v3_config' resolves to OFF
99
- * for the current tenant. Emits the legacy snake_case array shape that
100
- * '/approval-rules' historically consumed:
101
- * criteria: [{type: 'range', range_entity: 'currency', min, max,
102
- * currency_code, currency_symbol}]
98
+ * V1 wire mapper — used when `approval_rule_v3_config` resolves to OFF
99
+ * for the current tenant. Translates the form-side amount criteria into
100
+ * the legacy `{rangeType, rangeEntity, range: {min, max}}` shape. The
101
+ * V1 backend does not understand vendor / department criteria or the
102
+ * rule-level 3.0 fields, so those are dropped on the wire.
103
103
  *
104
- * Open-ended semantics are preserved on the wire: an absent 'min' or
105
- * 'max' is sent as 'null' rather than synthesised to '0' or
106
- * 'MAX_SAFE_INTEGER', so a 'greater_than' / 'less_than' rule stays
107
- * one-sided after rollback rather than collapsing into a closed range.
108
- *
109
- * The V1 backend does not understand vendor / department criteria or
110
- * the rule-level 3.0 fields, so those are dropped on the wire.
104
+ * The form-side amount slot is always read as a `range`-style criteria
105
+ * here: V1 schemas only supported ranges, so an absent `min` or `max`
106
+ * falls back to `0` / `Number.MAX_SAFE_INTEGER`.
111
107
  */
112
108
  const toApprovalChangableInfoPayloadV1 = (approverViewUpdateData) => {
113
109
  const amount = approverViewUpdateData.criteria?.amount;
@@ -133,18 +129,25 @@ const toApprovalChangableInfoPayloadV1 = (approverViewUpdateData) => {
133
129
  };
134
130
  });
135
131
  const isUpdate = approverViewUpdateData.approvalRuleId != null;
136
- const criteriaEntry = {
137
- type: 'range',
138
- range_entity: 'currency',
139
- min: minAmount?.amount ?? null,
140
- max: maxAmount?.amount ?? null,
141
- currency_code: currencyCode,
142
- currency_symbol: currencySymbol,
143
- };
144
132
  const payload = {
145
133
  steps,
146
134
  is_applicable_on_pending_approval_entity: approverViewUpdateData.isApplicableOnPendingApprovalEntity ?? true,
147
- criteria: [criteriaEntry],
135
+ criteria: {
136
+ rangeType: 'range',
137
+ rangeEntity: 'currency',
138
+ range: {
139
+ min: {
140
+ amount: minAmount?.amount ?? 0,
141
+ currencyCode,
142
+ currencySymbol,
143
+ },
144
+ max: {
145
+ amount: maxAmount?.amount ?? Number.MAX_SAFE_INTEGER,
146
+ currencyCode,
147
+ currencySymbol,
148
+ },
149
+ },
150
+ },
148
151
  };
149
152
  if (isUpdate) {
150
153
  payload.approval_rule_id = approverViewUpdateData.approvalRuleId;
@@ -100,6 +100,13 @@ export interface EditBillDetailSelectorView extends SelectorView {
100
100
  withdrawFromList: FundingAccount[];
101
101
  billPayInfo?: BillPayInfo;
102
102
  bookCloseDate?: ZeniDate;
103
+ /**
104
+ * Bill-level accounting class id. Approval Rules 3.0 'department'
105
+ * criteria match against this — selectors needing to evaluate a
106
+ * department-narrowed rule against the current bill should read
107
+ * this rather than digging through 'state.billTransactionState'.
108
+ */
109
+ classId?: ID;
103
110
  createTime?: ZeniDate;
104
111
  editBillInitialDetails?: EditBillInitialDetails;
105
112
  internationalWireOnboardingStatus?: AllowedValueWithCode;
@@ -366,6 +366,14 @@ const getEditBillDetail = (state, isVendorRecommendationFeatureEnabled, isZeniAc
366
366
  isDeleted,
367
367
  showAutofill,
368
368
  addBillAutoFields,
369
+ // Bill-level accounting class. Approval Rules 3.0 'department'
370
+ // criteria match against this. Lives on BillTransaction; not on
371
+ // any sub-section. Empty-string is normalised to 'undefined'
372
+ // so downstream readers treat "no class" uniformly regardless
373
+ // of whether the wire used '' or omitted the field.
374
+ classId: transaction?.classId != null && transaction.classId !== ''
375
+ ? transaction.classId
376
+ : undefined,
369
377
  billStage,
370
378
  deleteStatus,
371
379
  billActivities,
@@ -698,29 +706,22 @@ const getDefaultWithdrawFromAccount = (state, depositAccounts, companyId, lastSe
698
706
  exports.getDefaultWithdrawFromAccount = getDefaultWithdrawFromAccount;
699
707
  const checkIfCreatorIsApprover = (billAmount, approvalRules, signedInUser, billVendorId, billDepartmentId) => {
700
708
  let isCreatorAlsoApprover = false;
701
- // Approval Rules 3.0 — a rule is applicable when every criterion it
702
- // carries matches the bill. Amount uses inclusive bounds ('gte'/'lte'
703
- // on the wire), and either bound may be absent for a one-sided
704
- // 'greater_than' or 'less_than' rule. Vendor / department criteria
705
- // narrow the rule by entity; an absent criterion means 'no narrowing
706
- // on this axis'. Rules without an amount criterion at all are not
707
- // considered here, preserving the pre-3.0 behaviour where this
708
- // selector only ever matched amount-bearing rules.
709
+ // Approval Rules 3.0 — a rule applies to a bill only when all of
710
+ // its present criteria are satisfied. Criteria not declared on
711
+ // the rule are treated as "no narrowing on this axis".
712
+ //
713
+ // Strict-match semantics for vendor / department: if the rule
714
+ // narrows by an axis but the bill has no value on that axis, the
715
+ // rule does NOT apply (an absent bill-side value can't satisfy
716
+ // either 'is' or 'is_not'). Amount is always present on a rule
717
+ // (the form enforces this), but bounds may be one-sided.
709
718
  const approvalRule = approvalRules.find((rule) => {
710
- const amountCriteria = (0, approvalRuleSelector_1.getAmountCriteria)(rule.criteria);
711
- if (amountCriteria == null) {
712
- return false;
713
- }
714
- const { min, max } = amountCriteria;
715
- const matchesMin = min == null || billAmount >= min.amount;
716
- const matchesMax = max == null || billAmount <= max.amount;
717
- if (!matchesMin || !matchesMax) {
718
- return false;
719
- }
720
719
  const vendorCriteria = (0, approvalRuleSelector_1.getVendorCriteria)(rule.criteria);
721
720
  if (vendorCriteria != null) {
722
- const includesVendor = billVendorId != null &&
723
- vendorCriteria.vendorIds.includes(billVendorId);
721
+ if (billVendorId == null) {
722
+ return false;
723
+ }
724
+ const includesVendor = vendorCriteria.vendorIds.includes(billVendorId);
724
725
  if (vendorCriteria.operator === 'is' && !includesVendor) {
725
726
  return false;
726
727
  }
@@ -730,8 +731,10 @@ const checkIfCreatorIsApprover = (billAmount, approvalRules, signedInUser, billV
730
731
  }
731
732
  const departmentCriteria = (0, approvalRuleSelector_1.getDepartmentCriteria)(rule.criteria);
732
733
  if (departmentCriteria != null) {
733
- const includesDepartment = billDepartmentId != null &&
734
- departmentCriteria.departmentIds.includes(billDepartmentId);
734
+ if (billDepartmentId == null) {
735
+ return false;
736
+ }
737
+ const includesDepartment = departmentCriteria.departmentIds.includes(billDepartmentId);
735
738
  if (departmentCriteria.operator === 'is' && !includesDepartment) {
736
739
  return false;
737
740
  }
@@ -739,7 +742,21 @@ const checkIfCreatorIsApprover = (billAmount, approvalRules, signedInUser, billV
739
742
  return false;
740
743
  }
741
744
  }
742
- return true;
745
+ const amountCriteria = (0, approvalRuleSelector_1.getAmountCriteria)(rule.criteria);
746
+ if (amountCriteria == null) {
747
+ // The form guarantees an amount criterion on every rule, so
748
+ // this branch is defensive against backend drift. Keep
749
+ // pre-3.0 behaviour: a rule that doesn't declare amount
750
+ // doesn't participate here.
751
+ return false;
752
+ }
753
+ // Bounds use the wire's inclusive 'gte' / 'lte' semantics, and
754
+ // either side may be absent for a one-sided 'greater_than' /
755
+ // 'less_than' rule.
756
+ const { min, max } = amountCriteria;
757
+ const matchesMin = min == null || billAmount >= min.amount;
758
+ const matchesMax = max == null || billAmount <= max.amount;
759
+ return matchesMin && matchesMax;
743
760
  });
744
761
  if (approvalRule != null) {
745
762
  const requireApprovalSteps = approvalRule.steps.filter((step) => step.action === 'require_approval');
@@ -186,10 +186,15 @@ const remiSetupApproverView = (0, toolkit_1.createSlice)({
186
186
  reorderRemiApprovalRules: {
187
187
  reducer(draft, action) {
188
188
  const { approvalRuleIds } = action.payload;
189
+ const wasReorderInFlight = draft.reorderStatus.fetchState === 'In-Progress';
189
190
  draft.reorderStatus = { fetchState: 'In-Progress', error: undefined };
190
191
  // Stash the pre-reorder order for synchronous rollback on
191
- // 'Failure'. See the BillPay reducer for the rationale.
192
- draft.reorderRollbackIds = draft.approvalRuleIds;
192
+ // 'Failure'. See the BillPay reducer for the rationale,
193
+ // including the in-flight guard: a second drag before the
194
+ // first PUT lands must not overwrite the snapshot.
195
+ if (!wasReorderInFlight) {
196
+ draft.reorderRollbackIds = draft.approvalRuleIds;
197
+ }
193
198
  draft.approvalRuleIds = approvalRuleIds;
194
199
  },
195
200
  prepare(approvalRuleIds, useV3 = false) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeniai/client-epic-state",
3
- "version": "5.0.90-betaAR1",
3
+ "version": "5.0.90-betaAR3",
4
4
  "description": "Shared module between Web & Mobile containing required abstractions for state management, async network communication. ",
5
5
  "main": "lib/index.js",
6
6
  "module": "lib/esm/index.js",