@zeniai/client-epic-state 5.2.41 → 5.2.42-betaAK0

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.
Files changed (103) hide show
  1. package/lib/entity/actualMatching/actualMatchingPayload.d.ts +31 -0
  2. package/lib/entity/actualMatching/actualMatchingPayload.js +52 -0
  3. package/lib/entity/actualMatching/actualMatchingReducer.d.ts +9 -0
  4. package/lib/entity/actualMatching/actualMatchingReducer.js +33 -0
  5. package/lib/entity/actualMatching/actualMatchingSelector.d.ts +3 -0
  6. package/lib/entity/actualMatching/actualMatchingSelector.js +6 -0
  7. package/lib/entity/actualMatching/actualMatchingState.d.ts +23 -0
  8. package/lib/entity/actualMatching/actualMatchingState.js +2 -0
  9. package/lib/entity/actualMatching/actualMatchingTypes.d.ts +1 -0
  10. package/lib/entity/actualMatching/actualMatchingTypes.js +2 -0
  11. package/lib/entity/jeSchedules/jeSchedulesPayload.d.ts +31 -0
  12. package/lib/entity/jeSchedules/jeSchedulesPayload.js +53 -0
  13. package/lib/entity/jeSchedules/jeSchedulesState.d.ts +33 -0
  14. package/lib/entity/snackbar/snackbarTypes.d.ts +1 -1
  15. package/lib/entity/snackbar/snackbarTypes.js +2 -0
  16. package/lib/epic.d.ts +8 -1
  17. package/lib/epic.js +14 -0
  18. package/lib/esm/entity/actualMatching/actualMatchingPayload.js +48 -0
  19. package/lib/esm/entity/actualMatching/actualMatchingReducer.js +30 -0
  20. package/lib/esm/entity/actualMatching/actualMatchingSelector.js +3 -0
  21. package/lib/esm/entity/actualMatching/actualMatchingState.js +1 -0
  22. package/lib/esm/entity/actualMatching/actualMatchingTypes.js +1 -0
  23. package/lib/esm/entity/jeSchedules/jeSchedulesPayload.js +53 -0
  24. package/lib/esm/entity/snackbar/snackbarTypes.js +2 -0
  25. package/lib/esm/epic.js +14 -0
  26. package/lib/esm/index.js +7 -3
  27. package/lib/esm/reducer.js +6 -0
  28. package/lib/esm/view/expenseAutomationView/epics/common/fetchAllExpenseAutomationTabsEpic.js +25 -12
  29. package/lib/esm/view/expenseAutomationView/epics/jeSchedule/actualMatching/fetchActualMatchingEpic.js +46 -0
  30. package/lib/esm/view/expenseAutomationView/epics/jeSchedule/actualMatching/fetchActualMatchingMatchEpic.js +36 -0
  31. package/lib/esm/view/expenseAutomationView/epics/jeSchedule/actualMatching/fetchActualMatchingPageEpic.js +7 -0
  32. package/lib/esm/view/expenseAutomationView/epics/jeSchedule/actualMatching/matchAndReverseAccrualsEpic.js +108 -0
  33. package/lib/esm/view/expenseAutomationView/epics/jeSchedule/actualMatching/refreshActualMatchingList.js +16 -0
  34. package/lib/esm/view/expenseAutomationView/epics/jeSchedule/actualMatching/searchMatchAccrualsEpic.js +60 -0
  35. package/lib/esm/view/expenseAutomationView/epics/jeSchedule/actualMatching/searchMatchTransactionsEpic.js +74 -0
  36. package/lib/esm/view/expenseAutomationView/epics/jeSchedule/actualMatching/undoReversalAccrualsEpic.js +78 -0
  37. package/lib/esm/view/expenseAutomationView/epics/jeSchedule/fetchJeSchedulePageEpic.js +13 -3
  38. package/lib/esm/view/expenseAutomationView/epics/jeSchedule/prefetchOtherJeSchedulesTabEpic.js +40 -15
  39. package/lib/esm/view/expenseAutomationView/epics/jeSchedule/switchJESchedulesTabEpic.js +19 -8
  40. package/lib/esm/view/expenseAutomationView/expenseAutomationViewReducer.js +2 -1
  41. package/lib/esm/view/expenseAutomationView/expenseAutomationViewSelector.js +24 -6
  42. package/lib/esm/view/expenseAutomationView/expenseAutomationViewState.js +4 -2
  43. package/lib/esm/view/expenseAutomationView/payload/actualMatchingPayload.js +68 -0
  44. package/lib/esm/view/expenseAutomationView/reducers/actualMatchingViewReducer.js +462 -0
  45. package/lib/esm/view/expenseAutomationView/reducers/jeSchedulesViewReducer.js +2 -1
  46. package/lib/esm/view/expenseAutomationView/selectorTypes/actualMatchingViewSelectorTypes.js +1 -0
  47. package/lib/esm/view/expenseAutomationView/selectors/actualMatchingViewSelector.js +426 -0
  48. package/lib/esm/view/expenseAutomationView/selectors/jeSchedulesViewSelector.js +4 -0
  49. package/lib/esm/view/expenseAutomationView/types/actualMatchingViewState.js +38 -0
  50. package/lib/esm/view/expenseAutomationView/types/jeSchedulesViewState.js +8 -1
  51. package/lib/esm/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewPayload.js +39 -30
  52. package/lib/index.d.ts +14 -5
  53. package/lib/index.js +82 -48
  54. package/lib/reducer.d.ts +2 -0
  55. package/lib/reducer.js +6 -0
  56. package/lib/rootStateTypes.d.ts +4 -0
  57. package/lib/view/expenseAutomationView/epics/common/fetchAllExpenseAutomationTabsEpic.js +25 -12
  58. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/fetchActualMatchingEpic.d.ts +9 -0
  59. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/fetchActualMatchingEpic.js +50 -0
  60. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/fetchActualMatchingMatchEpic.d.ts +12 -0
  61. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/fetchActualMatchingMatchEpic.js +40 -0
  62. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/fetchActualMatchingPageEpic.d.ts +5 -0
  63. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/fetchActualMatchingPageEpic.js +11 -0
  64. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/matchAndReverseAccrualsEpic.d.ts +9 -0
  65. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/matchAndReverseAccrualsEpic.js +112 -0
  66. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/refreshActualMatchingList.d.ts +3 -0
  67. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/refreshActualMatchingList.js +19 -0
  68. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/searchMatchAccrualsEpic.d.ts +9 -0
  69. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/searchMatchAccrualsEpic.js +64 -0
  70. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/searchMatchTransactionsEpic.d.ts +8 -0
  71. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/searchMatchTransactionsEpic.js +78 -0
  72. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/undoReversalAccrualsEpic.d.ts +9 -0
  73. package/lib/view/expenseAutomationView/epics/jeSchedule/actualMatching/undoReversalAccrualsEpic.js +82 -0
  74. package/lib/view/expenseAutomationView/epics/jeSchedule/fetchJeSchedulePageEpic.d.ts +2 -1
  75. package/lib/view/expenseAutomationView/epics/jeSchedule/fetchJeSchedulePageEpic.js +13 -3
  76. package/lib/view/expenseAutomationView/epics/jeSchedule/prefetchOtherJeSchedulesTabEpic.d.ts +5 -1
  77. package/lib/view/expenseAutomationView/epics/jeSchedule/prefetchOtherJeSchedulesTabEpic.js +39 -14
  78. package/lib/view/expenseAutomationView/epics/jeSchedule/switchJESchedulesTabEpic.js +18 -7
  79. package/lib/view/expenseAutomationView/expenseAutomationViewReducer.d.ts +2 -1
  80. package/lib/view/expenseAutomationView/expenseAutomationViewReducer.js +2 -1
  81. package/lib/view/expenseAutomationView/expenseAutomationViewSelector.js +24 -6
  82. package/lib/view/expenseAutomationView/expenseAutomationViewState.d.ts +2 -1
  83. package/lib/view/expenseAutomationView/expenseAutomationViewState.js +6 -3
  84. package/lib/view/expenseAutomationView/helpers/transactionCategorizationLocalDataHelper.d.ts +1 -1
  85. package/lib/view/expenseAutomationView/payload/actualMatchingPayload.d.ts +85 -0
  86. package/lib/view/expenseAutomationView/payload/actualMatchingPayload.js +74 -0
  87. package/lib/view/expenseAutomationView/reducers/actualMatchingViewReducer.d.ts +79 -0
  88. package/lib/view/expenseAutomationView/reducers/actualMatchingViewReducer.js +466 -0
  89. package/lib/view/expenseAutomationView/reducers/jeSchedulesViewReducer.d.ts +6 -5
  90. package/lib/view/expenseAutomationView/reducers/jeSchedulesViewReducer.js +2 -1
  91. package/lib/view/expenseAutomationView/selectorTypes/actualMatchingViewSelectorTypes.d.ts +87 -0
  92. package/lib/view/expenseAutomationView/selectorTypes/actualMatchingViewSelectorTypes.js +2 -0
  93. package/lib/view/expenseAutomationView/selectorTypes/expenseAutomationViewSelectorTypes.d.ts +11 -0
  94. package/lib/view/expenseAutomationView/selectors/actualMatchingViewSelector.d.ts +38 -0
  95. package/lib/view/expenseAutomationView/selectors/actualMatchingViewSelector.js +445 -0
  96. package/lib/view/expenseAutomationView/selectors/jeSchedulesViewSelector.js +4 -0
  97. package/lib/view/expenseAutomationView/types/actualMatchingViewState.d.ts +106 -0
  98. package/lib/view/expenseAutomationView/types/actualMatchingViewState.js +46 -0
  99. package/lib/view/expenseAutomationView/types/jeSchedulesViewState.d.ts +1 -1
  100. package/lib/view/expenseAutomationView/types/jeSchedulesViewState.js +8 -1
  101. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewPayload.d.ts +30 -5
  102. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewPayload.js +39 -30
  103. package/package.json +1 -1
