@zeniai/client-epic-state 5.0.90-betaAR2 → 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.
@@ -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.
@@ -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-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",