@zeniai/client-epic-state 5.2.52 → 5.2.53

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 (19) hide show
  1. package/lib/esm/view/expenseAutomationView/epics/transactionCategorization/fetchTransactionCategorizationEpic.js +15 -4
  2. package/lib/esm/view/expenseAutomationView/reducers/transactionsViewReducer.js +10 -0
  3. package/lib/esm/view/expenseAutomationView/selectors/transactionCategorizationSelector.js +28 -11
  4. package/lib/esm/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewSelector.js +19 -3
  5. package/lib/esm/view/invoicing/editInvoicingCouponDetailView/editInvoicingCouponDetailViewSelector.js +21 -21
  6. package/lib/esm/view/invoicing/invoicingQboView/invoicingQboViewSelector.js +57 -9
  7. package/lib/view/expenseAutomationView/epics/transactionCategorization/fetchTransactionCategorizationEpic.js +15 -4
  8. package/lib/view/expenseAutomationView/payload/transactionCategorizationPayload.d.ts +18 -7
  9. package/lib/view/expenseAutomationView/reducers/transactionsViewReducer.js +10 -0
  10. package/lib/view/expenseAutomationView/selectorTypes/transactionsViewSelectorTypes.d.ts +8 -4
  11. package/lib/view/expenseAutomationView/selectors/transactionCategorizationSelector.js +27 -10
  12. package/lib/view/expenseAutomationView/types/transactionsViewState.d.ts +8 -5
  13. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewSelector.d.ts +7 -0
  14. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewSelector.js +18 -2
  15. package/lib/view/invoicing/editInvoicingCouponDetailView/editInvoicingCouponDetailViewSelector.d.ts +3 -2
  16. package/lib/view/invoicing/editInvoicingCouponDetailView/editInvoicingCouponDetailViewSelector.js +19 -19
  17. package/lib/view/invoicing/invoicingQboView/invoicingQboViewSelector.d.ts +49 -0
  18. package/lib/view/invoicing/invoicingQboView/invoicingQboViewSelector.js +59 -10
  19. package/package.json +1 -1
@@ -6,7 +6,6 @@ import { updateTransactions } from '../../../../entity/transaction/transactionRe
6
6
  import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
7
7
  import { getLineItemsByTransactionsIdsFromResponse } from '../../helpers/transactionCategorizationLocalDataHelper';
8
8
  import { fetchTransactionCategorization, fetchTransactionCategorizationFailure, fetchTransactionCategorizationSuccess, initializeTransactionCategorizationViewLocalData, resetOtherTabsFetchState, updateParentTotalCountForTab, updateTotalCountForTransactionCategorization, updateTransactionCategorizationUIState, } from '../../reducers/transactionsViewReducer';
9
- import { DEFAULT_COMPLETED_SUB_TAB } from '../../types/completedSubTab';
10
9
  import { TRANSACTIONS_TABS, toTransactionsSortKey, } from '../../types/transactionsViewState';
11
10
  export const fetchTransactionCategorizationEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter(fetchTransactionCategorization.match),
12
11
  // Per-tab switchMap: cancels in-flight requests for the same tab on a new
@@ -33,9 +32,10 @@ groupBy((action) => action.payload.selectedTab), mergeMap((group$) => group$.pip
33
32
  // cross-status search (omit or true) is reserved for Receipts
34
33
  // manual-match.
35
34
  search_all_statuses: false,
36
- sub_tab: selectedTab === 'autoCategorized'
37
- ? expenseAutomationTransactionsViewState.selectedTransactionCategorizationCompletedSubTab
38
- : DEFAULT_COMPLETED_SUB_TAB,
35
+ // Sent on both tabs: the backend ignores it for Review's own rows but uses
36
+ // it to scope the Completed badge it returns alongside them.
37
+ sub_tab: expenseAutomationTransactionsViewState
38
+ .selectedTransactionCategorizationCompletedSubTab,
39
39
  transaction_group: expenseAutomationTransactionsViewState.selectedTransactionGroup,
40
40
  };
41
41
  return zeniAPI
@@ -87,6 +87,17 @@ groupBy((action) => action.payload.selectedTab), mergeMap((group$) => group$.pip
87
87
  parentTotalCount: response.data.parent_tab_total_count ??
88
88
  response.data.total_count,
89
89
  }));
