@zeniai/client-epic-state 5.0.90-betaAR2 → 5.0.90-betaAR4

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,15 +91,20 @@ 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. 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.
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 (see the V1 fixture in
97
+ * 'entity/entityApprovalStatus/__mocks__/json/entityApprovalStatusPayload.json'):
98
+ * criteria: [{type: 'range', range_entity: 'currency', min, max,
99
+ * currency_code, currency_symbol}]
99
100
  *
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`.
101
+ * Open-ended semantics are preserved on the wire: an absent 'min' or
102
+ * 'max' is sent as 'null' rather than synthesised to '0' or
103
+ * 'MAX_SAFE_INTEGER', so a 'greater_than' / 'less_than' rule stays
104
+ * one-sided after rollback rather than collapsing into a closed range.
105
+ *
106
+ * The V1 backend does not understand vendor / department criteria or
107
+ * the rule-level 3.0 fields, so those are dropped on the wire.
103
108
  */
104
109
  export const toApprovalChangableInfoPayloadV1 = (approverViewUpdateData) => {
105
110
  const amount = approverViewUpdateData.criteria?.amount;
@@ -125,25 +130,18 @@ export const toApprovalChangableInfoPayloadV1 = (approverViewUpdateData) => {
125
130
  };
126
131
  });
127
132
  const isUpdate = approverViewUpdateData.approvalRuleId != null;
133
+ const criteriaEntry = {
134
+ type: 'range',
135
+ range_entity: 'currency',
136
+ min: minAmount?.amount ?? null,
137
+ max: maxAmount?.amount ?? null,
138
+ currency_code: currencyCode,
139
+ currency_symbol: currencySymbol,
140
+ };
128
141
  const payload = {
129
142
  steps,
130
143
  is_applicable_on_pending_approval_entity: approverViewUpdateData.isApplicableOnPendingApprovalEntity ?? true,
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
+ criteria: [criteriaEntry],
147
145
  };
148
146
  if (isUpdate) {
149
147
  payload.approval_rule_id = approverViewUpdateData.approvalRuleId;
@@ -1,6 +1,6 @@
1
1
  import recordGet from 'lodash/get';
2
2
  import has from 'lodash/has';
3
- import { getAmountCriteria } from '../../../../entity/approvalRule/approvalRuleSelector';
3
+ import { getAmountCriteria, getDepartmentCriteria, getVendorCriteria, } from '../../../../entity/approvalRule/approvalRuleSelector';
4
4
  import { reduceFetchState } from '../../../../commonStateTypes/reduceFetchState';
5
5
  import { getBankAccountByBankAccountId, getBankAccountsByBankAccountIds, } from '../../../../entity/bankAccount/bankAccountSelector';
6
6
  import { getContactById, getContactsByIds, } from '../../../../entity/billPay/contact/contactSelector';
@@ -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,
@@ -679,16 +687,59 @@ export const getDefaultWithdrawFromAccount = (state, depositAccounts, companyId,
679
687
  }
680
688
  return undefined;
681
689
  };
682
- export const checkIfCreatorIsApprover = (billAmount, approvalRules, signedInUser) => {
690
+ export const checkIfCreatorIsApprover = (billAmount, approvalRules, signedInUser, billVendorId, billDepartmentId) => {
683
691
  let isCreatorAlsoApprover = false;
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.
684
701
  const approvalRule = approvalRules.find((rule) => {
702
+ const vendorCriteria = getVendorCriteria(rule.criteria);
703
+ if (vendorCriteria != null) {
704
+ if (billVendorId == null) {
705
+ return false;
706
+ }
707
+ const includesVendor = vendorCriteria.vendorIds.includes(billVendorId);
708
+ if (vendorCriteria.operator === 'is' && !includesVendor) {
709
+ return false;
710
+ }
711
+ if (vendorCriteria.operator === 'is_not' && includesVendor) {
712
+ return false;
713
+ }
714
+ }
715
+ const departmentCriteria = getDepartmentCriteria(rule.criteria);
716
+ if (departmentCriteria != null) {
717
+ if (billDepartmentId == null) {
718
+ return false;
719
+ }
720
+ const includesDepartment = departmentCriteria.departmentIds.includes(billDepartmentId);
721
+ if (departmentCriteria.operator === 'is' && !includesDepartment) {
722
+ return false;
723
+ }
724
+ if (departmentCriteria.operator === 'is_not' && includesDepartment) {
725
+ return false;
726
+ }
727
+ }
685
728
  const amountCriteria = getAmountCriteria(rule.criteria);
686
- if (amountCriteria?.min == null) {
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.
687
734
  return false;
688
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.
689
739
  const { min, max } = amountCriteria;
690
- return (billAmount > min.amount &&
691
- (max?.amount != null ? billAmount <= max.amount : true));
740
+ const matchesMin = min == null || billAmount >= min.amount;
741
+ const matchesMax = max == null || billAmount <= max.amount;
742
+ return matchesMin && matchesMax;
692
743
  });
693
744
  if (approvalRule != null) {
694
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,42 +30,51 @@ 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 `{rangeType, rangeEntity, range}`
35
- * criteria. None of the 3.0 rule-level fields are sent.
33
+ * 'approval_rule_v3_config' dynamic config) can target the original
34
+ * '/approval-rules' endpoint with the legacy snake_case array of
35
+ * range entries. None of the 3.0 rule-level fields are sent.
36
+ *
37
+ * Reference: 'entityApprovalStatusPayload.json' fixture, which carries
38
+ * a real V1 approval rule from the legacy backend:
39
+ * criteria: [{type: 'range', range_entity: 'currency',
40
+ * min, max, currency_code, currency_symbol}]
41
+ * with 'min' / 'max' as scalar numbers and 'null' representing an
42
+ * absent bound for one-sided rules. The read-side mapper in
43
+ * 'approvalRulePayload.ts' (see 'LegacyAmountRangeCriterionPayload')
44
+ * decodes the same shape — write-side has to match.
36
45
  */
46
+ interface ApprovalUpdatableInfoPayloadV1CriterionEntry {
47
+ currency_code: string;
48
+ currency_symbol: string;
49
+ /** Upper bound. 'null' for a 'greater_than' (open-upper) rule. */
50
+ max: number | null;
51
+ /** Lower bound. 'null' for a 'less_than' (open-lower) rule. */
52
+ min: number | null;
53
+ range_entity: 'currency';
54
+ type: 'range';
55
+ }
37
56
  interface ApprovalUpdatableInfoPayloadV1 {
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
- };
57
+ criteria: ApprovalUpdatableInfoPayloadV1CriterionEntry[];
54
58
  is_applicable_on_pending_approval_entity: boolean;
55
59
  steps: ApproverViewStepPayload[];
56
60
  approval_rule_id?: ID;
57
61
  }
58
62
  export declare const toApprovalChangableInfoPayload: (approverViewUpdateData: ApprovalRuleUpdateData | ApprovalRuleCreateData) => Partial<ApprovalUpdatableInfoPayload>;
59
63
  /**
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.
64
+ * V1 wire mapper — used when 'approval_rule_v3_config' resolves to OFF
65
+ * for the current tenant. Emits the legacy snake_case array shape that
66
+ * '/approval-rules' historically consumed (see the V1 fixture in
67
+ * 'entity/entityApprovalStatus/__mocks__/json/entityApprovalStatusPayload.json'):
68
+ * criteria: [{type: 'range', range_entity: 'currency', min, max,
69
+ * currency_code, currency_symbol}]
70
+ *
71
+ * Open-ended semantics are preserved on the wire: an absent 'min' or
72
+ * 'max' is sent as 'null' rather than synthesised to '0' or
73
+ * 'MAX_SAFE_INTEGER', so a 'greater_than' / 'less_than' rule stays
74
+ * one-sided after rollback rather than collapsing into a closed range.
65
75
  *
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`.
76
+ * The V1 backend does not understand vendor / department criteria or
77
+ * the rule-level 3.0 fields, so those are dropped on the wire.
69
78
  */
