@zeniai/client-epic-state 5.1.51 → 5.1.52-beta0RD

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.
@@ -788,13 +788,23 @@ export default expenseAutomationReconciliationView.reducer;
788
788
  function isEntityStatementParsing(reconciliation) {
789
789
  return reconciliation.statement_data_status?.code === 'parsing';
790
790
  }
791
+ /** Entity no longer in an active parse — client latch should clear. */
792
+ function isEntityStatementParseTerminal(reconciliation) {
793
+ const statusCode = reconciliation.statement_data_status?.code;
794
+ return (statusCode === 'processed' ||
795
+ statusCode === 'validated' ||
796
+ statusCode === 'parsing_failed' ||
797
+ statusCode === 'validation_failed' ||
798
+ statusCode === 'processing_failed' ||
799
+ statusCode === 'deleting' ||
800
+ statusCode === 'deleted');
801
+ }
791
802
  function resolveStatementParseInProgressAfterFetch(oldRecord, reconciliation) {
792
803
  if (isEntityStatementParsing(reconciliation)) {
793
804
  return true;
794
805
  }
795
- if (oldRecord?.statementParseInProgress === true) {
796
- return true;
797
- }
806
+ // Keep latch while client upload/parse/reparse is actively in flight, even if
807
+ // entity already looks terminal (e.g. V3 parseStatement GET still loading).
798
808
  if (oldRecord?.statementParseStatus.fetchState === 'In-Progress') {
799
809
  return true;
800
810
  }
@@ -804,12 +814,21 @@ function resolveStatementParseInProgressAfterFetch(oldRecord, reconciliation) {
804
814
  if (oldRecord?.statementUploadStatus.fetchState === 'In-Progress') {
805
815
  return true;
806
816
  }
807
- // Reparse POST returned; async parse may not be on entity yet.
817
+ // Reparse POST returned; async parse may not be on entity yet (entity can
818
+ // still show the previous processed status).
808
819
  if (oldRecord?.reparseStatementStatus.fetchState === 'Completed' &&
809
820
  oldRecord.statementParseStatus.fetchState !== 'Completed' &&
810
821
  oldRecord.statementParseStatus.fetchState !== 'Error') {
811
822
  return true;
812
823
  }
824
+ // Entity reports parse finished and no client request is in flight — clear
825
+ // sticky latch. Critical for V1 (no parseStatement) after Pusher refresh.
826
+ if (isEntityStatementParseTerminal(reconciliation)) {
827
+ return false;
828
+ }
829
+ if (oldRecord?.statementParseInProgress === true) {
830
+ return true;
831
+ }
813
832
  // Continue-in-background while parse flow has not finished.
814
833
  if (oldRecord?.statementProcessingDismissedToBackground === true &&
815
834
  oldRecord.statementParseStatus.fetchState !== 'Completed' &&
@@ -951,9 +970,7 @@ function updateReconciliationByAccountID(draft, accounts, selectedPeriod, accoun
951
970
  : false,
952
971
  paymentAccountId: accounts.payment_account_id ?? undefined,
953
972
  };
954
- const oldStatementParseInProgress = oldRecord.statementParseInProgress;
955
- const statementParseInProgress = reconciliationData.bank_status.code === 'parsing' ||
956
- oldStatementParseInProgress === true;
973
+ const statementParseInProgress = resolveStatementParseInProgressAfterFetch(oldRecord, reconciliationData);
957
974
  if (refreshViewInBackground === true) {
958
975
  draft.accountReconciliationRecordsBySelectedPeriod[toMonthYearPeriodId(selectedPeriod)].reconciliationByAccountID[accountId] = {
959
976
  ...oldRecord,
@@ -5,7 +5,7 @@ import { getTransactionWithCOT, getTransactionWithCOTFromRecommendations, } from
5
5
  import { createZeniAPIStatus, isSuccessResponse } from '../../responsePayload';
6
6
  import { getUncategorizedAccounts } from '../accountList/accountListSelector';
7
7
  import { markCategoryClassRecommendationsCompletedForCategorization, markCategoryClassRecommendationsFailureForCategorization, markCategoryClassRecommendationsInProgressForCategorization, setEntityRecommendationForLineIdsForCategorization, } from '../expenseAutomationView/reducers/transactionsViewReducer';
8
- import { markCategoryClassRecommendationsCompletedForTransactionDetail, markCategoryClassRecommendationsFailureForTransactionDetail, markCategoryClassRecommendationsInProgressForTransactionDetail, setEntityRecommendationForLineIdsForTransactionDetail, } from '../transactionDetail/transactionDetailReducer';
8
+ import { markCategoryClassRecommendationsCompletedForTransactionDetail, markCategoryClassRecommendationsFailureForTransactionDetail, markCategoryClassRecommendationsInProgressForTransactionDetail, setEntityRecommendationForLineIdsForTransactionDetail, updateVendorBulkRecommendationsFetchStatus, } from '../transactionDetail/transactionDetailReducer';
9
9
  import { buildRecommendationUrl, getRecommendationByLineIdWithCOTFromRecommendationPayloadWithCOT, } from './recommendationHelper';
10
10
  import { fetchEntityRecommendationsForLineUpdate } from './recommendationReducer';
11
11
  // Close the per-target groupBy bucket after this much idle time so inactive
@@ -38,6 +38,16 @@ export const fetchEntityRecommendationsForLineUpdateEpic = (actions$, state$, ze
38
38
  const payload = action.payload;
39
39
  const { transactionId, transaction, lineIds } = payload.transactionDetails;
40
40
  const { entity, amount, isFetchCOT, isUncategorizedExpenseCategoryEnabled, } = payload;
41
+ // The vendor "Update all lines" popup on the detail page watches
42
+ // its own transaction-level fetch status, separate from the
43
+ // per-line statuses below — surface completion/failure there too
44
+ // when this fetch was triggered by that bulk action, or the popup
45
+ // hangs until its own 60s timeout regardless of how this request
46
+ // resolves.
47
+ const isBulkDetailFetch = payload.flowType === 'detail' && payload.isBulkFetch === true;
48
+ const emitWithBulkStatus = (primaryAction, bulkAction) => isBulkDetailFetch && bulkAction != null
49
+ ? of(primaryAction, bulkAction)
50
+ : of(primaryAction);
41
51
  // Synchronously emit the start arm so the per-line skeleton +
42
52
  // row uncheck happen before the HTTP request begins. The
43
53
  // discriminated-union payload narrows `payload.selectedTab` to
@@ -51,9 +61,9 @@ export const fetchEntityRecommendationsForLineUpdateEpic = (actions$, state$, ze
51
61
  .getJSON(url)
52
62
  .pipe(mergeMap((response) => {
53
63
  if (!isSuccessResponse(response) || response.data == null) {
54
- return of(payload.flowType === 'categorization'
64
+ return emitWithBulkStatus(payload.flowType === 'categorization'
55
65
  ? markCategoryClassRecommendationsFailureForCategorization(payload.selectedTab, transactionId, lineIds, response.status)
56
- : markCategoryClassRecommendationsFailureForTransactionDetail(transactionId, lineIds, response.status));
66
+ : markCategoryClassRecommendationsFailureForTransactionDetail(transactionId, lineIds, response.status), updateVendorBulkRecommendationsFetchStatus(transactionId, 'Error', response.status));
57
67
  }
58
68
  const recommendationWithCOTByLineId = getRecommendationByLineIdWithCOTFromRecommendationPayloadWithCOT(response.data.recommendations, lineIds);
59
69
  // HTTP 200 with no per-line recommendations: NOT an error.
@@ -61,9 +71,9 @@ export const fetchEntityRecommendationsForLineUpdateEpic = (actions$, state$, ze
61
71
  // downstream UI can treat this as "we tried, nothing to
62
72
  // apply".
63
73
  if (recommendationWithCOTByLineId == null) {
64
- return of(payload.flowType === 'categorization'
74
+ return emitWithBulkStatus(payload.flowType === 'categorization'
65
75
  ? markCategoryClassRecommendationsCompletedForCategorization(payload.selectedTab, transactionId, lineIds)
66
- : markCategoryClassRecommendationsCompletedForTransactionDetail(transactionId, lineIds));
76
+ : markCategoryClassRecommendationsCompletedForTransactionDetail(transactionId, lineIds), updateVendorBulkRecommendationsFetchStatus(transactionId, 'Completed'));
67
77
  }
68
78
  const transactionWithCOT = isFetchCOT === true
69
79
  ? getTransactionWithCOTFromRecommendations(transaction, recommendationWithCOTByLineId)
@@ -72,16 +82,16 @@ export const fetchEntityRecommendationsForLineUpdateEpic = (actions$, state$, ze
72
82
  if (payload.flowType === 'categorization') {
73
83
  return of(setEntityRecommendationForLineIdsForCategorization(payload.selectedTab, transactionId, transactionWithCOT, lineIds, entity, amount, recommendationWithCOTByLineId, uncategorizedIncomeExpense, isUncategorizedExpenseCategoryEnabled, getIsAccountingClassesEnabled(state$.value)));
74
84
  }
75
- return of(setEntityRecommendationForLineIdsForTransactionDetail(transactionId, transactionWithCOT, lineIds, entity, amount, recommendationWithCOTByLineId, uncategorizedIncomeExpense));
85
+ return emitWithBulkStatus(setEntityRecommendationForLineIdsForTransactionDetail(transactionId, transactionWithCOT, lineIds, entity, amount, recommendationWithCOTByLineId, uncategorizedIncomeExpense), updateVendorBulkRecommendationsFetchStatus(transactionId, 'Completed'));
76
86
  }), catchError((error) => {
77
87
  const errorMessage = 'fetchEntityRecommendationsForLineUpdate failed: ' +
78
88
  (error instanceof Error
79
89
  ? error.message
80
90
  : JSON.stringify(error));
81
91
  const status = createZeniAPIStatus('Unexpected Error', errorMessage);
82
- return of(payload.flowType === 'categorization'
92
+ return emitWithBulkStatus(payload.flowType === 'categorization'
83
93
  ? markCategoryClassRecommendationsFailureForCategorization(payload.selectedTab, transactionId, lineIds, status)
84
- : markCategoryClassRecommendationsFailureForTransactionDetail(transactionId, lineIds, status));
94
+ : markCategoryClassRecommendationsFailureForTransactionDetail(transactionId, lineIds, status), updateVendorBulkRecommendationsFetchStatus(transactionId, 'Error', status));
85
95
  }));
86
96
  return concat(of(startAction), httpStream$);
87
97
  }))));
@@ -793,13 +793,23 @@ exports.default = expenseAutomationReconciliationView.reducer;
793
793
  function isEntityStatementParsing(reconciliation) {
794
794
  return reconciliation.statement_data_status?.code === 'parsing';
795
795
  }
796
+ /** Entity no longer in an active parse — client latch should clear. */
797
+ function isEntityStatementParseTerminal(reconciliation) {
798
+ const statusCode = reconciliation.statement_data_status?.code;
799
+ return (statusCode === 'processed' ||
800
+ statusCode === 'validated' ||
801
+ statusCode === 'parsing_failed' ||
802
+ statusCode === 'validation_failed' ||
803
+ statusCode === 'processing_failed' ||
804
+ statusCode === 'deleting' ||
805
+ statusCode === 'deleted');
806
+ }
796
807
  function resolveStatementParseInProgressAfterFetch(oldRecord, reconciliation) {
797
808
  if (isEntityStatementParsing(reconciliation)) {
798
809
  return true;
799
810
  }
800
- if (oldRecord?.statementParseInProgress === true) {
801
- return true;
802
- }
811
+ // Keep latch while client upload/parse/reparse is actively in flight, even if
812
+ // entity already looks terminal (e.g. V3 parseStatement GET still loading).
803
813
  if (oldRecord?.statementParseStatus.fetchState === 'In-Progress') {
804
814
  return true;
805
815
  }
@@ -809,12 +819,21 @@ function resolveStatementParseInProgressAfterFetch(oldRecord, reconciliation) {
809
819
  if (oldRecord?.statementUploadStatus.fetchState === 'In-Progress') {
810
820
  return true;
811
821
  }
812
- // Reparse POST returned; async parse may not be on entity yet.
822
+ // Reparse POST returned; async parse may not be on entity yet (entity can
823
+ // still show the previous processed status).
813
824
  if (oldRecord?.reparseStatementStatus.fetchState === 'Completed' &&
814
825
  oldRecord.statementParseStatus.fetchState !== 'Completed' &&
815
826
  oldRecord.statementParseStatus.fetchState !== 'Error') {
816
827
  return true;
817
828
  }
829
+ // Entity reports parse finished and no client request is in flight — clear
830
+ // sticky latch. Critical for V1 (no parseStatement) after Pusher refresh.
831
+ if (isEntityStatementParseTerminal(reconciliation)) {
832
+ return false;
833
+ }
834
+ if (oldRecord?.statementParseInProgress === true) {
835
+ return true;
836
+ }
818
837
  // Continue-in-background while parse flow has not finished.
819
838
  if (oldRecord?.statementProcessingDismissedToBackground === true &&
820
839
  oldRecord.statementParseStatus.fetchState !== 'Completed' &&
@@ -956,9 +975,7 @@ function updateReconciliationByAccountID(draft, accounts, selectedPeriod, accoun
956
975
  : false,
957
976
  paymentAccountId: accounts.payment_account_id ?? undefined,
958
977
  };
959
- const oldStatementParseInProgress = oldRecord.statementParseInProgress;
960
- const statementParseInProgress = reconciliationData.bank_status.code === 'parsing' ||
961
- oldStatementParseInProgress === true;
978
+ const statementParseInProgress = resolveStatementParseInProgressAfterFetch(oldRecord, reconciliationData);
962
979
  if (refreshViewInBackground === true) {
963
980
  draft.accountReconciliationRecordsBySelectedPeriod[(0, timePeriod_1.toMonthYearPeriodId)(selectedPeriod)].reconciliationByAccountID[accountId] = {
964
981
  ...oldRecord,
@@ -3,7 +3,7 @@ import { Observable } from 'rxjs';
3
3
  import { RootState } from '../../reducer';
4
4
  import { ZeniAPI } from '../../zeniAPI';
5
5
  import { markCategoryClassRecommendationsCompletedForCategorization, markCategoryClassRecommendationsFailureForCategorization, markCategoryClassRecommendationsInProgressForCategorization, setEntityRecommendationForLineIdsForCategorization } from '../expenseAutomationView/reducers/transactionsViewReducer';
6
- import { markCategoryClassRecommendationsCompletedForTransactionDetail, markCategoryClassRecommendationsFailureForTransactionDetail, markCategoryClassRecommendationsInProgressForTransactionDetail, setEntityRecommendationForLineIdsForTransactionDetail } from '../transactionDetail/transactionDetailReducer';
6
+ import { markCategoryClassRecommendationsCompletedForTransactionDetail, markCategoryClassRecommendationsFailureForTransactionDetail, markCategoryClassRecommendationsInProgressForTransactionDetail, setEntityRecommendationForLineIdsForTransactionDetail, updateVendorBulkRecommendationsFetchStatus } from '../transactionDetail/transactionDetailReducer';
7
7
  import { fetchEntityRecommendationsForLineUpdate } from './recommendationReducer';
8
- export type ActionType = ReturnType<typeof fetchEntityRecommendationsForLineUpdate> | ReturnType<typeof markCategoryClassRecommendationsInProgressForCategorization> | ReturnType<typeof markCategoryClassRecommendationsCompletedForCategorization> | ReturnType<typeof markCategoryClassRecommendationsFailureForCategorization> | ReturnType<typeof markCategoryClassRecommendationsInProgressForTransactionDetail> | ReturnType<typeof markCategoryClassRecommendationsCompletedForTransactionDetail> | ReturnType<typeof markCategoryClassRecommendationsFailureForTransactionDetail> | ReturnType<typeof setEntityRecommendationForLineIdsForCategorization> | ReturnType<typeof setEntityRecommendationForLineIdsForTransactionDetail>;
8
+ export type ActionType = ReturnType<typeof fetchEntityRecommendationsForLineUpdate> | ReturnType<typeof markCategoryClassRecommendationsInProgressForCategorization> | ReturnType<typeof markCategoryClassRecommendationsCompletedForCategorization> | ReturnType<typeof markCategoryClassRecommendationsFailureForCategorization> | ReturnType<typeof markCategoryClassRecommendationsInProgressForTransactionDetail> | ReturnType<typeof markCategoryClassRecommendationsCompletedForTransactionDetail> | ReturnType<typeof markCategoryClassRecommendationsFailureForTransactionDetail> | ReturnType<typeof setEntityRecommendationForLineIdsForCategorization> | ReturnType<typeof setEntityRecommendationForLineIdsForTransactionDetail> | ReturnType<typeof updateVendorBulkRecommendationsFetchStatus>;
9
9
  export declare const fetchEntityRecommendationsForLineUpdateEpic: (actions$: ActionsObservable<ActionType>, state$: StateObservable<RootState>, zeniAPI: ZeniAPI) => Observable<ActionType>;
@@ -41,6 +41,16 @@ const fetchEntityRecommendationsForLineUpdateEpic = (actions$, state$, zeniAPI)
41
41
  const payload = action.payload;
42
42
  const { transactionId, transaction, lineIds } = payload.transactionDetails;
43
43
  const { entity, amount, isFetchCOT, isUncategorizedExpenseCategoryEnabled, } = payload;
44
+ // The vendor "Update all lines" popup on the detail page watches
45
+ // its own transaction-level fetch status, separate from the
46
+ // per-line statuses below — surface completion/failure there too
47
+ // when this fetch was triggered by that bulk action, or the popup
48
+ // hangs until its own 60s timeout regardless of how this request
49
+ // resolves.
50
+ const isBulkDetailFetch = payload.flowType === 'detail' && payload.isBulkFetch === true;
51
+ const emitWithBulkStatus = (primaryAction, bulkAction) => isBulkDetailFetch && bulkAction != null
52
+ ? (0, rxjs_1.of)(primaryAction, bulkAction)
53
+ : (0, rxjs_1.of)(primaryAction);
44
54
  // Synchronously emit the start arm so the per-line skeleton +
45
55
  // row uncheck happen before the HTTP request begins. The
46
56
  // discriminated-union payload narrows `payload.selectedTab` to
@@ -54,9 +64,9 @@ const fetchEntityRecommendationsForLineUpdateEpic = (actions$, state$, zeniAPI)
54
64
  .getJSON(url)
55
65
  .pipe((0, operators_1.mergeMap)((response) => {
56
66
  if (!(0, responsePayload_1.isSuccessResponse)(response) || response.data == null) {
57
- return (0, rxjs_1.of)(payload.flowType === 'categorization'
67
+ return emitWithBulkStatus(payload.flowType === 'categorization'
58
68
  ? (0, transactionsViewReducer_1.markCategoryClassRecommendationsFailureForCategorization)(payload.selectedTab, transactionId, lineIds, response.status)
59
- : (0, transactionDetailReducer_1.markCategoryClassRecommendationsFailureForTransactionDetail)(transactionId, lineIds, response.status));
69
+ : (0, transactionDetailReducer_1.markCategoryClassRecommendationsFailureForTransactionDetail)(transactionId, lineIds, response.status), (0, transactionDetailReducer_1.updateVendorBulkRecommendationsFetchStatus)(transactionId, 'Error', response.status));
60
70
  }
61
71
  const recommendationWithCOTByLineId = (0, recommendationHelper_1.getRecommendationByLineIdWithCOTFromRecommendationPayloadWithCOT)(response.data.recommendations, lineIds);
62
72
  // HTTP 200 with no per-line recommendations: NOT an error.
@@ -64,9 +74,9 @@ const fetchEntityRecommendationsForLineUpdateEpic = (actions$, state$, zeniAPI)
64
74
  // downstream UI can treat this as "we tried, nothing to
65
75
  // apply".
66
76
  if (recommendationWithCOTByLineId == null) {
67
- return (0, rxjs_1.of)(payload.flowType === 'categorization'
77
+ return emitWithBulkStatus(payload.flowType === 'categorization'
68
78
  ? (0, transactionsViewReducer_1.markCategoryClassRecommendationsCompletedForCategorization)(payload.selectedTab, transactionId, lineIds)
69
- : (0, transactionDetailReducer_1.markCategoryClassRecommendationsCompletedForTransactionDetail)(transactionId, lineIds));
79
+ : (0, transactionDetailReducer_1.markCategoryClassRecommendationsCompletedForTransactionDetail)(transactionId, lineIds), (0, transactionDetailReducer_1.updateVendorBulkRecommendationsFetchStatus)(transactionId, 'Completed'));
70
80
  }
71
81
  const transactionWithCOT = isFetchCOT === true
72
82
  ? (0, transactionHelper_1.getTransactionWithCOTFromRecommendations)(transaction, recommendationWithCOTByLineId)
@@ -75,16 +85,16 @@ const fetchEntityRecommendationsForLineUpdateEpic = (actions$, state$, zeniAPI)
75
85
  if (payload.flowType === 'categorization') {
76
86
  return (0, rxjs_1.of)((0, transactionsViewReducer_1.setEntityRecommendationForLineIdsForCategorization)(payload.selectedTab, transactionId, transactionWithCOT, lineIds, entity, amount, recommendationWithCOTByLineId, uncategorizedIncomeExpense, isUncategorizedExpenseCategoryEnabled, (0, tenantSelector_1.getIsAccountingClassesEnabled)(state$.value)));
77
87
  }
78
- return (0, rxjs_1.of)((0, transactionDetailReducer_1.setEntityRecommendationForLineIdsForTransactionDetail)(transactionId, transactionWithCOT, lineIds, entity, amount, recommendationWithCOTByLineId, uncategorizedIncomeExpense));
88
+ return emitWithBulkStatus((0, transactionDetailReducer_1.setEntityRecommendationForLineIdsForTransactionDetail)(transactionId, transactionWithCOT, lineIds, entity, amount, recommendationWithCOTByLineId, uncategorizedIncomeExpense), (0, transactionDetailReducer_1.updateVendorBulkRecommendationsFetchStatus)(transactionId, 'Completed'));
79
89
  }), (0, operators_1.catchError)((error) => {
80
90
  const errorMessage = 'fetchEntityRecommendationsForLineUpdate failed: ' +
81
91
  (error instanceof Error
82
92
  ? error.message
83
93
  : JSON.stringify(error));
84
94
  const status = (0, responsePayload_1.createZeniAPIStatus)('Unexpected Error', errorMessage);
85
- return (0, rxjs_1.of)(payload.flowType === 'categorization'
95
+ return emitWithBulkStatus(payload.flowType === 'categorization'
86
96
  ? (0, transactionsViewReducer_1.markCategoryClassRecommendationsFailureForCategorization)(payload.selectedTab, transactionId, lineIds, status)
87
- : (0, transactionDetailReducer_1.markCategoryClassRecommendationsFailureForTransactionDetail)(transactionId, lineIds, status));
97
+ : (0, transactionDetailReducer_1.markCategoryClassRecommendationsFailureForTransactionDetail)(transactionId, lineIds, status), (0, transactionDetailReducer_1.updateVendorBulkRecommendationsFetchStatus)(transactionId, 'Error', status));
88
98
  }));
89
99
  return (0, rxjs_1.concat)((0, rxjs_1.of)(startAction), httpStream$);
90
100
  }))));
@@ -15,6 +15,7 @@ type FetchEntityRecommendationsForLineUpdateCommonPayload = {
15
15
  transaction: SupportedTransaction;
16
16
  transactionId: TransactionID;
17
17
  };
18
+ isBulkFetch?: boolean;
18
19
  isFetchCOT?: boolean;
19
20
  isUncategorizedExpenseCategoryEnabled?: boolean;
20
21
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeniai/client-epic-state",
3
- "version": "5.1.51",
3
+ "version": "5.1.52-beta0RD",
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",