90
+ // The inactive tab's badge, counted under the same filters. Absent on
91
+ // pages after the first and on the cross-tab search path, where the
92
+ // last known value stays correct.
93
+ const otherTabTotalCount = response.data.other_tab_total_count;
94
+ if (otherTabTotalCount != null) {
95
+ updateActions.push(updateParentTotalCountForTab({
96
+ selectedPeriod,
97
+ selectedTab: selectedTab === 'review' ? 'autoCategorized' : 'review',
98
+ parentTotalCount: otherTabTotalCount,
99
+ }));
100
+ }
90
101
  updateActions.push(resetOtherTabsFetchState({
91
102
  refreshViewInBackground,
92
103
  tabs: TRANSACTIONS_TABS.filter((tab) => selectedTab !== tab),
@@ -373,6 +373,16 @@ const expenseAutomationTransactionsView = createSlice({
373
373
  if (cached != null) {
374
374
  autoCat.uiState.pageToken = cached.pageToken;
375
375
  autoCat.uiState.totalCount = { ...cached.totalCount };
376
+ // Keep the stored badge in step with the restored sub-tab: the selector
377
+ // falls back to it for any period this sub-tab has not loaded. Merged,
378
+ // not replaced -- the snapshot only holds periods this sub-tab actually
379
+ // loaded, while the badge map also carries periods filled from a Review
380
+ // response's `other_tab_total_count`. Overwriting would drop those and
381
+ // leave the badge at 0 for any period this sub-tab never visited.
382
+ draft.parentTotalCountByTab.autoCategorized = {
383
+ ...draft.parentTotalCountByTab.autoCategorized,
384
+ ...cached.totalCount,
385
+ };
376
386
  autoCat.uiState.scrollPosition = cached.scrollPosition;
377
387
  autoCat.transactionIdsBySelectedPeriod = {
378
388
  ...cached.transactionIdsBySelectedPeriod,
@@ -1,6 +1,6 @@
1
1
  import { createSelector } from '@reduxjs/toolkit';
2
2
  import omit from 'lodash/omit';
3
- import { toMonthYearPeriodId } from '../../../commonStateTypes/timePeriod';
3
+ import { toMonthYearPeriodId, } from '../../../commonStateTypes/timePeriod';
4
4
  import { getCurrentTenant, getIsAccountingProjectsEnabled, } from '../../../entity/tenant/tenantSelector';
5
5
  import { getTransactionWithCOT } from '../../../entity/transaction/transactionHelper';
6
6
  import { getSupportedTransactionsByIds } from '../../../entity/transaction/transactionSelector';
@@ -172,18 +172,35 @@ export function getExpenseAutomationTransactionView(state) {
172
172
  review: monthYearPeriodId != null ? totalCountByReview[monthYearPeriodId] : 0,
173
173
  autoCategorized: monthYearPeriodId != null ? totalCountByAutoCat[monthYearPeriodId] : 0,
174
174
  };
175
- // Parent-tab badge counts. Sourced from `parent_tab_total_count` in the
176
- // listing response and used exclusively by the navbar / tab strip badges.
177
- // Independent of which Completed sub-tab is active so the badge stays
178
- // stable across sub-tab switches.
175
+ // Tab badge counts, used exclusively by the navbar / tab strip badges. Both
176
+ // tabs are counted under the filters applied to the list, so a badge always
177
+ // equals the number of rows that tab shows.
178
+ //
179
+ // The ACTIVE tab prefers its own list count: the two are the same number (the
180
+ // backend's parent_tab_total_count mirrors total_count), but `uiState.totalCount`
181
+ // is restored by every cache path that skips a fetch -- switching back to an
182
+ // already-loaded period, or to a cached Completed sub-tab -- whereas the stored
183
+ // badge is only written when a response lands, so on those paths it would keep
184
+ // showing whatever was fetched last.
185
+ //
186
+ // The `in` check is load-bearing, not defensive: `totalCount` is a per-period
187
+ // map typed as `Record<MonthYearPeriodId, number>`, so indexing it is typed
188
+ // `number` but is `undefined` for any period the active (sub-)tab has not
189
+ // loaded -- the window between switching to a fresh sub-tab and its response
190
+ // landing. Falling back to the stored badge there keeps a real number in the
191
+ // label instead of blanking it.
179
192
  const parentTotalCountByTab = expenseAutomationTransactionsViewState.parentTotalCountByTab;
193
+ const storedBadgeByTab = (tab) => monthYearPeriodId != null
194
+ ? (parentTotalCountByTab[tab][monthYearPeriodId] ?? 0)
195
+ : 0;
196
+ const badgeForTab = (tab, listCountByPeriod) => selectedTransactionCategorizationTab === tab &&
197
+ monthYearPeriodId != null &&
198
+ monthYearPeriodId in listCountByPeriod
199
+ ? listCountByPeriod[monthYearPeriodId]
200
+ : storedBadgeByTab(tab);
180
201
  const parentTabTotalCountByTab = {
181
- review: monthYearPeriodId != null
182
- ? (parentTotalCountByTab.review[monthYearPeriodId] ?? 0)
183
- : 0,
184
- autoCategorized: monthYearPeriodId != null
185
- ? (parentTotalCountByTab.autoCategorized[monthYearPeriodId] ?? 0)
186
- : 0,
202
+ review: badgeForTab('review', totalCountByReview),
203
+ autoCategorized: badgeForTab('autoCategorized', totalCountByAutoCat),
187
204
  };
188
205
  const fetchStateByTab = {
189
206
  review: {
@@ -3,7 +3,7 @@ import { reduceAllFetchState } from '../../../commonStateTypes/reduceFetchState'
3
3
  import { getInvoicingPlanById, getInvoicingPlansByIds, } from '../../../entity/invoicing/plan/planSelector';
4
4
  import { getInvoicingConfigView, invoicingFieldHelpForGroup, } from '../invoicingConfigView/invoicingConfigViewSelector';
5
5
  import { getInvoicingFormDraftLocalData, isInvoicingFormDraftPending, } from '../invoicingFormDraft';
6
- import { getInvoicingQboView, isInvoicingAccountingPickerDataLoading, isInvoicingQboConnected, } from '../invoicingQboView/invoicingQboViewSelector';
6
+ import { INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES, INVOICING_INCOME_ACCOUNT_MAPPING_SLOT, NO_INVOICING_ACCOUNT_HIERARCHY, NO_INVOICING_ACCOUNT_IDS, NO_INVOICING_CLASS_HIERARCHY, getInvoicingQboAccountOptions, getInvoicingQboView, invoicingQboAccountIdsForMappingSlot, isInvoicingAccountingPickerDataLoading, isInvoicingQboConnected, } from '../invoicingQboView/invoicingQboViewSelector';
7
7
  import { INVOICING_CATALOG_APPLICABILITY_VALUES, INVOICING_CATALOG_PRICING_MODEL_VALUES, INVOICING_CATALOG_PRORATION_VALUES, INVOICING_CATALOG_TIER_TYPE_VALUES, initialInvoicingCatalogItemFormLocalData, } from './invoicingCatalogItemFormHelper';
8
8
  export const getEditInvoicingCatalogItemDetailViewState = (state) => ({
9
9
  fetchState: state.editInvoicingCatalogItemDetailViewState.fetchState,
@@ -44,6 +44,21 @@ const NOT_STARTED = {
44
44
  error: undefined,
45
45
  fetchState: 'Not-Started',
46
46
  };
47
+ /**
48
+ * The QuickBooks ids a price point's income account may be picked from.
49
+ *
50
+ * Memoized, and collapsing to one shared empty array while QBO is disconnected:
51
+ * the pages turn this into their picker options and re-seed their form defaults
52
+ * when those change, so a fresh array on every store change would reset the form
53
+ * under the user — including mid-save.
54
+ */
55
+ const getInvoicingCatalogIncomeAccountIds = createSelector([
56
+ getInvoicingQboAccountOptions,
57
+ (state) => getInvoicingConfigView(state).qboAccountMappingKindOptions,
58
+ isInvoicingQboConnected,
59
+ ], (accounts, qboAccountMappingKindOptions, isQboConnected) => isQboConnected
60
+ ? invoicingQboAccountIdsForMappingSlot(accounts, qboAccountMappingKindOptions, INVOICING_INCOME_ACCOUNT_MAPPING_SLOT, INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES)
61
+ : NO_INVOICING_ACCOUNT_IDS);
47
62
  export const getInvoicingCatalogItemFormView = (state, id) => {
48
63
  const { editDraftById, error, fetchState, newItemDraft, savedItemId, savedKind, } = state.editInvoicingCatalogItemDetailViewState;
49
64
  const plan = id != null ? getInvoicingPlanById(state.invoicingPlanState, id) : undefined;
@@ -94,8 +109,9 @@ export const getInvoicingCatalogItemFormView = (state, id) => {
94
109
  isFormLoading: isInvoicingAccountingPickerDataLoading(state) ||
95
110
  isInvoicingFormDraftPending(editDraftById, id),
96
111
  isQboPickerDataLoading: isInvoicingAccountingPickerDataLoading(state),
97
- qboAccountHierarchy: qboView?.nestedAccountHierarchy ?? [],
98
- qboClassHierarchy: qboView?.nestedClassHierarchy ?? [],
112
+ qboAccountHierarchy: qboView?.nestedAccountHierarchy ?? NO_INVOICING_ACCOUNT_HIERARCHY,
113
+ qboClassHierarchy: qboView?.nestedClassHierarchy ?? NO_INVOICING_CLASS_HIERARCHY,
114
+ qboIncomeAccountIds: getInvoicingCatalogIncomeAccountIds(state),
99
115
  applicabilityValues: INVOICING_CATALOG_APPLICABILITY_VALUES,
100
116
  applicableItemOptions,
101
117
  localData,
@@ -1,9 +1,10 @@
1
+ import { createSelector } from '@reduxjs/toolkit';
1
2
  import { reduceAllFetchState } from '../../../commonStateTypes/reduceFetchState';
2
3
  import { getInvoicingCouponById } from '../../../entity/invoicing/coupon/couponSelector';
3
- import { toInvoicingFormOption, toInvoicingQboAccountMappingSlot, } from '../../../entity/invoicing/invoicingCommonPayload';
4
+ import { toInvoicingFormOption, } from '../../../entity/invoicing/invoicingCommonPayload';
4
5
  import { getInvoicingConfigView, invoicingFieldHelpForGroup, } from '../invoicingConfigView/invoicingConfigViewSelector';
5
6
  import { getInvoicingFormDraftLocalData, isInvoicingFormDraftPending, } from '../invoicingFormDraft';
6
- import { getInvoicingQboView, invoicingAccountsForQboAccountTypes, invoicingQboAccountByQboId, isInvoicingAccountingPickerDataLoading, isInvoicingQboConnected, } from '../invoicingQboView/invoicingQboViewSelector';
7
+ import { INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES, INVOICING_DISCOUNTS_ACCOUNT_MAPPING_SLOT, NO_INVOICING_ACCOUNT_HIERARCHY, NO_INVOICING_ACCOUNT_IDS, getInvoicingQboAccountOptions, getInvoicingQboView, invoicingQboAccountByQboId, invoicingQboAccountIdsForMappingSlot, isInvoicingAccountingPickerDataLoading, isInvoicingQboConnected, } from '../invoicingQboView/invoicingQboViewSelector';
7
8
  import { getInvoicingSettings, getInvoicingSettingsFetchState, } from '../settingsView/settingsViewSelector';
8
9
  import { initialInvoicingCouponFormLocalData, invoicingCouponDiscountsAccountIdForCreate, } from './editInvoicingCouponDetailViewState';
9
10
  /**
@@ -11,6 +12,21 @@ import { initialInvoicingCouponFormLocalData, invoicingCouponDiscountsAccountIdF
11
12
  * the id of the coupon last saved, and — when a `couponId` is passed (edit
12
13
  * mode) — the existing coupon entity to prefill the form.
13
14
  */
15
+ /**
16
+ * The QuickBooks ids the discounts account may be picked from.
17
+ *
18
+ * Memoized, and collapsing to one shared empty array while QBO is disconnected:
19
+ * the form turns this into its picker options and re-seeds its defaults when
20
+ * those change, so a fresh array on every store change would reset the form
21
+ * under the user until their first edit.
22
+ */
23
+ const getInvoicingCouponDiscountsAccountIds = createSelector([
24
+ getInvoicingQboAccountOptions,
25
+ (state) => getInvoicingConfigView(state).qboAccountMappingKindOptions,
26
+ isInvoicingQboConnected,
27
+ ], (accounts, qboAccountMappingKindOptions, isQboConnected) => isQboConnected
28
+ ? invoicingQboAccountIdsForMappingSlot(accounts, qboAccountMappingKindOptions, INVOICING_DISCOUNTS_ACCOUNT_MAPPING_SLOT, INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES)
29
+ : NO_INVOICING_ACCOUNT_IDS);
14
30
  const NOT_STARTED = {
15
31
  error: undefined,
16
32
  fetchState: 'Not-Started',
@@ -51,14 +67,6 @@ export const getEditInvoicingCouponDetailViewState = (state, couponId) => {
51
67
  version: 1.0,
52
68
  };
53
69
  };
54
- /**
55
- * The account types the discounts slot falls back to when the config has no
56
- * `discounts_account` kind. A QuickBooks discount line posts to an income
57
- * account; offering every account instead would let a save be rejected.
58
- */
59
- const DEFAULT_DISCOUNTS_ACCOUNT_TYPES = 'Income';
60
- /** The account-mapping slot a coupon's discounts account shares with settings. */
61
- const DISCOUNTS_ACCOUNT_MAPPING_SLOT = 'discounts_account';
62
70
  /**
63
71
  * Bundled view for the discount form screen: the edit view (save state + the
64
72
  * coupon to prefill in edit mode), the draft `localData` (seeded from the
@@ -74,7 +82,7 @@ export const getInvoicingCouponFormView = (state, couponId) => {
74
82
  const localData = couponId != null
75
83
  ? getInvoicingFormDraftLocalData(editDraftById, couponId, initialInvoicingCouponFormLocalData)
76
84
  : newCouponDraft;
77
- const { billingPeriodUnitOptions, discountApplyOnOptions, discountDurationTypeOptions, discountTypeOptions, qboAccountMappingKindOptions, } = getInvoicingConfigView(state);
85
+ const { billingPeriodUnitOptions, discountApplyOnOptions, discountDurationTypeOptions, discountTypeOptions, } = getInvoicingConfigView(state);
78
86
  const isQboConnected = isInvoicingQboConnected(state);
79
87
  const qboView = isQboConnected ? getInvoicingQboView(state) : undefined;
80
88
  const isQboPickerDataLoading = isInvoicingAccountingPickerDataLoading(state);
@@ -84,14 +92,6 @@ export const getInvoicingCouponFormView = (state, couponId) => {
84
92
  const isCreateSeedPending = couponId == null &&
85
93
  (settingsFetchState === 'Not-Started' ||
86
94
  settingsFetchState === 'In-Progress');
87
- // The account types the discounts slot accepts come from the same config the
88
- // QBO settings page reads, so the two pickers offer the same accounts and
89
- // widening the slot stays a config change. Matched on the slot rather than
90
- // the code as written: a config that names the kind `discounts_account_id`
91
- // is the same slot, and missing it here would quietly fall back to the
92
- // default and offer a different set than the settings page does.
93
- const discountsAccountTypes = qboAccountMappingKindOptions.find((kindOption) => toInvoicingQboAccountMappingSlot(kindOption.code) ===
94
- DISCOUNTS_ACCOUNT_MAPPING_SLOT)?.accountTypes ?? DEFAULT_DISCOUNTS_ACCOUNT_TYPES;
95
95
  return {
96
96
  ...base,
97
97
  applyOnOptions: discountApplyOnOptions.map(toInvoicingFormOption),
@@ -130,7 +130,7 @@ export const getInvoicingCouponFormView = (state, couponId) => {
130
130
  },
131
131
  mode: couponId != null ? 'edit' : 'create',
132
132
  periodUnitOptions: billingPeriodUnitOptions.map(toInvoicingFormOption),
133
- qboAccountHierarchy: qboView?.nestedAccountHierarchy ?? [],
134
- qboIncomeAccountIds: invoicingAccountsForQboAccountTypes(qboView?.accounts ?? [], discountsAccountTypes).map((account) => account.id),
133
+ qboAccountHierarchy: qboView?.nestedAccountHierarchy ?? NO_INVOICING_ACCOUNT_HIERARCHY,
134
+ qboIncomeAccountIds: getInvoicingCouponDiscountsAccountIds(state),
135
135
  };
136
136
  };
@@ -1,3 +1,5 @@
1
+ import { createSelector } from '@reduxjs/toolkit';
2
+ import { toInvoicingQboAccountMappingSlot, } from '../../../entity/invoicing/invoicingCommonPayload';
1
3
  import { getAccountList, getNestedAccountListHierarchy, } from '../../accountList/accountListSelector';
2
4
  import { getClassList, getNestedClassListHierarchy, } from '../../classList/classListSelector';
3
5
  // The shared bucket, fetched with the same arguments as the other consumers so
@@ -82,14 +84,20 @@ export const getInvoicingCatalogAccountingLookup = (state) => ({
82
84
  classByQboId: (qboId) => invoicingQboClassByQboId(state, qboId),
83
85
  });
84
86
  /**
85
- * Accounts and classes as the invoicing pickers need them. Both come from the
86
- * shared accounting-service lists, so whatever another screen already loaded
87
- * is reused. `qboId` is the submitted value because that is what the invoicing
88
- * API stores; an account without one cannot be posted, so it is dropped rather
89
- * than offered.
87
+ * Accounts as the invoicing pickers need them, from the shared
88
+ * accounting-service list, so whatever another screen already loaded is reused.
89
+ * `qboId` is the submitted value because that is what the invoicing API stores;
90
+ * an account without one cannot be posted, so it is dropped rather than offered.
91
+ *
92
+ * Memoized on the two slices it reads: the pickers and the lists derived from
93
+ * them are handed straight to forms, and a fresh array on every store change
94
+ * re-seeds a form the user is editing.
90
95
  */
91
- const invoicingAccountingOptions = (state) => {
92
- const { accounts } = getAccountList(state.accountState, state.accountListState, INVOICING_ACCOUNT_LIST_KEY);
96
+ export const getInvoicingQboAccountOptions = createSelector([
97
+ (state) => state.accountState,
98
+ (state) => state.accountListState,
99
+ ], (accountState, accountListState) => {
100
+ const { accounts } = getAccountList(accountState, accountListState, INVOICING_ACCOUNT_LIST_KEY);
93
101
  return accounts.flatMap((account) => {
94
102
  if (account.qboId == null || account.isDeleted === true) {
95
103
  return [];
@@ -102,7 +110,7 @@ const invoicingAccountingOptions = (state) => {
102
110
  },
103
111
  ];
104
112
  });
105
- };
113
+ });
106
114
  /**
107
115
  * Normalises an account-type name for comparison. The config's mapping kinds
108
116
  * name QuickBooks types the way QuickBooks displays them ("Other Current
@@ -138,6 +146,46 @@ export const invoicingAccountsForQboAccountTypes = (accounts, accountTypes) => {
138
146
  return accounts.filter((account) => account.accountType != null &&
139
147
  wanted.has(normalizeAccountTypeName(account.accountType)));
140
148
  };
149
+ /**
150
+ * The account-mapping slot a catalog price point's income account shares with
151
+ * the settings page, and the one a discount's account shares with it.
152
+ */
153
+ export const INVOICING_INCOME_ACCOUNT_MAPPING_SLOT = 'default_income_account';
154
+ export const INVOICING_DISCOUNTS_ACCOUNT_MAPPING_SLOT = 'discounts_account';
155
+ /**
156
+ * The account types a slot falls back to when the config carries no kind for
157
+ * it. QuickBooks posts a product, a price and a discount line to an income
158
+ * account; offering every account instead would let a save be rejected.
159
+ */
160
+ export const INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES = 'Income';
161
+ /**
162
+ * The QuickBooks ids a mapping slot's picker may offer, from the account types
163
+ * the config gives that slot — so every picker filling the same slot offers the
164
+ * same accounts, and widening one stays a config change.
165
+ *
166
+ * Ids rather than a filtered tree: the pickers flatten the tree into options by
167
+ * walking each node's children, so dropping a parent that is not itself of the
168
+ * wanted type would drop its matching descendants with it.
169
+ *
170
+ * Matched on the slot rather than the code as written, since a config naming
171
+ * the kind `discounts_account_id` means the same slot. A slot the config does
172
+ * not carry falls back to `defaultAccountTypes` rather than to every account.
173
+ */
174
+ export const invoicingQboAccountIdsForMappingSlot = (accounts, accountMappingKindOptions, mappingSlot, defaultAccountTypes) => {
175
+ const accountTypes = accountMappingKindOptions.find((kindOption) => toInvoicingQboAccountMappingSlot(kindOption.code) === mappingSlot)?.accountTypes ?? defaultAccountTypes;
176
+ return invoicingAccountsForQboAccountTypes(accounts, accountTypes).map((account) => account.id);
177
+ };
178
+ /**
179
+ * Stable empties for a tenant with no QBO connection.
180
+ *
181
+ * `?? []` builds a fresh array per call, and the invoicing forms re-seed their
182
+ * fields whenever their option lists change identity — so a form open against a
183
+ * disconnected tenant would reset itself on every unrelated store change, until
184
+ * the first edit disarms the re-seed.
185
+ */
186
+ export const NO_INVOICING_ACCOUNT_HIERARCHY = [];
187
+ export const NO_INVOICING_CLASS_HIERARCHY = [];
188
+ export const NO_INVOICING_ACCOUNT_IDS = [];
141
189
  /** Classes as the invoicing pickers need them, from the shared class list. */
142
190
  const invoicingAccountingClassOptions = (state) => {
143
191
  const { classes } = getClassList(state.classState, state.classListState);
@@ -159,7 +207,7 @@ export const getInvoicingQboView = (state) => {
159
207
  const qboState = state.invoicingQboViewState;
160
208
  const accountList = state.accountListState.byReportId[INVOICING_ACCOUNT_LIST_KEY];
161
209
  return {
162
- accounts: invoicingAccountingOptions(state),
210
+ accounts: getInvoicingQboAccountOptions(state),
163
211
  accountsFetchState: {
164
212
  fetchState: accountList.fetchState,
165
213
  error: accountList.error,
@@ -9,7 +9,6 @@ const transactionReducer_1 = require("../../../../entity/transaction/transaction
9
9
  const responsePayload_1 = require("../../../../responsePayload");
10
10
  const transactionCategorizationLocalDataHelper_1 = require("../../helpers/transactionCategorizationLocalDataHelper");
11
11
  const transactionsViewReducer_1 = require("../../reducers/transactionsViewReducer");
12
- const completedSubTab_1 = require("../../types/completedSubTab");
13
12
  const transactionsViewState_1 = require("../../types/transactionsViewState");
14
13
  const fetchTransactionCategorizationEpic = (actions$, state$, zeniAPI) => actions$.pipe((0, operators_1.filter)(transactionsViewReducer_1.fetchTransactionCategorization.match),
15
14
  // Per-tab switchMap: cancels in-flight requests for the same tab on a new
@@ -36,9 +35,10 @@ const fetchTransactionCategorizationEpic = (actions$, state$, zeniAPI) => action
36
35
  // cross-status search (omit or true) is reserved for Receipts
37
36
  // manual-match.
38
37
  search_all_statuses: false,
39
- sub_tab: selectedTab === 'autoCategorized'
40
- ? expenseAutomationTransactionsViewState.selectedTransactionCategorizationCompletedSubTab
41
- : completedSubTab_1.DEFAULT_COMPLETED_SUB_TAB,
38
+ // Sent on both tabs: the backend ignores it for Review's own rows but uses
39
+ // it to scope the Completed badge it returns alongside them.
40
+ sub_tab: expenseAutomationTransactionsViewState
41
+ .selectedTransactionCategorizationCompletedSubTab,
42
42
  transaction_group: expenseAutomationTransactionsViewState.selectedTransactionGroup,
43
43
  };
44
44
  return zeniAPI
@@ -90,6 +90,17 @@ const fetchTransactionCategorizationEpic = (actions$, state$, zeniAPI) => action
90
90
  parentTotalCount: response.data.parent_tab_total_count ??
91
91
  response.data.total_count,
92
92
  }));
93
+ // The inactive tab's badge, counted under the same filters. Absent on
94
+ // pages after the first and on the cross-tab search path, where the
95
+ // last known value stays correct.
96
+ const otherTabTotalCount = response.data.other_tab_total_count;
97
+ if (otherTabTotalCount != null) {
98
+ updateActions.push((0, transactionsViewReducer_1.updateParentTotalCountForTab)({
99
+ selectedPeriod,
100
+ selectedTab: selectedTab === 'review' ? 'autoCategorized' : 'review',
101
+ parentTotalCount: otherTabTotalCount,
102
+ }));
103
+ }
93
104
  updateActions.push((0, transactionsViewReducer_1.resetOtherTabsFetchState)({
94
105
  refreshViewInBackground,
95
106
  tabs: transactionsViewState_1.TRANSACTIONS_TABS.filter((tab) => selectedTab !== tab),
@@ -15,9 +15,9 @@ export interface TransactionCategorizationQueryPayload {
15
15
  start_date: string | null;
16
16
  /**
17
17
  * Required. For the Completed (`auto_categorized=true`) listing, scopes the
18
- * returned page + `total_count` to one of the Completed sub-tabs. For the
19
- * Review listing it is forced to `'all'` to preserve parent-tab semantics
20
- * on the backend.
18
+ * returned page + `total_count` to one of the Completed sub-tabs. It is sent
19
+ * on the Review listing too: the backend ignores it for Review's own status
20
+ * set, but uses it to scope the Completed badge in `other_tab_total_count`.
21
21
  */
22
22
  sub_tab: CompletedSubTab;
23
23
  /**
@@ -44,10 +44,21 @@ export interface TransactionCategorizationPayload {
44
44
  total_count: number;
45
45
  transactions: SupportedTransactionPayload[];
46
46
  /**
47
- * Count of transactions for the entire parent tab (Review or Completed),
48
- * regardless of which sub-tab the listing was scoped to. Used solely to
49
- * drive the parent tab badge counts on the UI; pagination, list-level
50
- * counts, and sub-tab chips continue to consume {@link total_count}.
47
+ * Badge count for the OTHER tab (Completed when this response is Review, and
48
+ * vice versa) under the same filters, so both badges move together with the
49
+ * Transaction Group dropdown and the Completed sub-tab without having to
50
+ * fetch that tab's rows.
51
+ *
52
+ * `null`/absent when the backend computed no badge — pages after the first
53
+ * (a scroll cannot change either badge) and the cross-tab search path used by
54
+ * Receipts manual-match. Consumers must keep their last known value then.
55
+ */
56
+ other_tab_total_count?: number | null;
57
+ /**
58
+ * Badge count for the tab this response belongs to, taken under every filter
59
+ * the request carried (`transaction_group`, `sub_tab`, `search_text`) — so it
60
+ * matches the rows rendered below it and equals {@link total_count}. Retained
61
+ * as its own key for wire compatibility.
51
62
  *
52
63
  * Optional at the wire boundary so that any version-skew scenario
53
64
  * (delayed BE rollout, cached SW response, hotfix without the BE change)
@@ -380,6 +380,16 @@ const expenseAutomationTransactionsView = (0, toolkit_1.createSlice)({
380
380
  if (cached != null) {
381
381
  autoCat.uiState.pageToken = cached.pageToken;
382
382
  autoCat.uiState.totalCount = { ...cached.totalCount };
383
+ // Keep the stored badge in step with the restored sub-tab: the selector
384
+ // falls back to it for any period this sub-tab has not loaded. Merged,
385
+ // not replaced -- the snapshot only holds periods this sub-tab actually
386
+ // loaded, while the badge map also carries periods filled from a Review
387
+ // response's `other_tab_total_count`. Overwriting would drop those and
388
+ // leave the badge at 0 for any period this sub-tab never visited.
389
+ draft.parentTotalCountByTab.autoCategorized = {
390
+ ...draft.parentTotalCountByTab.autoCategorized,
391
+ ...cached.totalCount,
392
+ };
383
393
  autoCat.uiState.scrollPosition = cached.scrollPosition;
384
394
  autoCat.transactionIdsBySelectedPeriod = {
385
395
  ...cached.transactionIdsBySelectedPeriod,
@@ -28,10 +28,14 @@ export interface ExpenseAutomationTransactionViewSelector extends SelectorView {
28
28
  isAccountingProjectsEnabled: boolean;
29
29
  markAsNotMiscategorizedStatus: FetchStateAndError;
30
30
  /**
31
- * Parent tab badge counts, sourced from the listing response's
32
- * `parent_tab_total_count`. Used exclusively to drive the navbar / tab
33
- * strip badge labels — every other count consumer (pagination, in-list
34
- * "showing X of Y", save flows) keeps reading {@link totalCountByTab}.
31
+ * Tab badge counts under the filters currently applied to the list
32
+ * (transaction group + Completed sub-tab + search), so each badge matches
33
+ * the rows its tab renders. The active tab's entry is {@link totalCountByTab}
34
+ * itself, so it cannot drift when a cache hit skips the fetch; the inactive
35
+ * tab's comes from the listing response's `other_tab_total_count`. Used
36
+ * exclusively to drive the navbar / tab strip badge labels — every other
37
+ * count consumer (pagination, in-list "showing X of Y", save flows) keeps
38
+ * reading {@link totalCountByTab}.
35
39
  */
36
40
  parentTabTotalCountByTab: Record<TransactionsTab, number>;
37
41
  projectList: ProjectListSelectorView;
@@ -182,18 +182,35 @@ function getExpenseAutomationTransactionView(state) {
182
182
  review: monthYearPeriodId != null ? totalCountByReview[monthYearPeriodId] : 0,
183
183
  autoCategorized: monthYearPeriodId != null ? totalCountByAutoCat[monthYearPeriodId] : 0,
184
184
  };
185
- // Parent-tab badge counts. Sourced from `parent_tab_total_count` in the
186
- // listing response and used exclusively by the navbar / tab strip badges.
187
- // Independent of which Completed sub-tab is active so the badge stays
188
- // stable across sub-tab switches.
185
+ // Tab badge counts, used exclusively by the navbar / tab strip badges. Both
186
+ // tabs are counted under the filters applied to the list, so a badge always
187
+ // equals the number of rows that tab shows.
188
+ //
189
+ // The ACTIVE tab prefers its own list count: the two are the same number (the
190
+ // backend's parent_tab_total_count mirrors total_count), but `uiState.totalCount`
191
+ // is restored by every cache path that skips a fetch -- switching back to an
192
+ // already-loaded period, or to a cached Completed sub-tab -- whereas the stored
193
+ // badge is only written when a response lands, so on those paths it would keep
194
+ // showing whatever was fetched last.
195
+ //
196
+ // The `in` check is load-bearing, not defensive: `totalCount` is a per-period
197
+ // map typed as `Record<MonthYearPeriodId, number>`, so indexing it is typed
198
+ // `number` but is `undefined` for any period the active (sub-)tab has not
199
+ // loaded -- the window between switching to a fresh sub-tab and its response
200
+ // landing. Falling back to the stored badge there keeps a real number in the
201
+ // label instead of blanking it.
189
202
  const parentTotalCountByTab = expenseAutomationTransactionsViewState.parentTotalCountByTab;
203
+ const storedBadgeByTab = (tab) => monthYearPeriodId != null
204
+ ? (parentTotalCountByTab[tab][monthYearPeriodId] ?? 0)
205
+ : 0;
206
+ const badgeForTab = (tab, listCountByPeriod) => selectedTransactionCategorizationTab === tab &&
207
+ monthYearPeriodId != null &&
208
+ monthYearPeriodId in listCountByPeriod
209
+ ? listCountByPeriod[monthYearPeriodId]
210
+ : storedBadgeByTab(tab);
190
211
  const parentTabTotalCountByTab = {
191
- review: monthYearPeriodId != null
192
- ? (parentTotalCountByTab.review[monthYearPeriodId] ?? 0)
193
- : 0,
194
- autoCategorized: monthYearPeriodId != null
195
- ? (parentTotalCountByTab.autoCategorized[monthYearPeriodId] ?? 0)
196
- : 0,
212
+ review: badgeForTab('review', totalCountByReview),
213
+ autoCategorized: badgeForTab('autoCategorized', totalCountByAutoCat),
197
214
  };
198
215
  const fetchStateByTab = {
199
216
  review: {
@@ -139,11 +139,14 @@ export type TransactionsViewState = {
139
139
  */
140
140
  autoCategorizedSubTabCache: Partial<Record<CompletedSubTab, AutoCategorizedSubTabSnapshot>>;
141
141
  /**
142
- * Period-keyed parent tab counts, scoped per {@link TransactionsTab}.
143
- * Sourced from the listing response's `parent_tab_total_count` and used
144
- * exclusively to drive the parent tab badges on the UI — every other
145
- * count consumer (pagination, sub-tab chips, in-tab "showing X of Y")
146
- * keeps reading from {@link TransactionsViewUIState.totalCount}.
142
+ * Period-keyed tab badge counts, scoped per {@link TransactionsTab}. Every
143
+ * count here is taken under the filters currently applied to the list
144
+ * (`transaction_group` + Completed `sub_tab` + search), so a badge always
145
+ * matches the rows that tab renders. The active tab is filled from the
146
+ * listing response's `parent_tab_total_count` and the inactive one from its
147
+ * `other_tab_total_count`. Used exclusively to drive the tab badges — every
148
+ * other count consumer (pagination, in-tab "showing X of Y") keeps reading
149
+ * from {@link TransactionsViewUIState.totalCount}.
147
150
  */
148
151
  parentTotalCountByTab: Record<TransactionsTab, Record<MonthYearPeriodId, number>>;
149
152
  selectedTransactionCategorizationCompletedSubTab: CompletedSubTab;
@@ -79,6 +79,13 @@ export interface InvoicingCatalogItemFormView extends SelectorView {
79
79
  */
80
80
  qboAccountHierarchy: NestedAccountHierarchyForReport[];
81
81
  qboClassHierarchy: NestedClassHierarchyForReport[];
82
+ /**
83
+ * The ids within the account tree an income account may be picked from.
84
+ * QuickBooks only accepts an income account on a product, so a price point
85
+ * mapped to anything else — A/R, deferred revenue, a bank account — fails to
86
+ * sync once an invoice carrying it is pushed.
87
+ */
88
+ qboIncomeAccountIds: string[];
82
89
  /**
83
90
  * Loading the item, as distinct from this view's own `fetchState`, which
84
91
  * tracks the save.
@@ -50,6 +50,21 @@ const NOT_STARTED = {
50
50
  error: undefined,
51
51
  fetchState: 'Not-Started',
52
52
  };
53
+ /**
54
+ * The QuickBooks ids a price point's income account may be picked from.
55
+ *
56
+ * Memoized, and collapsing to one shared empty array while QBO is disconnected:
57
+ * the pages turn this into their picker options and re-seed their form defaults
58
+ * when those change, so a fresh array on every store change would reset the form
59
+ * under the user — including mid-save.
60
+ */
61
+ const getInvoicingCatalogIncomeAccountIds = (0, toolkit_1.createSelector)([
62
+ invoicingQboViewSelector_1.getInvoicingQboAccountOptions,
63
+ (state) => (0, invoicingConfigViewSelector_1.getInvoicingConfigView)(state).qboAccountMappingKindOptions,
64
+ invoicingQboViewSelector_1.isInvoicingQboConnected,
65
+ ], (accounts, qboAccountMappingKindOptions, isQboConnected) => isQboConnected
66
+ ? (0, invoicingQboViewSelector_1.invoicingQboAccountIdsForMappingSlot)(accounts, qboAccountMappingKindOptions, invoicingQboViewSelector_1.INVOICING_INCOME_ACCOUNT_MAPPING_SLOT, invoicingQboViewSelector_1.INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES)
67
+ : invoicingQboViewSelector_1.NO_INVOICING_ACCOUNT_IDS);
53
68
  const getInvoicingCatalogItemFormView = (state, id) => {
54
69
  const { editDraftById, error, fetchState, newItemDraft, savedItemId, savedKind, } = state.editInvoicingCatalogItemDetailViewState;
55
70
  const plan = id != null ? (0, planSelector_1.getInvoicingPlanById)(state.invoicingPlanState, id) : undefined;
@@ -100,8 +115,9 @@ const getInvoicingCatalogItemFormView = (state, id) => {
100
115
  isFormLoading: (0, invoicingQboViewSelector_1.isInvoicingAccountingPickerDataLoading)(state) ||
101
116
  (0, invoicingFormDraft_1.isInvoicingFormDraftPending)(editDraftById, id),
102
117
  isQboPickerDataLoading: (0, invoicingQboViewSelector_1.isInvoicingAccountingPickerDataLoading)(state),
103
- qboAccountHierarchy: qboView?.nestedAccountHierarchy ?? [],
104
- qboClassHierarchy: qboView?.nestedClassHierarchy ?? [],
118
+ qboAccountHierarchy: qboView?.nestedAccountHierarchy ?? invoicingQboViewSelector_1.NO_INVOICING_ACCOUNT_HIERARCHY,
119
+ qboClassHierarchy: qboView?.nestedClassHierarchy ?? invoicingQboViewSelector_1.NO_INVOICING_CLASS_HIERARCHY,
120
+ qboIncomeAccountIds: getInvoicingCatalogIncomeAccountIds(state),
105
121
  applicabilityValues: invoicingCatalogItemFormHelper_1.INVOICING_CATALOG_APPLICABILITY_VALUES,
106
122
  applicableItemOptions,
107
123
  localData,
@@ -82,8 +82,9 @@ export interface InvoicingCouponFormView extends EditInvoicingCouponDetailView {
82
82
  /**
83
83
  * The account tree the discounts-account picker renders, and the QuickBooks
84
84
  * ids within it the discounts slot accepts. Two values rather than a filtered
85
- * tree: the picker groups by parent, and dropping a parent that is not itself
86
- * an income account would take its income children with it.
85
+ * tree: the picker flattens the tree into options by walking each node's
86
+ * children, so dropping a parent that is not itself an income account would
87
+ * drop its income descendants with it.
87
88
  *
88
89
  * Empty until QBO is connected — the form must not offer a mapping that
89
90
  * cannot be pushed.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getInvoicingCouponFormView = exports.getEditInvoicingCouponDetailViewState = void 0;
4
+ const toolkit_1 = require("@reduxjs/toolkit");
4
5
  const reduceFetchState_1 = require("../../../commonStateTypes/reduceFetchState");
5
6
  const couponSelector_1 = require("../../../entity/invoicing/coupon/couponSelector");
6
7
  const invoicingCommonPayload_1 = require("../../../entity/invoicing/invoicingCommonPayload");
@@ -14,6 +15,21 @@ const editInvoicingCouponDetailViewState_1 = require("./editInvoicingCouponDetai
14
15
  * the id of the coupon last saved, and — when a `couponId` is passed (edit
15
16
  * mode) — the existing coupon entity to prefill the form.
16
17
  */
18
+ /**
19
+ * The QuickBooks ids the discounts account may be picked from.
20
+ *
21
+ * Memoized, and collapsing to one shared empty array while QBO is disconnected:
22
+ * the form turns this into its picker options and re-seeds its defaults when
23
+ * those change, so a fresh array on every store change would reset the form
24
+ * under the user until their first edit.
25
+ */
26
+ const getInvoicingCouponDiscountsAccountIds = (0, toolkit_1.createSelector)([
27
+ invoicingQboViewSelector_1.getInvoicingQboAccountOptions,
28
+ (state) => (0, invoicingConfigViewSelector_1.getInvoicingConfigView)(state).qboAccountMappingKindOptions,
29
+ invoicingQboViewSelector_1.isInvoicingQboConnected,
30
+ ], (accounts, qboAccountMappingKindOptions, isQboConnected) => isQboConnected
31
+ ? (0, invoicingQboViewSelector_1.invoicingQboAccountIdsForMappingSlot)(accounts, qboAccountMappingKindOptions, invoicingQboViewSelector_1.INVOICING_DISCOUNTS_ACCOUNT_MAPPING_SLOT, invoicingQboViewSelector_1.INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES)
32
+ : invoicingQboViewSelector_1.NO_INVOICING_ACCOUNT_IDS);
17
33
  const NOT_STARTED = {
18
34
  error: undefined,
19
35
  fetchState: 'Not-Started',
@@ -55,14 +71,6 @@ const getEditInvoicingCouponDetailViewState = (state, couponId) => {
55
71
  };
56
72
  };
57
73
  exports.getEditInvoicingCouponDetailViewState = getEditInvoicingCouponDetailViewState;
58
- /**
59
- * The account types the discounts slot falls back to when the config has no
60
- * `discounts_account` kind. A QuickBooks discount line posts to an income
61
- * account; offering every account instead would let a save be rejected.
62
- */
63
- const DEFAULT_DISCOUNTS_ACCOUNT_TYPES = 'Income';
64
- /** The account-mapping slot a coupon's discounts account shares with settings. */
65
- const DISCOUNTS_ACCOUNT_MAPPING_SLOT = 'discounts_account';
66
74
  /**
67
75
  * Bundled view for the discount form screen: the edit view (save state + the
68
76
  * coupon to prefill in edit mode), the draft `localData` (seeded from the
@@ -78,7 +86,7 @@ const getInvoicingCouponFormView = (state, couponId) => {
78
86
  const localData = couponId != null
79
87
  ? (0, invoicingFormDraft_1.getInvoicingFormDraftLocalData)(editDraftById, couponId, editInvoicingCouponDetailViewState_1.initialInvoicingCouponFormLocalData)
80
88
  : newCouponDraft;
81
- const { billingPeriodUnitOptions, discountApplyOnOptions, discountDurationTypeOptions, discountTypeOptions, qboAccountMappingKindOptions, } = (0, invoicingConfigViewSelector_1.getInvoicingConfigView)(state);
89
+ const { billingPeriodUnitOptions, discountApplyOnOptions, discountDurationTypeOptions, discountTypeOptions, } = (0, invoicingConfigViewSelector_1.getInvoicingConfigView)(state);
82
90
  const isQboConnected = (0, invoicingQboViewSelector_1.isInvoicingQboConnected)(state);
83
91
  const qboView = isQboConnected ? (0, invoicingQboViewSelector_1.getInvoicingQboView)(state) : undefined;
84
92
  const isQboPickerDataLoading = (0, invoicingQboViewSelector_1.isInvoicingAccountingPickerDataLoading)(state);
@@ -88,14 +96,6 @@ const getInvoicingCouponFormView = (state, couponId) => {
88
96
  const isCreateSeedPending = couponId == null &&
89
97
  (settingsFetchState === 'Not-Started' ||
90
98
  settingsFetchState === 'In-Progress');
91
- // The account types the discounts slot accepts come from the same config the
92
- // QBO settings page reads, so the two pickers offer the same accounts and
93
- // widening the slot stays a config change. Matched on the slot rather than
94
- // the code as written: a config that names the kind `discounts_account_id`
95
- // is the same slot, and missing it here would quietly fall back to the
96
- // default and offer a different set than the settings page does.
97
- const discountsAccountTypes = qboAccountMappingKindOptions.find((kindOption) => (0, invoicingCommonPayload_1.toInvoicingQboAccountMappingSlot)(kindOption.code) ===
98
- DISCOUNTS_ACCOUNT_MAPPING_SLOT)?.accountTypes ?? DEFAULT_DISCOUNTS_ACCOUNT_TYPES;
99
99
  return {
100
100
  ...base,
101
101
  applyOnOptions: discountApplyOnOptions.map(invoicingCommonPayload_1.toInvoicingFormOption),
@@ -134,8 +134,8 @@ const getInvoicingCouponFormView = (state, couponId) => {
134
134
  },
135
135
  mode: couponId != null ? 'edit' : 'create',
136
136
  periodUnitOptions: billingPeriodUnitOptions.map(invoicingCommonPayload_1.toInvoicingFormOption),
137
- qboAccountHierarchy: qboView?.nestedAccountHierarchy ?? [],
138
- qboIncomeAccountIds: (0, invoicingQboViewSelector_1.invoicingAccountsForQboAccountTypes)(qboView?.accounts ?? [], discountsAccountTypes).map((account) => account.id),
137
+ qboAccountHierarchy: qboView?.nestedAccountHierarchy ?? invoicingQboViewSelector_1.NO_INVOICING_ACCOUNT_HIERARCHY,
138
+ qboIncomeAccountIds: getInvoicingCouponDiscountsAccountIds(state),
139
139
  };
140
140
  };
141
141
  exports.getInvoicingCouponFormView = getInvoicingCouponFormView;
@@ -3,6 +3,7 @@ import { NestedClassHierarchyForReport } from '../../../commonStateTypes/classes
3
3
  import { FetchStateAndError } from '../../../commonStateTypes/common';
4
4
  import { AccountBase } from '../../../entity/account/accountState';
5
5
  import { ClassBase } from '../../../entity/class/classState';
6
+ import { InvoicingQboAccountMappingKindOption } from '../../../entity/invoicing/invoicingCommonPayload';
6
7
  import { RootState } from '../../../rootStateTypes';
7
8
  import { InvoicingQboConnection, InvoicingQboSyncHealth } from './invoicingQboViewPayload';
8
9
  export declare const INVOICING_ACCOUNT_LIST_KEY: "accountList";
@@ -60,6 +61,17 @@ export interface InvoicingQboView {
60
61
  connection?: InvoicingQboConnection;
61
62
  syncHealth?: InvoicingQboSyncHealth;
62
63
  }
64
+ /**
65
+ * Accounts as the invoicing pickers need them, from the shared
66
+ * accounting-service list, so whatever another screen already loaded is reused.
67
+ * `qboId` is the submitted value because that is what the invoicing API stores;
68
+ * an account without one cannot be posted, so it is dropped rather than offered.
69
+ *
70
+ * Memoized on the two slices it reads: the pickers and the lists derived from
71
+ * them are handed straight to forms, and a fresh array on every store change
72
+ * re-seeds a form the user is editing.
73
+ */
74
+ export declare const getInvoicingQboAccountOptions: (state: RootState) => InvoicingAccountingOption[];
63
75
  /**
64
76
  * The accounts valid for one QBO mapping kind, whose `accountTypes` is a
65
77
  * comma-separated list of QuickBooks type names. An unrecognised list yields no
@@ -67,6 +79,43 @@ export interface InvoicingQboView {
67
79
  * required would post a mapping QuickBooks rejects.
68
80
  */
69
81
  export declare const invoicingAccountsForQboAccountTypes: (accounts: InvoicingAccountingOption[], accountTypes: string) => InvoicingAccountingOption[];
82
+ /**
83
+ * The account-mapping slot a catalog price point's income account shares with
84
+ * the settings page, and the one a discount's account shares with it.
85
+ */
86
+ export declare const INVOICING_INCOME_ACCOUNT_MAPPING_SLOT = "default_income_account";
87
+ export declare const INVOICING_DISCOUNTS_ACCOUNT_MAPPING_SLOT = "discounts_account";
88
+ /**
89
+ * The account types a slot falls back to when the config carries no kind for
90
+ * it. QuickBooks posts a product, a price and a discount line to an income
91
+ * account; offering every account instead would let a save be rejected.
92
+ */
93
+ export declare const INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES = "Income";
94
+ /**
95
+ * The QuickBooks ids a mapping slot's picker may offer, from the account types
96
+ * the config gives that slot — so every picker filling the same slot offers the
97
+ * same accounts, and widening one stays a config change.
98
+ *
99
+ * Ids rather than a filtered tree: the pickers flatten the tree into options by
100
+ * walking each node's children, so dropping a parent that is not itself of the
101
+ * wanted type would drop its matching descendants with it.
102
+ *
103
+ * Matched on the slot rather than the code as written, since a config naming
104
+ * the kind `discounts_account_id` means the same slot. A slot the config does
105
+ * not carry falls back to `defaultAccountTypes` rather than to every account.
106
+ */
107
+ export declare const invoicingQboAccountIdsForMappingSlot: (accounts: InvoicingAccountingOption[], accountMappingKindOptions: InvoicingQboAccountMappingKindOption[], mappingSlot: string, defaultAccountTypes: string) => string[];
108
+ /**
109
+ * Stable empties for a tenant with no QBO connection.
110
+ *
111
+ * `?? []` builds a fresh array per call, and the invoicing forms re-seed their
112
+ * fields whenever their option lists change identity — so a form open against a
113
+ * disconnected tenant would reset itself on every unrelated store change, until
114
+ * the first edit disarms the re-seed.
115
+ */
116
+ export declare const NO_INVOICING_ACCOUNT_HIERARCHY: NestedAccountHierarchyForReport[];
117
+ export declare const NO_INVOICING_CLASS_HIERARCHY: NestedClassHierarchyForReport[];
118
+ export declare const NO_INVOICING_ACCOUNT_IDS: string[];
70
119
  /**
71
120
  * Single bundled view for the QBO integration settings screen — the
72
121
  * connection, the account and class options, sync health, and the fetch/action
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getInvoicingQboView = exports.invoicingAccountsForQboAccountTypes = exports.getInvoicingCatalogAccountingLookup = exports.invoicingQboClassByQboId = exports.invoicingQboAccountByQboId = exports.getInvoicingQboConnectionFetchState = exports.isInvoicingAccountingPickerDataLoading = exports.isInvoicingQboConnected = exports.INVOICING_ACCOUNT_LIST_KEY = void 0;
3
+ exports.getInvoicingQboView = exports.NO_INVOICING_ACCOUNT_IDS = exports.NO_INVOICING_CLASS_HIERARCHY = exports.NO_INVOICING_ACCOUNT_HIERARCHY = exports.invoicingQboAccountIdsForMappingSlot = exports.INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES = exports.INVOICING_DISCOUNTS_ACCOUNT_MAPPING_SLOT = exports.INVOICING_INCOME_ACCOUNT_MAPPING_SLOT = exports.invoicingAccountsForQboAccountTypes = exports.getInvoicingQboAccountOptions = exports.getInvoicingCatalogAccountingLookup = exports.invoicingQboClassByQboId = exports.invoicingQboAccountByQboId = exports.getInvoicingQboConnectionFetchState = exports.isInvoicingAccountingPickerDataLoading = exports.isInvoicingQboConnected = exports.INVOICING_ACCOUNT_LIST_KEY = void 0;
4
+ const toolkit_1 = require("@reduxjs/toolkit");
5
+ const invoicingCommonPayload_1 = require("../../../entity/invoicing/invoicingCommonPayload");
4
6
  const accountListSelector_1 = require("../../accountList/accountListSelector");
5
7
  const classListSelector_1 = require("../../classList/classListSelector");
6
8
  // The shared bucket, fetched with the same arguments as the other consumers so
@@ -91,14 +93,20 @@ const getInvoicingCatalogAccountingLookup = (state) => ({
91
93
  });
92
94
  exports.getInvoicingCatalogAccountingLookup = getInvoicingCatalogAccountingLookup;
93
95
  /**
94
- * Accounts and classes as the invoicing pickers need them. Both come from the
95
- * shared accounting-service lists, so whatever another screen already loaded
96
- * is reused. `qboId` is the submitted value because that is what the invoicing
97
- * API stores; an account without one cannot be posted, so it is dropped rather
98
- * than offered.
96
+ * Accounts as the invoicing pickers need them, from the shared
97
+ * accounting-service list, so whatever another screen already loaded is reused.
98
+ * `qboId` is the submitted value because that is what the invoicing API stores;
99
+ * an account without one cannot be posted, so it is dropped rather than offered.
100
+ *
101
+ * Memoized on the two slices it reads: the pickers and the lists derived from
102
+ * them are handed straight to forms, and a fresh array on every store change
103
+ * re-seeds a form the user is editing.
99
104
  */
100
- const invoicingAccountingOptions = (state) => {
101
- const { accounts } = (0, accountListSelector_1.getAccountList)(state.accountState, state.accountListState, exports.INVOICING_ACCOUNT_LIST_KEY);
105
+ exports.getInvoicingQboAccountOptions = (0, toolkit_1.createSelector)([
106
+ (state) => state.accountState,
107
+ (state) => state.accountListState,
108
+ ], (accountState, accountListState) => {
109
+ const { accounts } = (0, accountListSelector_1.getAccountList)(accountState, accountListState, exports.INVOICING_ACCOUNT_LIST_KEY);
102
110
  return accounts.flatMap((account) => {
103
111
  if (account.qboId == null || account.isDeleted === true) {
104
112
  return [];
@@ -111,7 +119,7 @@ const invoicingAccountingOptions = (state) => {
111
119
  },
112
120
  ];
113
121
  });
114
- };
122
+ });
115
123
  /**
116
124
  * Normalises an account-type name for comparison. The config's mapping kinds
117
125
  * name QuickBooks types the way QuickBooks displays them ("Other Current
@@ -148,6 +156,47 @@ const invoicingAccountsForQboAccountTypes = (accounts, accountTypes) => {
148
156
  wanted.has(normalizeAccountTypeName(account.accountType)));
149
157
  };
150
158
  exports.invoicingAccountsForQboAccountTypes = invoicingAccountsForQboAccountTypes;
159
+ /**
160
+ * The account-mapping slot a catalog price point's income account shares with
161
+ * the settings page, and the one a discount's account shares with it.
162
+ */
163
+ exports.INVOICING_INCOME_ACCOUNT_MAPPING_SLOT = 'default_income_account';
164
+ exports.INVOICING_DISCOUNTS_ACCOUNT_MAPPING_SLOT = 'discounts_account';
165
+ /**
166
+ * The account types a slot falls back to when the config carries no kind for
167
+ * it. QuickBooks posts a product, a price and a discount line to an income
168
+ * account; offering every account instead would let a save be rejected.
169
+ */
170
+ exports.INVOICING_DEFAULT_INCOME_ACCOUNT_TYPES = 'Income';
171
+ /**
172
+ * The QuickBooks ids a mapping slot's picker may offer, from the account types
173
+ * the config gives that slot — so every picker filling the same slot offers the
174
+ * same accounts, and widening one stays a config change.
175
+ *
176
+ * Ids rather than a filtered tree: the pickers flatten the tree into options by
177
+ * walking each node's children, so dropping a parent that is not itself of the
178
+ * wanted type would drop its matching descendants with it.
179
+ *
180
+ * Matched on the slot rather than the code as written, since a config naming
181
+ * the kind `discounts_account_id` means the same slot. A slot the config does
182
+ * not carry falls back to `defaultAccountTypes` rather than to every account.
183
+ */
184
+ const invoicingQboAccountIdsForMappingSlot = (accounts, accountMappingKindOptions, mappingSlot, defaultAccountTypes) => {
185
+ const accountTypes = accountMappingKindOptions.find((kindOption) => (0, invoicingCommonPayload_1.toInvoicingQboAccountMappingSlot)(kindOption.code) === mappingSlot)?.accountTypes ?? defaultAccountTypes;
186
+ return (0, exports.invoicingAccountsForQboAccountTypes)(accounts, accountTypes).map((account) => account.id);
187
+ };
188
+ exports.invoicingQboAccountIdsForMappingSlot = invoicingQboAccountIdsForMappingSlot;
189
+ /**
190
+ * Stable empties for a tenant with no QBO connection.
191
+ *
192
+ * `?? []` builds a fresh array per call, and the invoicing forms re-seed their
193
+ * fields whenever their option lists change identity — so a form open against a
194
+ * disconnected tenant would reset itself on every unrelated store change, until
195
+ * the first edit disarms the re-seed.
196
+ */
197
+ exports.NO_INVOICING_ACCOUNT_HIERARCHY = [];
198
+ exports.NO_INVOICING_CLASS_HIERARCHY = [];
199
+ exports.NO_INVOICING_ACCOUNT_IDS = [];
151
200
  /** Classes as the invoicing pickers need them, from the shared class list. */
152
201
  const invoicingAccountingClassOptions = (state) => {
153
202
  const { classes } = (0, classListSelector_1.getClassList)(state.classState, state.classListState);
@@ -169,7 +218,7 @@ const getInvoicingQboView = (state) => {
169
218
  const qboState = state.invoicingQboViewState;
170
219
  const accountList = state.accountListState.byReportId[exports.INVOICING_ACCOUNT_LIST_KEY];
171
220
  return {
172
- accounts: invoicingAccountingOptions(state),
221
+ accounts: (0, exports.getInvoicingQboAccountOptions)(state),
173
222
  accountsFetchState: {
174
223
  fetchState: accountList.fetchState,
175
224
  error: accountList.error,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeniai/client-epic-state",
3
- "version": "5.2.52",
3
+ "version": "5.2.53",
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",