70
79
  export declare const toApprovalChangableInfoPayloadV1: (approverViewUpdateData: ApprovalRuleUpdateData | ApprovalRuleCreateData) => Partial<ApprovalUpdatableInfoPayloadV1>;
71
80
  export {};
@@ -95,15 +95,20 @@ 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. 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.
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 (see the V1 fixture in
101
+ * 'entity/entityApprovalStatus/__mocks__/json/entityApprovalStatusPayload.json'):
102
+ * criteria: [{type: 'range', range_entity: 'currency', min, max,
103
+ * currency_code, currency_symbol}]
103
104
  *
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`.
105
+ * Open-ended semantics are preserved on the wire: an absent 'min' or
106
+ * 'max' is sent as 'null' rather than synthesised to '0' or
107
+ * 'MAX_SAFE_INTEGER', so a 'greater_than' / 'less_than' rule stays
108
+ * one-sided after rollback rather than collapsing into a closed range.
109
+ *
110
+ * The V1 backend does not understand vendor / department criteria or
111
+ * the rule-level 3.0 fields, so those are dropped on the wire.
107
112
  */
108
113
  const toApprovalChangableInfoPayloadV1 = (approverViewUpdateData) => {
109
114
  const amount = approverViewUpdateData.criteria?.amount;
@@ -129,25 +134,18 @@ const toApprovalChangableInfoPayloadV1 = (approverViewUpdateData) => {
129
134
  };
130
135
  });
131
136
  const isUpdate = approverViewUpdateData.approvalRuleId != null;
137
+ const criteriaEntry = {
138
+ type: 'range',
139
+ range_entity: 'currency',
140
+ min: minAmount?.amount ?? null,
141
+ max: maxAmount?.amount ?? null,
142
+ currency_code: currencyCode,
143
+ currency_symbol: currencySymbol,
144
+ };
132
145
  const payload = {
133
146
  steps,
134
147
  is_applicable_on_pending_approval_entity: approverViewUpdateData.isApplicableOnPendingApprovalEntity ?? true,
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
+ criteria: [criteriaEntry],
151
149
  };
152
150
  if (isUpdate) {
153
151
  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;
@@ -157,4 +164,4 @@ export declare const getDefaultWithdrawFromAccount: (state: RootState, depositAc
157
164
  accountId: ID;
158
165
  accountType: AccountTypeSubConfigCodeKeyType;
159
166
  }) => FundingAccount | undefined;
160
- export declare const checkIfCreatorIsApprover: (billAmount: number, approvalRules: ApprovalRuleWithUser[], signedInUser?: LoggedInUser) => boolean;
167
+ export declare const checkIfCreatorIsApprover: (billAmount: number, approvalRules: ApprovalRuleWithUser[], signedInUser?: LoggedInUser, billVendorId?: ID, billDepartmentId?: ID) => boolean;
@@ -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,
@@ -696,16 +704,59 @@ const getDefaultWithdrawFromAccount = (state, depositAccounts, companyId, lastSe
696
704
  return undefined;
697
705
  };
698
706
  exports.getDefaultWithdrawFromAccount = getDefaultWithdrawFromAccount;
699
- const checkIfCreatorIsApprover = (billAmount, approvalRules, signedInUser) => {
707
+ const checkIfCreatorIsApprover = (billAmount, approvalRules, signedInUser, billVendorId, billDepartmentId) => {
700
708
  let isCreatorAlsoApprover = false;
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.
701
718
  const approvalRule = approvalRules.find((rule) => {
719
+ const vendorCriteria = (0, approvalRuleSelector_1.getVendorCriteria)(rule.criteria);
720
+ if (vendorCriteria != null) {
721
+ if (billVendorId == null) {
722
+ return false;
723
+ }
724
+ const includesVendor = vendorCriteria.vendorIds.includes(billVendorId);
725
+ if (vendorCriteria.operator === 'is' && !includesVendor) {
726
+ return false;
727
+ }
728
+ if (vendorCriteria.operator === 'is_not' && includesVendor) {
729
+ return false;
730
+ }
731
+ }
732
+ const departmentCriteria = (0, approvalRuleSelector_1.getDepartmentCriteria)(rule.criteria);
733
+ if (departmentCriteria != null) {
734
+ if (billDepartmentId == null) {
735
+ return false;
736
+ }
737
+ const includesDepartment = departmentCriteria.departmentIds.includes(billDepartmentId);
738
+ if (departmentCriteria.operator === 'is' && !includesDepartment) {
739
+ return false;
740
+ }
741
+ if (departmentCriteria.operator === 'is_not' && includesDepartment) {
742
+ return false;
743
+ }
744
+ }
702
745
  const amountCriteria = (0, approvalRuleSelector_1.getAmountCriteria)(rule.criteria);
703
- if (amountCriteria?.min == null) {
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.
704
751
  return false;
705
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.
706
756
  const { min, max } = amountCriteria;
707
- return (billAmount > min.amount &&
708
- (max?.amount != null ? billAmount <= max.amount : true));
757
+ const matchesMin = min == null || billAmount >= min.amount;
758
+ const matchesMax = max == null || billAmount <= max.amount;
759
+ return matchesMin && matchesMax;
709
760
  });
710
761
  if (approvalRule != null) {
711
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-betaAR2",
3
+ "version": "5.0.90-betaAR4",
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",