@zeniai/client-epic-state 5.1.41 → 5.1.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/esm/index.js CHANGED
@@ -395,9 +395,9 @@ import { getAllTopExpenses, getTop6Expenses, getTopExpenses, } from './view/topE
395
395
  */
396
396
  import { TOP_EX_TIME_PERIODS, } from './view/topEx/topExState';
397
397
  import { getAccountingProviderAttachment, } from './view/transactionDetail/getAccountingProviderAttachmentSelector';
398
- import { JOURNAL_ENTRY_SORT_KEYS, filterJournalEntryLinesExcludingLinked, journalEntryDefaultSortConfig, journalEntryTotalsByPostingSide, sortJournalEntryLines, sumJournalEntryAmounts, toJournalEntrySortKey, } from './view/transactionDetail/journalEntryLinesViewModel';
398
+ import { JOURNAL_ENTRY_SORT_KEYS, filterJournalEntryLinesExcludingLinked, journalEntryDefaultSortConfig, journalEntrySplitReconciliation, journalEntryTotalsByPostingSide, sortJournalEntryLines, sumJournalEntryAmounts, toJournalEntrySortKey, } from './view/transactionDetail/journalEntryLinesViewModel';
399
399
  import { getChangedLineItemCountFromLocalData, isAccountUncategorized, } from './view/transactionDetail/transactionDetailLocalDataHelper';
400
- import { clearTransactionDetailSaveStatus, deleteTransactionAttachment, downloadAccountingProviderAttachment, downloadAccountingProviderAttachmentFailure, downloadAccountingProviderAttachmentSuccess, fetchTransactionDetail, initializeTransactionDetailLocalData, markCategoryClassRecommendationsFailureForTransactionDetail, removeEntityRecommendationForLineIdForTransactionDetail, resetAllLineItemsToVendor, resetLineItemsCategoryClassInLocalData, resetSelectedCustomer, resetVendor, saveTransactionDetail, saveTransactionDetailLocalData, setAllLineItemsLocalDataIsUpdateAllChecked, setAllLineItemsToCategoryClassInLocalData, setIsVendorUpdateAllChecked, updateLatestSelectedEntity, updateLocalDataWithTransactionsUpdateWithVendorCheckbox, updateSelectedCustomer, updateSelectedVendor, updateTransactionDetail, updateVendorBulkRecommendationsFetchStatus, updateVendorToAllLineItems, uploadMissingAttachmentSuccess, } from './view/transactionDetail/transactionDetailReducer';
400
+ import { addSplitLine, clearTransactionDetailSaveStatus, deleteSplitLine, deleteTransactionAttachment, downloadAccountingProviderAttachment, downloadAccountingProviderAttachmentFailure, downloadAccountingProviderAttachmentSuccess, fetchTransactionDetail, initializeTransactionDetailLocalData, markCategoryClassRecommendationsFailureForTransactionDetail, removeEntityRecommendationForLineIdForTransactionDetail, resetAllLineItemsToVendor, resetLineItemsCategoryClassInLocalData, resetSelectedCustomer, resetVendor, saveTransactionDetail, saveTransactionDetailLocalData, setAllLineItemsLocalDataIsUpdateAllChecked, setAllLineItemsToCategoryClassInLocalData, setIsVendorUpdateAllChecked, toggleSplitEditMode, updateLatestSelectedEntity, updateLocalDataWithTransactionsUpdateWithVendorCheckbox, updateSelectedCustomer, updateSelectedVendor, updateTransactionDetail, updateVendorBulkRecommendationsFetchStatus, updateVendorToAllLineItems, uploadMissingAttachmentSuccess, } from './view/transactionDetail/transactionDetailReducer';
401
401
  import { getInitialUpdatablePeriodsForTransactionRecommendations, getTransactionDetail, getTransactionDetailForTransaction, } from './view/transactionDetail/transactionDetailSelector';
402
402
  import { getThirdPartyIdFromTransactionId, } from './view/transactionDetail/transactionDetailState';
403
403
  import { getTransactionListByAccount, getTransactionListUIStateByAccountKey, } from './view/transactionList/transactionListByAccountSelector';
@@ -516,7 +516,7 @@ export { isTransferTransaction };
516
516
  export { getSupportedTransactionById, };
517
517
  export { clearTransactionList, updateLatestTransactionId, parallelFetchAccountTransactionList, parallelFetchEntityTransactionList, fetchAccountTransactionList, updateTransactionListByAccount, updateTransactionListByAccountFailure, updateTransactionListByAccountUIState, parallelFetchClassTransactionList, updateTransactionListByClassUIState, updateTransactionListByClass, parallelFetchProjectTransactionList, updateTransactionListByProjectUIState, updateTransactionListByProject, updateTransactionListUIStateByCategoryType, parallelFetchTransactionListByCategoryType, };
518
518
  export { getTransactionListByAccount, getTransactionListUIStateByAccountKey, };