@@ -0,0 +1,426 @@
1
+ import orderBy from 'lodash/orderBy';
2
+ import { toMonthYearPeriodId, } from '../../../commonStateTypes/timePeriod';
3
+ import { getActualMatchingMatchById } from '../../../entity/actualMatching/actualMatchingSelector';
4
+ import { generateJEOneTimeAccrualKey } from '../../../entity/jeSchedules/jeScheduleHelper';
5
+ import { getJEOneTimeAccrualByKey, } from '../../../entity/jeSchedules/jeSchedulesSelector';
6
+ import { getCurrentTenant } from '../../../entity/tenant/tenantSelector';
7
+ import { date } from '../../../zeniDayJS';
8
+ import { actualMatchingTransactionLineKey, } from '../types/actualMatchingViewState';
9
+ export function getSelectedMonthYearForCurrentTenant(state) {
10
+ const tenantId = getCurrentTenant(state)?.tenantId;
11
+ if (tenantId == null) {
12
+ return undefined;
13
+ }
14
+ return state.expenseAutomationViewState.selectedPeriodByTenantId[tenantId];
15
+ }
16
+ export function getTenantDisplayCurrency(state) {
17
+ const locale = getCurrentTenant(state)?.company?.companyLocaleInfo;
18
+ if (locale?.currencyCode == null || locale.currencyCode === '') {
19
+ return undefined;
20
+ }
21
+ return {
22
+ currencyCode: locale.currencyCode,
23
+ currencySymbol: locale.currencySymbol != null && locale.currencySymbol !== ''
24
+ ? locale.currencySymbol
25
+ : locale.currencyCode,
26
+ };
27
+ }
28
+ function toMatchModalAccrual(member) {
29
+ if ('jeOneTimeAccrualKey' in member && member.jeOneTimeAccrualKey != null) {
30
+ return member;
31
+ }
32
+ return {
33
+ ...member,
34
+ jeOneTimeAccrualKey: generateJEOneTimeAccrualKey(member.accrualId),
35
+ };
36
+ }
37
+ function uniqueMatchModalAccruals(pinned, searched) {
38
+ const byId = new Map();
39
+ pinned.forEach((row) => byId.set(row.accrualId, row));
40
+ searched.forEach((row) => {
41
+ if (!byId.has(row.accrualId)) {
42
+ byId.set(row.accrualId, row);
43
+ }
44
+ });
45
+ return Array.from(byId.values());
46
+ }
47
+ function uniqueTransactionSearchResults(pinned, searched) {
48
+ const byKey = new Map();
49
+ pinned.forEach((row) => byKey.set(actualMatchingTransactionLineKey(row.transactionType, row.transactionIntegrationId, row.lineId), row));
50
+ searched.forEach((row) => {
51
+ const key = actualMatchingTransactionLineKey(row.transactionType, row.transactionIntegrationId, row.lineId);
52
+ if (!byKey.has(key)) {
53
+ byKey.set(key, row);
54
+ }
55
+ });
56
+ return Array.from(byKey.values());
57
+ }
58
+ export function parseActualMatchingMatchId(matchId) {
59
+ const separator = matchId.indexOf(':');
60
+ if (separator <= 0 || separator === matchId.length - 1) {
61
+ return null;
62
+ }
63
+ return {
64
+ transactionType: matchId.slice(0, separator).toLowerCase(),
65
+ transactionIntegrationId: matchId.slice(separator + 1),
66
+ };
67
+ }
68
+ const NOT_STARTED = {
69
+ fetchState: 'Not-Started',
70
+ error: undefined,
71
+ };
72
+ export function isAutoReversedAccrual(accrual) {
73
+ return (accrual.accrualStatus === 'reversed' &&
74
+ accrual.reversalTrigger === 'bill_match');
75
+ }
76
+ export function isManuallyReversedAccrual(accrual) {
77
+ return (accrual.accrualStatus === 'reversed' &&
78
+ accrual.reversalTrigger === 'manual_reverse');
79
+ }
80
+ export function isPendingReviewAccrual(accrual) {
81
+ if (accrual.accrualStatus !== 'posted' || accrual.matchSuggestion == null) {
82
+ return false;
83
+ }
84
+ const resolution = accrual.matchSuggestion.resolution;
85
+ return resolution == null || resolution.length === 0;
86
+ }
87
+ export function isReversedMatchKind(kind) {
88
+ return kind === 'auto_reversed' || kind === 'manually_reversed';
89
+ }
90
+ export function getClearedMatchedAmount(accrual) {
91
+ return accrual.cleared?.matchedAmount?.amount;
92
+ }
93
+ export function getActualMatchingAmount(row) {
94
+ return row.actualAmount?.amount ?? row.estimatedAmount.amount;
95
+ }
96
+ function parseMatchTransactionDate(transactionDate) {
97
+ if (transactionDate == null || transactionDate === '') {
98
+ return undefined;
99
+ }
100
+ const parsed = date(transactionDate);
101
+ return Number.isFinite(parsed.valueOf()) ? parsed : undefined;
102
+ }
103
+ export function getActualMatchingReversalDate(accrual, matchTransactionDate) {
104
+ const matchedDate = isPendingReviewAccrual(accrual)
105
+ ? accrual.matchSuggestion.matchedTransactionDate
106
+ : accrual.cleared?.matchedTransactionDate;
107
+ return matchedDate ?? parseMatchTransactionDate(matchTransactionDate);
108
+ }
109
+ function collectGroupedMembers(members) {
110
+ if (members.length <= 1) {
111
+ return undefined;
112
+ }
113
+ return members.map((member) => ({
114
+ accrualId: member.accrualId,
115
+ estimatedAmount: member.estimatedAmount,
116
+ jeDebitAccountName: member.jeDebitAccountName,
117
+ memo: member.memo,
118
+ serviceMonth: member.serviceMonth,
119
+ }));
120
+ }
121
+ function collectLeftoverMembers(leftovers) {
122
+ if (leftovers == null || leftovers.length === 0) {
123
+ return undefined;
124
+ }
125
+ return leftovers.map((member) => ({
126
+ accrualId: member.accrualId,
127
+ estimatedAmount: member.estimatedAmount,
128
+ jeDebitAccountName: member.jeDebitAccountName,
129
+ memo: member.memo,
130
+ serviceMonth: member.serviceMonth,
131
+ }));
132
+ }
133
+ function toActualMatchingRow(match) {
134
+ const firstMember = match.members[0];
135
+ if (firstMember == null) {
136
+ throw new Error('Actual matching row requires at least one member');
137
+ }
138
+ const first = toMatchModalAccrual(firstMember);
139
+ const leftoverMembers = match.leftovers ?? [];
140
+ const fromCleared = firstMember.cleared?.intendedAccrualIds;
141
+ const intendedAccrualIds = fromCleared != null && fromCleared.length > 0
142
+ ? fromCleared
143
+ : [
144
+ ...match.members.map((member) => member.accrualId),
145
+ ...leftoverMembers.map((leftover) => leftover.accrualId),
146
+ ];
147
+ return {
148
+ ...first,
149
+ matchId: match.matchId,
150
+ kind: match.kind,
151
+ accruedAmount: match.accruedAmount,
152
+ actualAmount: match.actualAmount,
153
+ matchTier: match.matchTier,
154
+ matchShape: match.matchShape,
155
+ transactionType: match.transactionType,
156
+ transactionDate: match.transactionDate,
157
+ groupedAccrualIds: match.members.map((member) => member.accrualId),
158
+ groupedMembers: collectGroupedMembers(match.members),
159
+ intendedAccrualIds,
160
+ leftovers: collectLeftoverMembers(match.leftovers),
161
+ reversalDate: getActualMatchingReversalDate(first, match.transactionDate),
162
+ vendorName: match.vendorName != null && match.vendorName !== ''
163
+ ? match.vendorName
164
+ : first.vendorName,
165
+ estimatedAmount: match.accruedAmount,
166
+ };
167
+ }
168
+ function matchesSearch(row, searchString) {
169
+ const trimmed = searchString.trim().toLowerCase();
170
+ if (trimmed.length === 0) {
171
+ return true;
172
+ }
173
+ if ((row.vendorName ?? '').toLowerCase().includes(trimmed)) {
174
+ return true;
175
+ }
176
+ if ((row.memo ?? '').toLowerCase().includes(trimmed)) {
177
+ return true;
178
+ }
179
+ return (row.groupedMembers ?? []).some((member) => (member.memo ?? '').toLowerCase().includes(trimmed));
180
+ }
181
+ function getActualMatchingSortValue(row, sortKey) {
182
+ switch (sortKey) {
183
+ case 'vendor':
184
+ return (row.vendorName ?? '').toLowerCase();
185
+ case 'date': {
186
+ const millis = row.reversalDate?.valueOf() ??
187
+ (row.transactionDate != null && row.transactionDate !== ''
188
+ ? date(row.transactionDate).valueOf()
189
+ : 0);
190
+ return Number.isFinite(millis) ? millis : 0;
191
+ }
192
+ case 'amount':
193
+ return getActualMatchingAmount(row);
194
+ case 'category':
195
+ return row.jeDebitAccountName?.toLowerCase();
196
+ case 'class':
197
+ return row.jeDebitAccountingClassName?.toLowerCase();
198
+ case 'type':
199
+ return row.transactionType?.toLowerCase();
200
+ case 'lifecycle':
201
+ return row.kind;
202
+ case 'service':
203
+ return row.serviceMonth;
204
+ case 'accrued':
205
+ return row.accruedAmount?.amount ?? row.estimatedAmount.amount;
206
+ case 'actual':
207
+ return row.actualAmount?.amount;
208
+ case 'variance': {
209
+ const accrued = row.accruedAmount?.amount ?? row.estimatedAmount.amount;
210
+ const actual = row.actualAmount?.amount;
211
+ if (actual == null || accrued === 0) {
212
+ return undefined;
213
+ }
214
+ return ((actual - accrued) / Math.abs(accrued)) * 100;
215
+ }
216
+ default:
217
+ return undefined;
218
+ }
219
+ }
220
+ function sortActualMatchingRows(rows, uiState) {
221
+ const lodashOrder = uiState.sortOrder === 'ascending' ? 'asc' : 'desc';
222
+ return orderBy(rows, [(row) => getActualMatchingSortValue(row, uiState.sortKey)], [lodashOrder]);
223
+ }
224
+ const ESTIMATED_MINUTES_PER_PENDING_MATCH = 4;
225
+ function emptyKpiSummary() {
226
+ return {
227
+ actualsReceived: { count: 0, expectedCount: 0, vendorsPending: 0 },
228
+ autoReversed: { count: 0, percent: 0, clearedAmount: 0 },
229
+ pendingReview: { count: 0, amount: 0, estimatedMinutesToClear: 0 },
230
+ variance: { percent: 0, averagePercentPerMatch: 0, netAmount: 0 },
231
+ };
232
+ }
233
+ function sumRowAmounts(rows, displayCurrencyCode) {
234
+ return rows.reduce((sum, row) => {
235
+ if (displayCurrencyCode != null &&
236
+ row.accruedAmount.currencyCode !== displayCurrencyCode) {
237
+ return sum;
238
+ }
239
+ return sum + getActualMatchingAmount(row);
240
+ }, 0);
241
+ }
242
+ function isKnownKpiKind(kind) {
243
+ return (kind === 'auto_reversed' ||
244
+ kind === 'pending_review' ||
245
+ kind === 'manually_reversed');
246
+ }
247
+ function rowMatchesDisplayCurrency(row, displayCurrencyCode) {
248
+ if (displayCurrencyCode == null) {
249
+ return true;
250
+ }
251
+ const rowCurrency = row.accruedAmount?.currencyCode ?? row.estimatedAmount.currencyCode;
252
+ return rowCurrency === displayCurrencyCode;
253
+ }
254
+ function uniqueVendorCount(rows) {
255
+ const ids = new Set();
256
+ rows.forEach((row) => {
257
+ if (row.vendorId != null && row.vendorId !== '') {
258
+ ids.add(row.vendorId);
259
+ return;
260
+ }
261
+ if (row.vendorName != null && row.vendorName !== '') {
262
+ ids.add(row.vendorName);
263
+ }
264
+ });
265
+ return ids.size;
266
+ }
267
+ function rowVariancePercent(row) {
268
+ const accrued = row.accruedAmount?.amount ?? row.estimatedAmount.amount;
269
+ const actual = row.actualAmount?.amount;
270
+ if (actual == null || accrued === 0) {
271
+ return undefined;
272
+ }
273
+ return ((actual - accrued) / Math.abs(accrued)) * 100;
274
+ }
275
+ export function buildActualMatchingKpiSummary(rows, displayCurrencyCode) {
276
+ // Strip counts and amounts share this set: known kinds only, and (when a
277
+ // display currency is set) that currency only. Lists still render every row.
278
+ const kpiRows = rows.filter((row) => isKnownKpiKind(row.kind) &&
279
+ rowMatchesDisplayCurrency(row, displayCurrencyCode));
280
+ if (kpiRows.length === 0) {
281
+ return emptyKpiSummary();
282
+ }
283
+ const autoReversed = kpiRows.filter((row) => row.kind === 'auto_reversed');
284
+ const pendingReview = kpiRows.filter((row) => row.kind === 'pending_review');
285
+ const receivedCount = kpiRows.length;
286
+ const rowsWithActual = kpiRows.filter((row) => row.actualAmount?.amount != null);
287
+ const accruedTotal = rowsWithActual.reduce((sum, row) => sum + (row.accruedAmount?.amount ?? row.estimatedAmount.amount), 0);
288
+ const actualTotal = rowsWithActual.reduce((sum, row) => sum + (row.actualAmount?.amount ?? 0), 0);
289
+ const variancePercents = kpiRows
290
+ .map(rowVariancePercent)
291
+ .filter((percent) => percent != null);
292
+ return {
293
+ actualsReceived: {
294
+ count: receivedCount,
295
+ // List API does not return still-expected accruals; hide "of N expected"
296
+ // in the strip until expectedCount is greater than received.
297
+ expectedCount: receivedCount,
298
+ vendorsPending: uniqueVendorCount(pendingReview),
299
+ },
300
+ autoReversed: {
301
+ count: autoReversed.length,
302
+ percent: Math.round((autoReversed.length / receivedCount) * 100),
303
+ clearedAmount: sumRowAmounts(autoReversed, displayCurrencyCode),
304
+ },
305
+ pendingReview: {
306
+ count: pendingReview.length,
307
+ amount: sumRowAmounts(pendingReview, displayCurrencyCode),
308
+ estimatedMinutesToClear: pendingReview.length * ESTIMATED_MINUTES_PER_PENDING_MATCH,
309
+ },
310
+ variance: {
311
+ percent: accruedTotal === 0
312
+ ? 0
313
+ : ((actualTotal - accruedTotal) / Math.abs(accruedTotal)) * 100,
314
+ averagePercentPerMatch: variancePercents.length === 0
315
+ ? 0
316
+ : variancePercents.reduce((sum, percent) => sum + percent, 0) /
317
+ variancePercents.length,
318
+ netAmount: actualTotal - accruedTotal,
319
+ },
320
+ };
321
+ }
322
+ export function getExpenseAutomationActualMatchingMatchView(state, matchId) {
323
+ const view = state.expenseAutomationActualMatchingViewState;
324
+ return {
325
+ match: getActualMatchingMatchById(state.actualMatchingState, matchId),
326
+ detailFetchState: view.detailFetchStateByMatchId[matchId] ?? NOT_STARTED,
327
+ refreshStatus: view.detailRefreshStatusByMatchId[matchId] ?? NOT_STARTED,
328
+ undoReversalStatus: view.undoReversalStatusByMatchId[matchId] ?? NOT_STARTED,
329
+ lastMatchResults: view.lastMatchResultsByMatchId?.[matchId],
330
+ lastUndoResults: view.lastUndoResultsByMatchId?.[matchId],
331
+ };
332
+ }
333
+ export function getCloseMatchAfterUndo(state) {
334
+ return state.expenseAutomationActualMatchingViewState.closeMatchAfterUndo;
335
+ }
336
+ export function getExpenseAutomationActualMatchingView(state) {
337
+ const { expenseAutomationActualMatchingViewState } = state;
338
+ const { matchIdsByPeriod, fetchState, error, refreshStatus, uiState, matchModal, } = expenseAutomationActualMatchingViewState;
339
+ const selectedPeriod = getSelectedMonthYearForCurrentTenant(state);
340
+ const monthYearPeriodId = selectedPeriod != null ? toMonthYearPeriodId(selectedPeriod) : undefined;
341
+ const matchIds = monthYearPeriodId != null ? matchIdsByPeriod[monthYearPeriodId] : undefined;
342
+ const allRows = (matchIds ?? [])
343
+ .map((matchId) => getActualMatchingMatchById(state.actualMatchingState, matchId))
344
+ .filter((match) => match != null)
345
+ .filter((match) => match.members.length > 0)
346
+ .map(toActualMatchingRow);
347
+ const rows = allRows.filter((row) => matchesSearch(row, uiState.searchString));
348
+ const reversed = sortActualMatchingRows(rows.filter((row) => isReversedMatchKind(row.kind)), uiState);
349
+ const pendingReview = sortActualMatchingRows(rows.filter((row) => row.kind === 'pending_review'), uiState);
350
+ const kpiSummary = buildActualMatchingKpiSummary(allRows, getTenantDisplayCurrency(state)?.currencyCode);
351
+ const searchedAccruals = matchModal.accrualSearch.results
352
+ .map((key) => getJEOneTimeAccrualByKey(key, state.jeSchedulesState))
353
+ .filter((accrual) => accrual != null);
354
+ const openedMatch = matchModal.openedFromMatchId != null
355
+ ? getActualMatchingMatchById(state.actualMatchingState, matchModal.openedFromMatchId)
356
+ : undefined;
357
+ const pinnedAccruals = [
358
+ ...(openedMatch?.members ?? []),
359
+ ...(openedMatch?.leftovers ?? []),
360
+ ].map(toMatchModalAccrual);
361
+ const firstMember = openedMatch?.members[0];
362
+ const cleared = firstMember?.cleared;
363
+ const suggestion = firstMember?.matchSuggestion;
364
+ const stamp = cleared ?? suggestion;
365
+ const consumedLineIds = cleared?.consumedLineIds;
366
+ const prefill = openedMatch == null
367
+ ? undefined
368
+ : {
369
+ selectedAccrualIds: [
370
+ ...openedMatch.members.map((member) => member.accrualId),
371
+ ...(openedMatch.leftovers ?? []).map((leftover) => leftover.accrualId),
372
+ ],
373
+ actualAmount: openedMatch.actualAmount.amount,
374
+ matchedTransactionId: stamp?.matchedTransactionId,
375
+ matchedLineId: cleared == null ? suggestion?.matchedLineId : undefined,
376
+ ...(consumedLineIds != null && consumedLineIds.length > 0
377
+ ? { consumedLineIds }
378
+ : {}),
379
+ };
380
+ // `fetchState` is a flat slice field that survives a period switch. Until this
381
+ // month's ids land, leftover Completed/Error is the previous month. Report
382
+ // Not-Started so chrome matches Review's "cache slot missing" skeleton, not
383
+ // empty-complete or a stale error. In-Progress is this month's fetch.
384
+ const fetchStatus = matchIds == null &&
385
+ fetchState !== 'In-Progress' &&
386
+ fetchState !== 'Not-Started'
387
+ ? { fetchState: 'Not-Started', error: undefined }
388
+ : { fetchState, error };
389
+ return {
390
+ fetchState: fetchStatus.fetchState,
391
+ error: fetchStatus.error,
392
+ reversed,
393
+ pendingReview,
394
+ kpiSummary,
395
+ uiState,
396
+ refreshStatus,
397
+ hasLoadedForSelectedPeriod: matchIds != null,
398
+ matchModal: {
399
+ isOpen: matchModal.isOpen,
400
+ openedFromMatchId: matchModal.openedFromMatchId,
401
+ prefill,
402
+ confirmStatus: matchModal.confirmStatus,
403
+ lastBatchResults: matchModal.lastBatchResults,
404
+ transactionSearch: {
405
+ query: matchModal.transactionSearch.query,
406
+ results: uniqueTransactionSearchResults(matchModal.pinnedTransactionResults, matchModal.transactionSearch.results),
407
+ fetchState: {
408
+ fetchState: matchModal.transactionSearch.fetchState,
409
+ error: matchModal.transactionSearch.error,
410
+ },
411
+ nextPageToken: matchModal.transactionSearch.nextPageToken,
412
+ isLoadingMore: matchModal.transactionSearch.isLoadingMore,
413
+ },
414
+ accrualSearch: {
415
+ query: matchModal.accrualSearch.query,
416
+ results: uniqueMatchModalAccruals(pinnedAccruals, searchedAccruals),
417
+ fetchState: {
418
+ fetchState: matchModal.accrualSearch.fetchState,
419
+ error: matchModal.accrualSearch.error,
420
+ },
421
+ nextPageToken: matchModal.accrualSearch.nextPageToken,
422
+ isLoadingMore: matchModal.accrualSearch.isLoadingMore,
423
+ },
424
+ },
425
+ };
426
+ }
@@ -366,6 +366,10 @@ export function getExpenseAutomationJESchedulesView(state) {
366
366
  // reports undefined rather than 0, so the UI can hide the badge instead of asserting "none".
367
367
  const scheduleCountsByTab = {};
368
368
  ALL_JE_PAGE_TABS.forEach((pageTab) => {
369
+ // AM's badge is `actualMatching.kpiSummary`, not schedule row keys.
370
+ if (pageTab === 'actual_matching') {
371
+ return;
372
+ }
369
373
  const periodTabKey = monthYearPeriodId != null
370
374
  ? getJESchedulePeriodTabKey(pageTab, monthYearPeriodId)
371
375
  : null;
@@ -0,0 +1,38 @@
1
+ import { stringToUnion } from '../../../commonStateTypes/stringToUnion';
2
+ const ACTUAL_MATCHING_SORT_KEYS = [
3
+ 'vendor',
4
+ 'date',
5
+ 'amount',
6
+ 'category',
7
+ 'class',
8
+ 'type',
9
+ 'lifecycle',
10
+ 'service',
11
+ 'accrued',
12
+ 'actual',
13
+ 'variance',
14
+ ];
15
+ export const toActualMatchingSortKey = (v) => stringToUnion(v, ACTUAL_MATCHING_SORT_KEYS);
16
+ export const MATCH_TRANSACTION_SEARCH_PAGE_SIZE = 50;
17
+ export const MATCH_ACCRUAL_SEARCH_LIMIT = 50;
18
+ export const MATCH_SEARCH_DEBOUNCE_MS = 300;
19
+ const MATCH_AND_REVERSE_RESULT_STATUSES = [
20
+ 'reversed',
21
+ 'noop',
22
+ 'conflict',
23
+ 'claim_lost',
24
+ 'error',
25
+ ];
26
+ export const toMatchAndReverseResultStatus = (v) => stringToUnion(v, MATCH_AND_REVERSE_RESULT_STATUSES);
27
+ export function isMatchAndReverseCompleteStatus(status) {
28
+ return status === 'reversed' || status === 'noop';
29
+ }
30
+ const UNDO_REVERSAL_RESULT_STATUSES = [
31
+ 'undone',
32
+ 'claim_lost',
33
+ 'error',
34
+ ];
35
+ export const toUndoReversalResultStatus = (v) => stringToUnion(v, UNDO_REVERSAL_RESULT_STATUSES);
36
+ export function actualMatchingTransactionLineKey(transactionType, transactionIntegrationId, lineId) {
37
+ return `${transactionType}:${transactionIntegrationId}:${lineId}`;
38
+ }
@@ -16,7 +16,14 @@ const JE_SCHEDULE_SORT_KEYS = [
16
16
  'memo',
17
17
  ];
18
18
  export const toJEScheduleSortKey = (v) => stringToUnion(v, JE_SCHEDULE_SORT_KEYS);
19
- const JE_PAGE_TABS = ['schedules', 'existing_schedules'];
19
+ // `actual_matching` is chrome only: the JE tab bar includes it, but AM data is
20
+ // `expenseAutomationActualMatchingViewState`, not the schedules period-tab
21
+ // cache below. Walks of ALL_JE_PAGE_TABS that read schedule keys must skip it.
22
+ const JE_PAGE_TABS = [
23
+ 'schedules',
24
+ 'actual_matching',
25
+ 'existing_schedules',
26
+ ];
20
27
  export const toJEPageTab = (v) => stringToUnion(v, JE_PAGE_TABS);
21
28
  export const ALL_JE_PAGE_TABS = JE_PAGE_TABS;
22
29
  export const getJESchedulePeriodTabKey = (selectedPageTab, monthYearPeriodId) => `${selectedPageTab}_${monthYearPeriodId}`;
@@ -3,7 +3,6 @@ import { toClassBasePayload } from '../../../entity/class/classPayload';
3
3
  import { blankInvoicingCatalogSubFamily, isTieredCatalogPricingModel, } from './invoicingCatalogItemFormHelper';
4
4
  const CURRENCY_CODE = 'USD';
5
5
  const trimOrUndefined = (value) => value.trim() !== '' ? value.trim() : undefined;
6
- const trimOrNull = (value) => value.trim() !== '' ? value.trim() : null;
7
6
  const toProductCode = (value) => value
8
7
  .trim()
9
8
  .toLowerCase()
@@ -91,13 +90,24 @@ const buildSubFamilyPayload = (localData, subFamily, clearEmptyValues, lookup) =
91
90
  /** `unit` is the item's unit of measure, not a currency; `CURRENCY_CODE` above prices Price points. */
92
91
  const DEFAULT_CATALOG_ITEM_UNIT = 'USD';
93
92
  /**
94
- * Build the parent catalog-item request body from the form draft. Creates omit
95
- * both parent and price point IDs; edits retain returned price point IDs.
93
+ * Build the parent catalog-item request body from the form draft. Every field
94
+ * the form holds is sent, so a value cleared on the form is cleared on the
95
+ * item: the service reads an absent key as "leave it alone", which is why
96
+ * `description` travels as an empty string and `applicable_items` as an empty
97
+ * list rather than being dropped. Fields the form does not collect
98
+ * (`external_name`, `item_family_id`, `metadata`, …) stay off the body so an
99
+ * edit cannot overwrite them with a value nobody chose.
96
100
  */
97
101
  export const localDataToSaveInvoicingCatalogItemPayload = (localData, isCreate, lookup) => {
98
- const description = trimOrUndefined(localData.description);
99
102
  const productCode = trimOrUndefined(localData.productCode) ?? toProductCode(localData.name);
100
103
  const itemPayload = {
104
+ // `applicable_items` only means anything while the item is restricted;
105
+ // sending the empty list otherwise is what clears a restriction that was
106
+ // just lifted.
107
+ applicable_items: localData.itemApplicability === 'restricted'
108
+ ? localData.applicableItems
109
+ : [],
110
+ description: trimOrUndefined(localData.description) ?? '',
101
111
  enabled_for_checkout: true,
102
112
  enabled_in_portal: true,
103
113
  is_giftable: false,
@@ -107,41 +117,40 @@ export const localDataToSaveInvoicingCatalogItemPayload = (localData, isCreate,
107
117
  name: localData.name.trim(),
108
118
  product_code: productCode,
109
119
  type: localData.catalogType,
120
+ // The form no longer collects a unit; an item that already has one keeps it.
121
+ unit: trimOrUndefined(localData.unit) ?? DEFAULT_CATALOG_ITEM_UNIT,
110
122
  };
111
- if (description != null) {
112
- itemPayload.description = description;
113
- }
114
- // The form no longer collects a unit; an item that already has one keeps it.
115
- itemPayload.unit =
116
- trimOrUndefined(localData.unit) ?? DEFAULT_CATALOG_ITEM_UNIT;
117
- if (localData.itemApplicability === 'restricted' &&
118
- localData.applicableItems.length > 0) {
119
- itemPayload.applicable_items = localData.applicableItems;
123
+ // The external id is assigned by the billing service and shown read-only, so
124
+ // it is echoed back only when the item already has one: sending the blank a
125
+ // create starts with would clear an id assigned in the meantime.
126
+ const externalId = trimOrUndefined(localData.externalId);
127
+ if (externalId != null) {
128
+ itemPayload.external_id = externalId;
120
129
  }
121
130
  const subFamilies = localData.subFamilies.length > 0
122
131
  ? localData.subFamilies
123
132
  : [blankInvoicingCatalogSubFamily()];
124
- itemPayload.sub_family_details = isCreate
133
+ itemPayload.sub_families = isCreate
125
134
  ? subFamilies.map((subFamily) => buildSubFamilyPayload(localData, subFamily, false, lookup))
126
135
  : subFamilies.map((subFamily) => {
127
- const priceCode = trimOrUndefined(subFamily.priceCode) ?? toProductCode(subFamily.name);
128
- const detail = {
129
- description: trimOrNull(subFamily.description),
130
- invoice_notes: trimOrNull(subFamily.invoiceNotes),
131
- name: subFamily.name.trim(),
132
- price_code: priceCode,
133
- show_description_in_invoices: subFamily.showDescriptionInInvoices,
134
- };
135
- // Edits send a narrower body than creates, but the accounting mapping
136
- // is editable on this form, so leaving it out dropped the account and
137
- // class the user had just picked. `null` rather than omitted when it is
138
- // empty: an absent key on an update reads as "leave it alone", which
139
- // would make a cleared mapping impossible to save — the same reason
140
- // `description` and `invoice_notes` go through `trimOrNull`.
136
+ // Edits send the same body a create does. The narrower hand-built one
137
+ // this replaces had to be kept in step by hand and was not: it dropped
138
+ // `accounting_detail` once, and `billing_cycles`, `price`,
139
+ // `pricing_model`, `period`, the trial fields, `proration_type`,
140
+ // `free_quantity`, `is_taxable` and `tiers` were never added at all, so
141
+ // editing any of them looked saved and changed nothing.
142
+ const detail = buildSubFamilyPayload(localData, subFamily, true, lookup);
143
+ // `null` rather than omitted when empty: an absent key on an update
144
+ // reads as "leave it alone", which would make a cleared mapping
145
+ // impossible to save — the same reason `description` and
146
+ // `invoice_notes` are cleared rather than dropped above.
141
147
  detail.accounting_detail =
142
148
  buildAccountingDetail(subFamily, lookup) ?? null;
143
- const id = trimOrUndefined(subFamily.id);
144
- return id != null ? { ...detail, id } : detail;
149
+ // A price point added on this form has no id yet, and travels with a
150
+ // blank one. The update endpoint reads that as "create this one", so it
151
+ // lands in the same request as the edits around it rather than a second
152
+ // call that could fail on its own.
153
+ return { ...detail, id: subFamily.id.trim() };
145
154
  });
146
155
  return itemPayload;
147
156
  };