519
- export { fetchEntityTransactionList, updateTransactionListByEntity, saveTransactionDetailLocalData, clearTransactionDetailSaveStatus, updateTransactionListByEntityFailure, resetLineItemsCategoryClassInLocalData, setAllLineItemsToCategoryClassInLocalData, saveTransactionDetail, setAllLineItemsLocalDataIsUpdateAllChecked, initializeTransactionDetailLocalData, markCategoryClassRecommendationsFailureForTransactionDetail, removeEntityRecommendationForLineIdForTransactionDetail, updateSelectedVendor, updateSelectedCustomer, resetSelectedCustomer, resetVendor, updateLocalDataWithTransactionsUpdateWithVendorCheckbox, updateVendorToAllLineItems, resetAllLineItemsToVendor, updateLatestSelectedEntity, updateVendorBulkRecommendationsFetchStatus, setIsVendorUpdateAllChecked, };
519
+ export { fetchEntityTransactionList, updateTransactionListByEntity, saveTransactionDetailLocalData, clearTransactionDetailSaveStatus, updateTransactionListByEntityFailure, resetLineItemsCategoryClassInLocalData, setAllLineItemsToCategoryClassInLocalData, saveTransactionDetail, setAllLineItemsLocalDataIsUpdateAllChecked, initializeTransactionDetailLocalData, markCategoryClassRecommendationsFailureForTransactionDetail, removeEntityRecommendationForLineIdForTransactionDetail, updateSelectedVendor, updateSelectedCustomer, resetSelectedCustomer, resetVendor, updateLocalDataWithTransactionsUpdateWithVendorCheckbox, updateVendorToAllLineItems, resetAllLineItemsToVendor, updateLatestSelectedEntity, updateVendorBulkRecommendationsFetchStatus, setIsVendorUpdateAllChecked, addSplitLine, deleteSplitLine, toggleSplitEditMode, };
520
520
  export { downloadAccountingProviderAttachment, downloadAccountingProviderAttachmentSuccess, downloadAccountingProviderAttachmentFailure, fetchTransactionDetail, updateTransactionDetail, uploadMissingAttachmentSuccess, deleteTransactionAttachment, getThirdPartyIdFromTransactionId, };
521
521
  export { getTransactionListByEntity };
522
522
  export { getTransactionListByClass, getTransactionListUIStateByClassKey, getTransactionListByProject, getTransactionListUIStateByProjectKey, getTransactionListByEntityForSinglePeriod, };
@@ -595,7 +595,7 @@ export { getNextNthWorkingDay, getNextTransferDate, getPreviousNthWorkingDay, is
595
595
  export { fetchCompanyOnboardingView, fetchQBOConnectionPool, updateQBOConnectionPoolExternalConnection, fetchOnboardingCompletedCompanies, saveOnboardingCustomerCompletedStatus, toProductType, toProductTypeStrict, getOnboardingCockpitView, getNewOnboardingCustomerView, getOnboardingEmailGroup, initializeOnboardingCustomerViewUpdateData, clearOnboardingCustomerViewUpdateData, saveOnboardingCustomerViewUpdateData, updateOnboardingCustomerListUIState, updateCustomerCreationStatus, updateStatusAfterOnboardingCompleted, saveOnboardingCustomerViewUpdates, saveOnboardingCustomerNotes, saveOnboardingCustomerDataInLocalStore, updateOnboardingCustomerDataInLocalStore, resetNewOnboardedCustomerId, sendOnboardingCustomerViewInvite, retryBankAccountConnectionForOnboarding, };
596
596
  export { getTransactionsListByCategoryType, getTransactionListUIStateByCategoryType, };
597
597
  export { getChangedLineItemCountFromLocalData, isAccountUncategorized };
598
- export { JOURNAL_ENTRY_SORT_KEYS, filterJournalEntryLinesExcludingLinked, journalEntryDefaultSortConfig, journalEntryTotalsByPostingSide, sortJournalEntryLines, sumJournalEntryAmounts, toJournalEntrySortKey, };
598
+ export { JOURNAL_ENTRY_SORT_KEYS, filterJournalEntryLinesExcludingLinked, journalEntryDefaultSortConfig, journalEntryTotalsByPostingSide, journalEntrySplitReconciliation, sortJournalEntryLines, sumJournalEntryAmounts, toJournalEntrySortKey, };
599
599
  export { openSnackbar, closeSnackbar, getSnackbar, };
600
600
  export { getSpendManagementEffectiveListPeriod, getSelectedCompanyOfficer, showBillPayPromoPage, showReimbursementPromoPage, getActualPaymentDate, isVerifiedStatusCode, isVerifiedPaymentAccountProviderStatusCode, isBulkProcessing, getBillListUniqueType, getRemiListUniqueType, getBulkOperationSuffix, toAccountsPromoConfig, toSameDayAchDisablementConfig, isPaymentMethodOutsideZeni, isAwaitingMarkAsPaid, checkIfLowBalance, };
601
601
  export { canSendMonthEndEmailReport, shouldEnableCalendarPickerForLastReportSent, };
@@ -168,6 +168,13 @@ const expenseAutomationTransactionsView = createSlice({
168
168
  if (searchString !== undefined) {
169
169
  draft.transactionCategorizationView[selectedTab].uiState.searchString =
170
170
  searchString;
171
+ // When search is cleared, drop all sub-tab snapshots. A snapshot
172
+ // captured while search was active holds filtered row IDs — restoring
173
+ // it after the clear would show stale search results instead of the
174
+ // full unfiltered list.
175
+ if (searchString === '' && selectedTab === 'autoCategorized') {
176
+ draft.autoCategorizedSubTabCache = {};
177
+ }
171
178
  }
172
179
  draft.transactionCategorizationView[selectedTab].error = undefined;
173
180
  const monthYearPeriod = {
@@ -45,7 +45,15 @@ export const toTransactionUpdatesFromTransactionDetailLocalData = (transactionDe
45
45
  let lastUpdatedCategory;
46
46
  let lastUpdatedClass;
47
47
  // single recommendation to just pick the last recommendation in the list of lines
48
+ const existingLineIds = new Set(transaction.lines?.map((l) => l.id) ?? []);
49
+ const deletedLineIds = new Set((transactionDetailLocalData.deletedLineIds ?? []).filter((id) => existingLineIds.has(id)));
50
+ // Collect new lines — in lineItemById but not yet in transaction.lines.
51
+ const addedLines = Object.values(transactionDetailLocalData.lineItemById).filter((item) => !existingLineIds.has(item.id));
48
52
  transaction.lines?.forEach((line) => {
53
+ // Skip lines the user deleted in edit mode.
54
+ if (deletedLineIds.has(line.id)) {
55
+ return;
56
+ }
49
57
  const lineItem = transactionDetailLocalData.lineItemById[line.id];
50
58
  if (line.type === 'transaction_with_account_and_class_line' ||
51
59
  line.type === 'transaction_with_product_or_service_line' ||
@@ -56,6 +64,7 @@ export const toTransactionUpdatesFromTransactionDetailLocalData = (transactionDe
56
64
  const entityDetails = getEntityDetails(selectedEntity, lineItem, transactionDetailLocalData.vendorUpdates, transactionDetailLocalData.customerUpdates);
57
65
  updatesByLineId[line.id] = {
58
66
  lineDescription: lineItem.lineDescription,
67
+ amount: lineItem.amount,
59
68
  class: lineItem.class,
60
69
  account: lineItem.account,
61
70
  // Project is at the transaction level for invoice/credit_memo; omit from lines.
@@ -77,6 +86,17 @@ export const toTransactionUpdatesFromTransactionDetailLocalData = (transactionDe
77
86
  currentSelectedEntityLine.type === 'vendor'
78
87
  ? lineItem.vendor
79
88
  : undefined,
89
+ // Carry the JE debit/credit side so a split edit that flips an
90
+ // existing line's posting side persists through the line patch.
91
+ ...(line.type === 'journal_entry_transaction_line'
92
+ ? {
93
+ postingType: lineItem.postingType,
94
+ postingTypeName: lineItem.postingType != null
95
+ ? lineItem.postingType.charAt(0).toUpperCase() +
96
+ lineItem.postingType.slice(1)
97
+ : lineItem.postingTypeName,
98
+ }
99
+ : {}),
80
100
  };
81
101
  }
82
102
  let hasCategoryChanged = false;
@@ -163,6 +183,8 @@ export const toTransactionUpdatesFromTransactionDetailLocalData = (transactionDe
163
183
  and all the line items are NOT uncategorized*/
164
184
  markAsMiscatUpdates: isTransactionMisCategorized,
165
185
  anyBulkUpdates: isBulkUpdate,
186
+ addedLines: addedLines.length > 0 ? addedLines : undefined,
187
+ deletedLineIds: deletedLineIds.size > 0 ? [...deletedLineIds] : undefined,
166
188
  };
167
189
  };
168
190
  const doUpdateEntityUpdates = (entityUpdates, entityId, entityType, recommendation, isBulkUpdate = false, updatedEntityId, entityName) => {
@@ -58,7 +58,9 @@ export const updateTransactionDetailEpic = (actions$, state$, zeniAPI) => action
58
58
  if (response.data.vendors != null) {
59
59
  actions.push(updateVendors(response.data.vendors, 'merge'));
60
60
  }
61
- actions.push(updateTransaction(transactionDetailKeyForResponse, response.data.transaction));
61
+ // 'replace' so deleted lines are removed from Redux; 'merge' would
62
+ // keep any line absent from the server response (deletion bug).
63
+ actions.push(updateTransaction(transactionDetailKeyForResponse, response.data.transaction, 'replace'));
62
64
  actions.push(initializeTransactionDetailLocalData({
63
65
  transactionId: transactionIdResp,
64
66
  vendorId: response.data.vendors?.[0]?.vendor_id,
@@ -159,7 +161,7 @@ function preparePayload(transactionState, vendorState, transactionDetailState, t
159
161
  };
160
162
  }
161
163
  if (detail != null) {
162
- const transUpdates = toTransactionUpdatePayload(saveUpdates.updates, detail, cotTransactionTracking, isAccountingProjectsEnabled, vendorUpdates, customerUpdates, currentSelectedEntity);
164
+ const transUpdates = toTransactionUpdatePayload(saveUpdates.updates, detail, cotTransactionTracking, isAccountingProjectsEnabled, vendorUpdates, customerUpdates, currentSelectedEntity, saveUpdates.deletedLineIds, saveUpdates.addedLines);
163
165
  const entityUpdates = toEntityUpdatePayload(saveUpdates.entityRelatedUpdates);
164
166
  return {
165
167
  entity_related_updates: entityUpdates,
@@ -169,7 +171,7 @@ function preparePayload(transactionState, vendorState, transactionDetailState, t
169
171
  }
170
172
  return undefined;
171
173
  }
172
- export const toTransactionUpdatePayload = (updates, detail, cotTransactionTracking, isAccountingProjectsEnabled, vendorUpdates, customerUpdates, tranSelectedEntity) => {
174
+ export const toTransactionUpdatePayload = (updates, detail, cotTransactionTracking, isAccountingProjectsEnabled, vendorUpdates, customerUpdates, tranSelectedEntity, deletedLineIds, addedLines) => {
173
175
  const transaction = Object.assign({}, detail);
174
176
  transaction.memo = updates.memo ?? '';
175
177
  // Unlike `memo` (always reset to '' when absent), `documentId` falls back to
@@ -178,6 +180,11 @@ export const toTransactionUpdatePayload = (updates, detail, cotTransactionTracki
178
180
  // either the unchanged current value or undefined, and we must not send an
179
181
  // empty `document_id` that the server would treat as clearing the number.
180
182
  transaction.documentId = updates.documentId ?? transaction.documentId;
183
+ // Remove lines deleted by the user in edit mode before patching or serializing.
184
+ if (deletedLineIds != null && deletedLineIds.length > 0) {
185
+ const deletedSet = new Set(deletedLineIds);
186
+ transaction.lines = transaction.lines?.filter((line) => !deletedSet.has(line.id));
187
+ }
181
188
  const recommendationUpdates = {};
182
189
  for (const key in updates.updatesByLineId) {
183
190
  const lineUpdates = updates.updatesByLineId[key];
@@ -185,6 +192,9 @@ export const toTransactionUpdatePayload = (updates, detail, cotTransactionTracki
185
192
  if (line.id === key) {
186
193
  const lineTobeUpdated = Object.assign({}, line);
187
194
  lineTobeUpdated.lineDescription = lineUpdates.lineDescription ?? '';
195
+ if (lineUpdates.amount != null) {
196
+ lineTobeUpdated.amount = lineUpdates.amount;
197
+ }
188
198
  if (lineUpdates.class != null) {
189
199
  lineTobeUpdated.class = lineUpdates.class;
190
200
  }
@@ -206,6 +216,16 @@ export const toTransactionUpdatePayload = (updates, detail, cotTransactionTracki
206
216
  else {
207
217
  lineTobeUpdated.vendor = undefined;
208
218
  }
219
+ // Persist a debit↔credit flip on an existing JE line. `lineTobeUpdated`
220
+ // is typed as the account-and-class shape, but for a JE the runtime
221
+ // object is a JournalEntryTransactionLine, so cast to set the side.
222
+ if (lineUpdates.postingType != null) {
223
+ const journalEntryLine = lineTobeUpdated;
224
+ journalEntryLine.postingType = lineUpdates.postingType;
225
+ if (lineUpdates.postingTypeName != null) {
226
+ journalEntryLine.postingTypeName = lineUpdates.postingTypeName;
227
+ }
228
+ }
209
229
  if (lineUpdates.recommendation != null) {
210
230
  recommendationUpdates[line.id] = lineUpdates.recommendation;
211
231
  }
@@ -225,6 +245,12 @@ export const toTransactionUpdatePayload = (updates, detail, cotTransactionTracki
225
245
  });
226
246
  transaction.lines = newLineMap;
227
247
  }
248
+ // Append lines added by the user in edit mode. Sending id='' tells the
249
+ // backend to create a new QBO line rather than updating an existing one.
250
+ if (addedLines != null && addedLines.length > 0) {
251
+ const newLines = addedLines.map(toNewTransactionLine);
252
+ transaction.lines = [...(transaction.lines ?? []), ...newLines];
253
+ }
228
254
  transaction.recommendations = recommendationUpdates;
229
255
  // Convert transaction to payload first
230
256
  let basePayload;
@@ -299,6 +325,52 @@ const checkIfVendorOrCustomerMissingForAnyLineItem = (transactionPayload) => {
299
325
  }
300
326
  return isAnyVendorOrCustomerMissing;
301
327
  };
328
+ // Converts a locally-created line item (from edit mode) into the wire shape
329
+ // expected by the categorize endpoint. Sending id='' signals the backend to
330
+ // create a new QBO line rather than update an existing one.
331
+ //
332
+ // Type-dispatched so each transaction family contributes its own new-line
333
+ // shape: journal entries carry debit/credit `postingType`, expenses carry
334
+ // `billable`. Phase 3 types extend this by adding a branch rather than forking
335
+ // the function.
336
+ const EMPTY_ACCOUNT = {
337
+ accountDescription: '',
338
+ accountId: '',
339
+ accountName: '',
340
+ labels: [],
341
+ };
342
+ export const toNewTransactionLine = (item) => {
343
+ if (item.type === 'journal_entry_transaction_line') {
344
+ const journalEntryLine = {
345
+ ...item,
346
+ id: '',
347
+ type: 'journal_entry_transaction_line',
348
+ platformLineDetailType: item.platformLineDetailType ?? 'journal_entry_line_detail',
349
+ account: item.account ?? EMPTY_ACCOUNT,
350
+ billable: item.billable ?? 'not_billable',
351
+ isCategoryMiscategorized: false,
352
+ isClassMiscategorized: false,
353
+ lineTransactionType: item.lineTransactionType ?? 'journal_entry',
354
+ postingType: item.postingType ?? 'debit',
355
+ postingTypeName: item.postingTypeName != null && item.postingTypeName !== ''
356
+ ? item.postingTypeName
357
+ : (item.postingType ?? 'debit').charAt(0).toUpperCase() +
358
+ (item.postingType ?? 'debit').slice(1),
359
+ };
360
+ return journalEntryLine;
361
+ }
362
+ const accountAndClassLine = {
363
+ ...item,
364
+ id: '',
365
+ type: 'transaction_with_account_and_class_line',
366
+ platformLineDetailType: item.platformLineDetailType ?? 'account_based_expense_line_detail',
367
+ account: item.account ?? EMPTY_ACCOUNT,
368
+ billable: item.billable ?? 'not_billable',
369
+ isCategoryMiscategorized: false,
370
+ isClassMiscategorized: false,
371
+ };
372
+ return accountAndClassLine;
373
+ };
302
374
  const toEntityUpdatePayload = (entityUpdates) => {
303
375
  const entityUpdatesPayload = [];
304
376
  let entityKey;
@@ -1,3 +1,4 @@
1
+ import Big from 'big.js';
1
2
  import orderBy from 'lodash/orderBy';
2
3
  import { stringToUnion } from '../../commonStateTypes/stringToUnion';
3
4
  import { LINE_TYPE_LINKED, } from '../../entity/transaction/stateTypes/transactionLine';
@@ -101,6 +102,63 @@ export const journalEntryTotalsByPostingSide = (lines) => {
101
102
  };
102
103
  };
103
104
  // ---------------------------------------------------------------------------
105
+ // Split reconciliation & validation (Phase 2 — JE split)
106
+ // ---------------------------------------------------------------------------
107
+ // Half-cent tolerance for the balance equality checks, matching the expense
108
+ // reconciliation row's epsilon. Sums use big.js so repeated decimal addition
109
+ // doesn't drift before the comparison.
110
+ const JOURNAL_ENTRY_BALANCE_EPSILON = 0.005;
111
+ /**
112
+ * Sums debit and credit sides (big.js-exact) and reports whether the split is
113
+ * balanced: total debits equal total credits AND equal the original
114
+ * transaction total. The original total of a valid JE equals its total debits,
115
+ * so it is passed in by the caller (`transaction.amount.amount`).
116
+ */
117
+ export const journalEntrySplitReconciliation = (lines, originalTotal) => {
118
+ let debit = new Big(0);
119
+ let credit = new Big(0);
120
+ for (const line of lines) {
121
+ const amount = new Big(line.amount?.amount ?? 0);
122
+ if (line.postingType === 'debit') {
123
+ debit = debit.plus(amount);
124
+ }
125
+ else if (line.postingType === 'credit') {
126
+ credit = credit.plus(amount);
127
+ }
128
+ }
129
+ const balancedToEachOther = debit
130
+ .minus(credit)
131
+ .abs()
132
+ .lt(JOURNAL_ENTRY_BALANCE_EPSILON);
133
+ const balancedToOriginal = debit
134
+ .minus(originalTotal)
135
+ .abs()
136
+ .lt(JOURNAL_ENTRY_BALANCE_EPSILON);
137
+ return {
138
+ debitTotal: debit.toNumber(),
139
+ creditTotal: credit.toNumber(),
140
+ isBalanced: balancedToEachOther && balancedToOriginal,
141
+ };
142
+ };
143
+ /**
144
+ * Save-time validation for a JE split (Figma `4165:33028` / `4165:45379`):
145
+ * 1. every line needs a non-zero debit or credit, and
146
+ * 2. total debits == total credits == original total.
147
+ * Returns the first failing rule's `errorKey` (maps to a strings entry). Does
148
+ * NOT require category/class to be chosen — balance is the only gate.
149
+ */
150
+ export const validateJournalEntrySplit = (lines, originalTotal) => {
151
+ const everyLineHasSide = lines.every((line) => (line.postingType === 'debit' || line.postingType === 'credit') &&
152
+ Math.abs(line.amount?.amount ?? 0) > JOURNAL_ENTRY_BALANCE_EPSILON);
153
+ if (!everyLineHasSide) {
154
+ return { ok: false, errorKey: 'lineNeedsDebitOrCredit' };
155
+ }
156
+ if (!journalEntrySplitReconciliation(lines, originalTotal).isBalanced) {
157
+ return { ok: false, errorKey: 'totalsMustEqual' };
158
+ }
159
+ return { ok: true };
160
+ };
161
+ // ---------------------------------------------------------------------------
104
162
  // Filter
105
163
  // ---------------------------------------------------------------------------
106
164
  /**
@@ -823,14 +823,16 @@ export const getChangedLineItemCountFromLocalData = (transaction, transactionLoc
823
823
  line.type === 'transaction_with_product_or_service_line' ||
824
824
  line.type === 'journal_entry_transaction_line') {
825
825
  const accountLineLocal = transactionLocalData.lineItemById[lineId];
826
- const accountLine = line;
827
- hasCategoryChanged =
828
- accountLineLocal.account?.qboId !== accountLine.account.qboId &&
829
- accountLineLocal.account?.accountId !== accountLine.account.accountId;
830
- hasClassChanged =
831
- (accountLineLocal.class?.qboId != null ||
832
- accountLine.class?.qboId != null) &&
833
- accountLineLocal.class?.qboId !== accountLine.class?.qboId;
826
+ if (accountLineLocal != null) {
827
+ const accountLine = line;
828
+ hasCategoryChanged =
829
+ accountLineLocal.account?.qboId !== accountLine.account.qboId &&
830
+ accountLineLocal.account?.accountId !== accountLine.account.accountId;
831
+ hasClassChanged =
832
+ (accountLineLocal.class?.qboId != null ||
833
+ accountLine.class?.qboId != null) &&
834
+ accountLineLocal.class?.qboId !== accountLine.class?.qboId;
835
+ }
834
836
  }
835
837
  hasDescriptionChanged =
836
838
  transactionLocalData.lineItemById[lineId] != null
@@ -663,6 +663,71 @@ const transactionDetail = createSlice({
663
663
  };
664
664
  },
665
665
  },
666
+ // Split transaction — Phase 1 (expense)
667
+ addSplitLine: {
668
+ reducer(draft, action) {
669
+ const key = getTransactionDetailKey(action.payload.transactionId);
670
+ const detail = recordGet(draft.transactionDetailById, key, undefined);
671
+ if (detail != null) {
672
+ draft.transactionDetailById[key] = {
673
+ ...detail,
674
+ transactionDetailLocalData: {
675
+ ...detail.transactionDetailLocalData,
676
+ lineItemById: {
677
+ ...detail.transactionDetailLocalData.lineItemById,
678
+ [action.payload.newLine.id]: action.payload.newLine,
679
+ },
680
+ },
681
+ };
682
+ }
683
+ },
684
+ prepare(transactionId, newLine) {
685
+ return { payload: { transactionId, newLine } };
686
+ },
687
+ },
688
+ deleteSplitLine: {
689
+ reducer(draft, action) {
690
+ const key = getTransactionDetailKey(action.payload.transactionId);
691
+ const detail = recordGet(draft.transactionDetailById, key, undefined);
692
+ if (detail == null) {
693
+ return;
694
+ }
695
+ const { lineItemById, deletedLineIds = [] } = detail.transactionDetailLocalData;
696
+ const updated = { ...lineItemById };
697
+ delete updated[action.payload.lineId];
698
+ draft.transactionDetailById[key] = {
699
+ ...detail,
700
+ transactionDetailLocalData: {
701
+ ...detail.transactionDetailLocalData,
702
+ lineItemById: updated,
703
+ deletedLineIds: [...deletedLineIds, action.payload.lineId],
704
+ },
705
+ };
706
+ },
707
+ prepare(transactionId, lineId) {
708
+ return { payload: { transactionId, lineId } };
709
+ },
710
+ },
711
+ toggleSplitEditMode: {
712
+ reducer(draft, action) {
713
+ const key = getTransactionDetailKey(action.payload.transactionId);
714
+ const detail = recordGet(draft.transactionDetailById, key, undefined);
715
+ if (detail != null) {
716
+ draft.transactionDetailById[key] = {
717
+ ...detail,
718
+ transactionDetailLocalData: {
719
+ ...detail.transactionDetailLocalData,
720
+ editMode: action.payload.editMode,
721
+ // Clear delete tracking when exiting edit mode without saving.
722
+ ...(!action.payload.editMode && { deletedLineIds: [] }),
723
+ },
724
+ };
725
+ }
726
+ },
727
+ prepare(transactionId, editMode) {
728
+ return { payload: { transactionId, editMode } };
729
+ },
730
+ },
666
731
  clearRecommendedVendorsList(draft) {
667
732
  Object.assign(draft, initialState);
668
733
  },
@@ -771,7 +836,7 @@ const transactionDetail = createSlice({
771
836
  },
772
837
  },
773
838
  });
774
- export const { downloadAccountingProviderAttachment, downloadAccountingProviderAttachmentSuccess, downloadAccountingProviderAttachmentFailure, fetchTransactionDetail, updateTransactionDetailFetchState, removeTransactionDetail, clearTransactionDetail, uploadMissingAttachmentSuccess, updateTransactionDetail, updateTransactionDetailSaveStatus, clearTransactionDetailSaveStatus, markTransactionAsNotMiscategorized, deleteTransactionAttachment, deleteTransactionAttachmentFailure, deleteTransactionAttachmentSuccess, updateStatusForTransactionNotMiscategorizedUpdate, updateStatusConsolidatedSaveTransactionDetail, updateTransactionStateIfUpdateActionNotAllowed, saveTransactionDetail, initializeTransactionDetailLocalData, saveTransactionDetailLocalData, clearTransactionDetailLocalData, resetLineItemsCategoryClassInLocalData, setAllLineItemsToCategoryClassInLocalData, setAllLineItemsLocalDataIsUpdateAllChecked, setEntityRecommendationForLineIdsForTransactionDetail, markCategoryClassRecommendationsInProgressForTransactionDetail, markCategoryClassRecommendationsCompletedForTransactionDetail, markCategoryClassRecommendationsFailureForTransactionDetail, removeEntityRecommendationForLineIdForTransactionDetail, updateSelectedVendor, updateSelectedCustomer, resetVendor, resetSelectedCustomer, updateLocalDataWithTransactionsUpdateWithVendorCheckbox, updateVendorToAllLineItems, resetAllLineItemsToVendor, updateLatestSelectedEntity, updateVendorBulkRecommendationsFetchStatus, setIsVendorUpdateAllChecked, } = transactionDetail.actions;
839
+ export const { downloadAccountingProviderAttachment, downloadAccountingProviderAttachmentSuccess, downloadAccountingProviderAttachmentFailure, fetchTransactionDetail, updateTransactionDetailFetchState, removeTransactionDetail, clearTransactionDetail, uploadMissingAttachmentSuccess, updateTransactionDetail, updateTransactionDetailSaveStatus, clearTransactionDetailSaveStatus, markTransactionAsNotMiscategorized, deleteTransactionAttachment, deleteTransactionAttachmentFailure, deleteTransactionAttachmentSuccess, updateStatusForTransactionNotMiscategorizedUpdate, updateStatusConsolidatedSaveTransactionDetail, updateTransactionStateIfUpdateActionNotAllowed, saveTransactionDetail, initializeTransactionDetailLocalData, saveTransactionDetailLocalData, clearTransactionDetailLocalData, resetLineItemsCategoryClassInLocalData, setAllLineItemsToCategoryClassInLocalData, setAllLineItemsLocalDataIsUpdateAllChecked, setEntityRecommendationForLineIdsForTransactionDetail, markCategoryClassRecommendationsInProgressForTransactionDetail, markCategoryClassRecommendationsCompletedForTransactionDetail, markCategoryClassRecommendationsFailureForTransactionDetail, removeEntityRecommendationForLineIdForTransactionDetail, updateSelectedVendor, updateSelectedCustomer, resetVendor, resetSelectedCustomer, updateLocalDataWithTransactionsUpdateWithVendorCheckbox, updateVendorToAllLineItems, resetAllLineItemsToVendor, updateLatestSelectedEntity, updateVendorBulkRecommendationsFetchStatus, setIsVendorUpdateAllChecked, addSplitLine, deleteSplitLine, toggleSplitEditMode, } = transactionDetail.actions;
775
840
  export default transactionDetail.reducer;
776
841
  function getUpdatedEntityLocalData(detail, updates, entity, resetRecommendations, type) {
777
842
  const key = type === 'customer' ? 'customerUpdates' : 'vendorUpdates';
package/lib/index.d.ts CHANGED
@@ -582,9 +582,9 @@ import { TopExAccount, TopExReport, TopExTimePeriodWithMetaData, TopExpenses } f
582
582
  */
583
583
  import { TOP_EX_TIME_PERIODS, TopExTimePeriod, TopExpense } from './view/topEx/topExState';
584
584
  import { AccountingProviderAttachmentSelector, getAccountingProviderAttachment } from './view/transactionDetail/getAccountingProviderAttachmentSelector';
585
- import { JOURNAL_ENTRY_SORT_KEYS, JournalEntryRowSortConfig, JournalEntrySortAccessors, JournalEntrySortKey, filterJournalEntryLinesExcludingLinked, journalEntryDefaultSortConfig, journalEntryTotalsByPostingSide, sortJournalEntryLines, sumJournalEntryAmounts, toJournalEntrySortKey } from './view/transactionDetail/journalEntryLinesViewModel';
585
+ import { JOURNAL_ENTRY_SORT_KEYS, JournalEntryRowSortConfig, JournalEntrySortAccessors, JournalEntrySortKey, filterJournalEntryLinesExcludingLinked, journalEntryDefaultSortConfig, journalEntrySplitReconciliation, journalEntryTotalsByPostingSide, sortJournalEntryLines, sumJournalEntryAmounts, toJournalEntrySortKey } from './view/transactionDetail/journalEntryLinesViewModel';
586
586
  import { getChangedLineItemCountFromLocalData, isAccountUncategorized } from './view/transactionDetail/transactionDetailLocalDataHelper';
587
- import { clearTransactionDetailSaveStatus, deleteTransactionAttachment, downloadAccountingProviderAttachment, downloadAccountingProviderAttachmentFailure, downloadAccountingProviderAttachmentSuccess, fetchTransactionDetail, initializeTransactionDetailLocalData, markCategoryClassRecommendationsFailureForTransactionDetail, removeEntityRecommendationForLineIdForTransactionDetail, resetAllLineItemsToVendor, resetLineItemsCategoryClassInLocalData, resetSelectedCustomer, resetVendor, saveTransactionDetail, saveTransactionDetailLocalData, setAllLineItemsLocalDataIsUpdateAllChecked, setAllLineItemsToCategoryClassInLocalData, setIsVendorUpdateAllChecked, updateLatestSelectedEntity, updateLocalDataWithTransactionsUpdateWithVendorCheckbox, updateSelectedCustomer, updateSelectedVendor, updateTransactionDetail, updateVendorBulkRecommendationsFetchStatus, updateVendorToAllLineItems, uploadMissingAttachmentSuccess } from './view/transactionDetail/transactionDetailReducer';
587
+ import { addSplitLine, clearTransactionDetailSaveStatus, deleteSplitLine, deleteTransactionAttachment, downloadAccountingProviderAttachment, downloadAccountingProviderAttachmentFailure, downloadAccountingProviderAttachmentSuccess, fetchTransactionDetail, initializeTransactionDetailLocalData, markCategoryClassRecommendationsFailureForTransactionDetail, removeEntityRecommendationForLineIdForTransactionDetail, resetAllLineItemsToVendor, resetLineItemsCategoryClassInLocalData, resetSelectedCustomer, resetVendor, saveTransactionDetail, saveTransactionDetailLocalData, setAllLineItemsLocalDataIsUpdateAllChecked, setAllLineItemsToCategoryClassInLocalData, setIsVendorUpdateAllChecked, toggleSplitEditMode, updateLatestSelectedEntity, updateLocalDataWithTransactionsUpdateWithVendorCheckbox, updateSelectedCustomer, updateSelectedVendor, updateTransactionDetail, updateVendorBulkRecommendationsFetchStatus, updateVendorToAllLineItems, uploadMissingAttachmentSuccess } from './view/transactionDetail/transactionDetailReducer';
588
588
  import { TransactionDetailReport, VendorDetailsView, getInitialUpdatablePeriodsForTransactionRecommendations, getTransactionDetail, getTransactionDetailForTransaction } from './view/transactionDetail/transactionDetailSelector';
589
589
  import { TransactionUpdates, getThirdPartyIdFromTransactionId } from './view/transactionDetail/transactionDetailState';
590
590
  import { TransactionDetailLineItemData, TransactionDetailLocalData, TransactionVendorUpdates } from './view/transactionDetail/transactionDetailTypes';
@@ -743,7 +743,7 @@ export { MatchedTransaction };
743
743
  export { SupportedTransaction, SupportedTransactionWithCOT, SupportedTransactionPayload, getSupportedTransactionById, };
744
744
  export { clearTransactionList, updateLatestTransactionId, parallelFetchAccountTransactionList, parallelFetchEntityTransactionList, fetchAccountTransactionList, updateTransactionListByAccount, updateTransactionListByAccountFailure, updateTransactionListByAccountUIState, parallelFetchClassTransactionList, updateTransactionListByClassUIState, updateTransactionListByClass, parallelFetchProjectTransactionList, updateTransactionListByProjectUIState, updateTransactionListByProject, updateTransactionListUIStateByCategoryType, parallelFetchTransactionListByCategoryType, };
745
745
  export { TransactionListByAccountReport, getTransactionListByAccount, getTransactionListUIStateByAccountKey, };
746
- export { fetchEntityTransactionList, updateTransactionListByEntity, saveTransactionDetailLocalData, clearTransactionDetailSaveStatus, updateTransactionListByEntityFailure, resetLineItemsCategoryClassInLocalData, setAllLineItemsToCategoryClassInLocalData, saveTransactionDetail, setAllLineItemsLocalDataIsUpdateAllChecked, initializeTransactionDetailLocalData, markCategoryClassRecommendationsFailureForTransactionDetail, removeEntityRecommendationForLineIdForTransactionDetail, updateSelectedVendor, updateSelectedCustomer, resetSelectedCustomer, resetVendor, updateLocalDataWithTransactionsUpdateWithVendorCheckbox, updateVendorToAllLineItems, resetAllLineItemsToVendor, updateLatestSelectedEntity, updateVendorBulkRecommendationsFetchStatus, setIsVendorUpdateAllChecked, };
746
+ export { fetchEntityTransactionList, updateTransactionListByEntity, saveTransactionDetailLocalData, clearTransactionDetailSaveStatus, updateTransactionListByEntityFailure, resetLineItemsCategoryClassInLocalData, setAllLineItemsToCategoryClassInLocalData, saveTransactionDetail, setAllLineItemsLocalDataIsUpdateAllChecked, initializeTransactionDetailLocalData, markCategoryClassRecommendationsFailureForTransactionDetail, removeEntityRecommendationForLineIdForTransactionDetail, updateSelectedVendor, updateSelectedCustomer, resetSelectedCustomer, resetVendor, updateLocalDataWithTransactionsUpdateWithVendorCheckbox, updateVendorToAllLineItems, resetAllLineItemsToVendor, updateLatestSelectedEntity, updateVendorBulkRecommendationsFetchStatus, setIsVendorUpdateAllChecked, addSplitLine, deleteSplitLine, toggleSplitEditMode, };
747
747
  export { TransactionUpdates, downloadAccountingProviderAttachment, downloadAccountingProviderAttachmentSuccess, downloadAccountingProviderAttachmentFailure, fetchTransactionDetail, updateTransactionDetail, uploadMissingAttachmentSuccess, deleteTransactionAttachment, getThirdPartyIdFromTransactionId, };
748
748
  export { TransactionListByEntityView, getTransactionListByEntity };
749
749
  export { TransactionListByClassReport, getTransactionListByClass, getTransactionListUIStateByClassKey, TransactionListByProjectReport, getTransactionListByProject, getTransactionListUIStateByProjectKey, getTransactionListByEntityForSinglePeriod, };
@@ -843,7 +843,7 @@ export { fetchCompanyOnboardingView, fetchQBOConnectionPool, updateQBOConnection
843
843
  export { TransactionListByCategoryType, getTransactionsListByCategoryType, getTransactionListUIStateByCategoryType, TransactionCategoryType, };
844
844
  export { TransactionDetailLocalData, TransactionDetailLineItemData, TransactionVendorUpdates, };
845
845
  export { getChangedLineItemCountFromLocalData, isAccountUncategorized };
846
- export { JOURNAL_ENTRY_SORT_KEYS, filterJournalEntryLinesExcludingLinked, journalEntryDefaultSortConfig, journalEntryTotalsByPostingSide, sortJournalEntryLines, sumJournalEntryAmounts, toJournalEntrySortKey, };
846
+ export { JOURNAL_ENTRY_SORT_KEYS, filterJournalEntryLinesExcludingLinked, journalEntryDefaultSortConfig, journalEntryTotalsByPostingSide, journalEntrySplitReconciliation, sortJournalEntryLines, sumJournalEntryAmounts, toJournalEntrySortKey, };
847
847
  export type { JournalEntryRowSortConfig, JournalEntrySortAccessors, JournalEntrySortKey, };
848
848
  export { openSnackbar, closeSnackbar, getSnackbar, SnackbarMessageType, SnackbarMessageSections, SnackbarMessageSectionTexts, SnackbarMessageConfig, };
849
849
  export { getSpendManagementEffectiveListPeriod, getSelectedCompanyOfficer, showBillPayPromoPage, showReimbursementPromoPage, getActualPaymentDate, isVerifiedStatusCode, isVerifiedPaymentAccountProviderStatusCode, isBulkProcessing, getBillListUniqueType, getRemiListUniqueType, BulkOperationType, BulkOperationFinishedType, getBulkOperationSuffix, ZeniAccountsPromoConfig, toAccountsPromoConfig, SameDayAchDisablementConfig, toSameDayAchDisablementConfig, isPaymentMethodOutsideZeni, isAwaitingMarkAsPaid, checkIfLowBalance, };