import { ref, computed } from 'vue'; import { defineStore } from 'pinia'; import { useSettingsStore } from './setting.ts'; import { useUserStore } from './user.ts'; import { useAccountsStore } from './account.ts'; import { useTransactionCategoriesStore } from './transactionCategory.ts'; import { useOverviewStore } from './overview.ts'; import { useStatisticsStore } from './statistics.ts'; import { useExplorersStore } from '@/stores/explorer.ts'; import { useExchangeRatesStore } from './exchangeRates.ts'; import { type BeforeResolveFunction, itemAndIndex, entries, keys } from '@/core/base.ts'; import { type TextualYearMonth, DateRange } from '@/core/datetime.ts'; import { CategoryType } from '@/core/category.ts'; import type { ImportFileTypeSupportedAdditionalOptions } from '@/core/file.ts'; import { TransactionType, TransactionTagFilterType } from '@/core/transaction.ts'; import { TRANSACTION_MIN_AMOUNT, TRANSACTION_MAX_AMOUNT } from '@/consts/transaction.ts'; import { type TransactionDraft, type TransactionCreateRequest, type TransactionInfoResponse, type TransactionPageWrapper, type TransactionReconciliationStatementResponse, Transaction, TransactionTagFilter, EMPTY_TRANSACTION_RESULT } from '@/models/transaction.ts'; import type { TransactionPictureInfoBasicResponse } from '@/models/transaction_picture_info.ts'; import { type ImportTransactionResponsePageWrapper, ImportTransaction } from '@/models/imported_transaction.ts'; import { type ExportTransactionDataRequest } from '@/models/data_management.ts'; import type { RecognizedReceiptImageResponse } from '@/models/large_language_model.ts'; import { getUserTransactionDraft, updateUserTransactionDraft, clearUserTransactionDraft } from '@/lib/userstate.ts'; import { isDefined, isNumber, isString, isArray1SubsetOfArray2, getObjectOwnFieldCount, splitItemsToMap, countSplitItems } from '@/lib/common.ts'; import { parseDateTimeFromUnixTimeWithTimezoneOffset } from '@/lib/datetime.ts'; import { getAmountWithDecimalNumberCount } from '@/lib/numeral.ts'; import { getCurrencyFraction } from '@/lib/currency.ts'; import { getFirstVisibleCategoryId } from '@/lib/category.ts'; import services, { type ApiResponsePromise } from '@/lib/services.ts'; import logger from '@/lib/logger.ts'; export interface TransactionListPartialFilter { dateType?: number; maxTime?: number; minTime?: number; type?: number; categoryIds?: string; accountIds?: string; tagFilter?: string; amountFilter?: string; keyword?: string; } export interface TransactionListFilter extends TransactionListPartialFilter { dateType: number; maxTime: number; minTime: number; type: number; categoryIds: string; accountIds: string; tagFilter: string; amountFilter: string; keyword: string; } export interface TransactionTotalAmount { expense: number; incompleteExpense: boolean; income: number; incompleteIncome: boolean; } export interface TransactionMonthList { readonly year: number; readonly month: number; // 1-based (1 = January, 12 = December) readonly yearDashMonth: TextualYearMonth; opened: boolean; readonly items: Transaction[]; readonly totalAmount: TransactionTotalAmount; readonly dailyTotalAmounts: Record; } export const useTransactionsStore = defineStore('transactions', () => { const settingsStore = useSettingsStore(); const userStore = useUserStore(); const accountsStore = useAccountsStore(); const transactionCategoriesStore = useTransactionCategoriesStore(); const overviewStore = useOverviewStore(); const statisticsStore = useStatisticsStore(); const explorersStore = useExplorersStore(); const exchangeRatesStore = useExchangeRatesStore(); const transactionDraft = ref(getUserTransactionDraft()); const transactionsFilter = ref({ dateType: DateRange.All.type, maxTime: 0, minTime: 0, type: 0, categoryIds: '', accountIds: '', tagFilter: '', amountFilter: '', keyword: '' }); const transactions = ref([]); const transactionsNextTimeId = ref(0); const transactionListStateInvalid = ref(true); const transactionReconciliationStatementStateInvalid = ref(true); const allFilterCategoryIds = computed>(() => splitItemsToMap(transactionsFilter.value.categoryIds, ',')); const allFilterAccountIds = computed>(() => splitItemsToMap(transactionsFilter.value.accountIds, ',')); const allFilterTagIds = computed>(() => { const tagFilters: TransactionTagFilter[] = TransactionTagFilter.parse(transactionsFilter.value.tagFilter); const allTagIdsMap: Record = {}; for (const tagFilter of tagFilters) { let state: boolean = true; if (tagFilter.type === TransactionTagFilterType.HasAny || tagFilter.type === TransactionTagFilterType.HasAll) { state = true; } else if (tagFilter.type === TransactionTagFilterType.NotHasAny || tagFilter.type === TransactionTagFilterType.NotHasAll) { state = false; } else { continue; } for (const tagId of tagFilter.tagIds) { allTagIdsMap[tagId] = state; } } return allTagIdsMap; }); const allFilterCategoryIdsCount = computed(() => countSplitItems(transactionsFilter.value.categoryIds, ',')); const allFilterAccountIdsCount = computed(() => countSplitItems(transactionsFilter.value.accountIds, ',')); const allFilterTagIdsCount = computed(() => getObjectOwnFieldCount(allFilterTagIds.value)); const noTransaction = computed(() => { for (const transactionMonthList of transactions.value) { for (const transaction of transactionMonthList.items) { if (transaction) { return false; } } } return true; }); const hasMoreTransaction = computed(() => { return transactionsNextTimeId.value > 0; }); function loadTransactionList({ transactionPageWrapper, reload, autoExpand, defaultCurrency, nextTimeSequenceId }: { transactionPageWrapper: TransactionPageWrapper, reload: boolean, autoExpand: boolean, defaultCurrency: string, nextTimeSequenceId?: number }): void { if (reload) { transactions.value = []; } if (transactionPageWrapper.items && transactionPageWrapper.items.length) { let currentMonthListIndex = -1; let currentMonthList: TransactionMonthList | null = null; for (const [item, index] of itemAndIndex(transactionPageWrapper.items)) { fillTransactionObject(item); const transactionTime = parseDateTimeFromUnixTimeWithTimezoneOffset(item.time, item.utcOffset); const transactionYear = transactionTime.getGregorianCalendarYear(); const transactionMonth = transactionTime.getGregorianCalendarMonth(); const transactionYearDashMonth = transactionTime.getGregorianCalendarYearDashMonth(); if (index === 0 && transactions.value.length > 0) { const lastMonthList = transactions.value[transactions.value.length - 1] as TransactionMonthList; if (lastMonthList.totalAmount.incompleteExpense || lastMonthList.totalAmount.incompleteIncome) { // calculate the total amount of last month which has incomplete total amount before starting to process a new request calculateMonthTotalAmount(lastMonthList, defaultCurrency, transactionsFilter.value.accountIds, false); } } if (currentMonthList && currentMonthList.year === transactionYear && currentMonthList.month === transactionMonth) { currentMonthList.items.push(Object.freeze(item)); if (index === transactionPageWrapper.items.length - 1) { // calculate the total amount of current month when processing the last transaction item of this request calculateMonthTotalAmount(currentMonthList, defaultCurrency, transactionsFilter.value.accountIds, true); } continue; } for (let j = currentMonthListIndex + 1; j < transactions.value.length; j++) { if (transactions.value[j]!.year === transactionYear && transactions.value[j]!.month === transactionMonth) { currentMonthListIndex = j; currentMonthList = transactions.value[j] as TransactionMonthList; break; } } if (!currentMonthList || currentMonthList.year !== transactionYear || currentMonthList.month !== transactionMonth) { // calculate the total amount of current month when processing the first transaction item of the next month calculateMonthTotalAmount(currentMonthList, defaultCurrency, transactionsFilter.value.accountIds, false); const monthList: TransactionMonthList = { year: transactionYear, month: transactionMonth, yearDashMonth: transactionYearDashMonth, opened: autoExpand, items: [], totalAmount: { expense: 0, incompleteExpense: true, income: 0, incompleteIncome: true }, dailyTotalAmounts: {} }; transactions.value.push(monthList); currentMonthListIndex = transactions.value.length - 1; currentMonthList = transactions.value[transactions.value.length - 1] as TransactionMonthList; } currentMonthList.items.push(Object.freeze(item)); // init the total amount struct of current month when processing the first transaction item of current month calculateMonthTotalAmount(currentMonthList, defaultCurrency, transactionsFilter.value.accountIds, true); } } if (nextTimeSequenceId) { transactionsNextTimeId.value = nextTimeSequenceId; } else { calculateMonthTotalAmount(transactions.value[transactions.value.length - 1] as TransactionMonthList, defaultCurrency, transactionsFilter.value.accountIds, false); transactionsNextTimeId.value = -1; } } function updateTransactionInTransactionList({ currentTransaction, defaultCurrency }: { currentTransaction: Transaction, defaultCurrency: string }): void { const transactionTime = parseDateTimeFromUnixTimeWithTimezoneOffset(currentTransaction.time, currentTransaction.utcOffset); const transactionYear = transactionTime.getGregorianCalendarYear(); const transactionMonth = transactionTime.getGregorianCalendarMonth(); for (const [transactionMonthList, monthIndex] of itemAndIndex(transactions.value)) { if (!transactionMonthList.items) { continue; } for (const [transaction, transactionIndex] of itemAndIndex(transactionMonthList.items)) { if (transaction.id === currentTransaction.id) { fillTransactionObject(currentTransaction); if (transactionYear !== transactionMonthList.year || transactionMonth !== transactionMonthList.month || currentTransaction.gregorianCalendarDayOfMonth !== transaction.gregorianCalendarDayOfMonth) { transactionListStateInvalid.value = true; return; } if ((transactionsFilter.value.categoryIds && !allFilterCategoryIds.value[currentTransaction.categoryId]) || (transactionsFilter.value.accountIds && !allFilterAccountIds.value[currentTransaction.sourceAccountId] && !allFilterAccountIds.value[currentTransaction.destinationAccountId] && (!currentTransaction.sourceAccount || !allFilterAccountIds.value[currentTransaction.sourceAccount.parentId]) && (!currentTransaction.destinationAccount || !allFilterAccountIds.value[currentTransaction.destinationAccount.parentId]) ) ) { transactionMonthList.items.splice(transactionIndex, 1); } else { transactionMonthList.items.splice(transactionIndex, 1, currentTransaction); } if (transactionMonthList.items.length < 1) { transactions.value.splice(monthIndex, 1); } else { calculateMonthTotalAmount(transactionMonthList, defaultCurrency, transactionsFilter.value.accountIds, monthIndex >= transactions.value.length - 1 && transactionsNextTimeId.value > 0); } return; } } } } function removeTransactionFromTransactionList({ currentTransaction, defaultCurrency }: { currentTransaction: TransactionInfoResponse, defaultCurrency: string }): void { for (const [transactionMonthList, monthIndex] of itemAndIndex(transactions.value)) { if (!transactionMonthList.items || transactionMonthList.items[0]!.time < currentTransaction.time || transactionMonthList.items[transactionMonthList.items.length - 1]!.time > currentTransaction.time) { continue; } for (const [transaction, transactionIndex] of itemAndIndex(transactionMonthList.items)) { if (transaction.id === currentTransaction.id) { transactionMonthList.items.splice(transactionIndex, 1); } } if (transactionMonthList.items.length < 1) { transactions.value.splice(monthIndex, 1); } else { calculateMonthTotalAmount(transactionMonthList, defaultCurrency, transactionsFilter.value.accountIds, monthIndex >= transactions.value.length - 1 && transactionsNextTimeId.value > 0); } } } function calculateMonthTotalAmount(transactionMonthList: TransactionMonthList | null, defaultCurrency: string, accountIds: string, incomplete: boolean): void { if (!transactionMonthList) { return; } let totalExpense = 0; let totalIncome = 0; let hasUnCalculatedTotalExpense = false; let hasUnCalculatedTotalIncome = false; const dailyTotalAmounts: Record = {}; const allAccountIdsMap: Record = {}; let totalAccountIdsCount = 0; if (accountIds && accountIds !== '0') { const allAccountIdsArray = accountIds.split(','); for (const accountId of allAccountIdsArray) { if (accountId) { allAccountIdsMap[accountId] = true; totalAccountIdsCount++; } } } for (const transaction of transactionMonthList.items) { const transactionDay = isNumber(transaction.gregorianCalendarDayOfMonth) ? transaction.gregorianCalendarDayOfMonth.toString() : '0'; let dailyTotalAmount = dailyTotalAmounts[transactionDay]; if (!dailyTotalAmount) { dailyTotalAmount = { expense: 0, incompleteExpense: false, income: 0, incompleteIncome: false }; dailyTotalAmounts[transactionDay] = dailyTotalAmount; } let amount = transaction.sourceAmount; let account = transaction.sourceAccount; if (totalAccountIdsCount > 0 && transaction.destinationAccount && (!allAccountIdsMap[transaction.sourceAccount?.id || ''] && !allAccountIdsMap[transaction.sourceAccount?.parentId || '']) && (allAccountIdsMap[transaction.destinationAccount.id] || allAccountIdsMap[transaction.destinationAccount.parentId])) { amount = transaction.destinationAmount; account = transaction.destinationAccount; } if (!account) { continue; } if (account.currency !== defaultCurrency) { const balance = exchangeRatesStore.getExchangedAmount(amount, account.currency, defaultCurrency); if (!isNumber(balance)) { if (transaction.type === TransactionType.Expense) { hasUnCalculatedTotalExpense = true; dailyTotalAmount.incompleteExpense = true; } else if (transaction.type === TransactionType.Income) { hasUnCalculatedTotalIncome = true; dailyTotalAmount.incompleteIncome = true; } continue; } amount = balance; } if (transaction.type === TransactionType.Expense) { totalExpense += amount; dailyTotalAmount.expense += amount; } else if (transaction.type === TransactionType.Income) { totalIncome += amount; dailyTotalAmount.income += amount; } else if (transaction.type === TransactionType.Transfer && totalAccountIdsCount > 0) { if (allAccountIdsMap[transaction.sourceAccountId] && allAccountIdsMap[transaction.destinationAccountId]) { // Do Nothing } else if (transaction.sourceAccount && transaction.destinationAccount && allAccountIdsMap[transaction.sourceAccount.parentId] && allAccountIdsMap[transaction.destinationAccount.parentId]) { // Do Nothing } else if (transaction.sourceAccount && allAccountIdsMap[transaction.sourceAccount.parentId] && allAccountIdsMap[transaction.destinationAccountId]) { // Do Nothing } else if (transaction.destinationAccount && allAccountIdsMap[transaction.sourceAccountId] && allAccountIdsMap[transaction.destinationAccount.parentId]) { // Do Nothing } else if (allAccountIdsMap[transaction.sourceAccountId] || (transaction.sourceAccount && allAccountIdsMap[transaction.sourceAccount.parentId])) { totalExpense += amount; dailyTotalAmount.expense += amount; } else if (allAccountIdsMap[transaction.destinationAccountId] || (transaction.destinationAccount && allAccountIdsMap[transaction.destinationAccount.parentId])) { totalIncome += amount; dailyTotalAmount.income += amount; } } } transactionMonthList.totalAmount.expense = Math.trunc(totalExpense); transactionMonthList.totalAmount.incompleteExpense = incomplete || hasUnCalculatedTotalExpense; transactionMonthList.totalAmount.income = Math.trunc(totalIncome); transactionMonthList.totalAmount.incompleteIncome = incomplete || hasUnCalculatedTotalIncome; for (const day of keys(transactionMonthList.dailyTotalAmounts)) { delete transactionMonthList.dailyTotalAmounts[day]; } for (const [day, dailyTotalAmount] of entries(dailyTotalAmounts)) { transactionMonthList.dailyTotalAmounts[day] = { expense: Math.trunc(dailyTotalAmount.expense), incompleteExpense: incomplete || dailyTotalAmount.incompleteExpense, income: Math.trunc(dailyTotalAmount.income), incompleteIncome: incomplete || dailyTotalAmount.incompleteIncome }; } } function fillTransactionObject(transaction: Transaction): void { if (!transaction) { return; } const transactionTime = parseDateTimeFromUnixTimeWithTimezoneOffset(transaction.time, transaction.utcOffset); transaction.setDisplayDate(transactionTime.getGregorianCalendarYearDashMonthDashDay(), transactionTime.getGregorianCalendarDay(), transactionTime.getWeekDay()); if (transaction.sourceAccountId) { transaction.setSourceAccount(accountsStore.allAccountsMap[transaction.sourceAccountId]); } if (transaction.destinationAccountId) { transaction.setDestinationAccount(accountsStore.allAccountsMap[transaction.destinationAccountId]); } if (transaction.categoryId) { transaction.setCategory(transactionCategoriesStore.allTransactionCategoriesMap[transaction.categoryId]); } } function initTransactionDraft(): void { if (settingsStore.appSettings.autoSaveTransactionDraft === 'enabled' || settingsStore.appSettings.autoSaveTransactionDraft === 'confirmation') { transactionDraft.value = getUserTransactionDraft(); } else { transactionDraft.value = null; } } function isTransactionDraftModified(transaction?: Transaction, initAmount?: number, initCategoryId?: string, initAccountId?: string, initTagIds?: string, firstVisibleAccountId?: string): boolean { if (!transaction) { return false; } if (transaction.sourceAmount !== 0 && transaction.sourceAmount !== initAmount) { return true; } if (transaction.type === TransactionType.Transfer && transaction.destinationAmount !== 0) { return true; } if (transaction.sourceAccountId && transaction.sourceAccountId !== '0' && transaction.sourceAccountId !== userStore.currentUserDefaultAccountId && ((userStore.currentUserDefaultAccountId !== '' && userStore.currentUserDefaultAccountId !== '0') || transaction.sourceAccountId !== firstVisibleAccountId) && transaction.sourceAccountId !== initAccountId) { return true; } if (transaction.type === TransactionType.Transfer && transaction.destinationAccountId && transaction.destinationAccountId !== '0' && transaction.destinationAccountId !== userStore.currentUserDefaultAccountId && transaction.destinationAccountId !== initAccountId) { return true; } const allCategories = transactionCategoriesStore.allTransactionCategories; if (allCategories) { if (transaction.type === TransactionType.Expense) { const defaultCategoryId = getFirstVisibleCategoryId(allCategories[CategoryType.Expense]); if (transaction.expenseCategoryId && transaction.expenseCategoryId !== '0' && transaction.expenseCategoryId !== defaultCategoryId && transaction.expenseCategoryId !== initCategoryId) { return true; } } else if (transaction.type === TransactionType.Income) { const defaultCategoryId = getFirstVisibleCategoryId(allCategories[CategoryType.Income]); if (transaction.incomeCategoryId && transaction.incomeCategoryId !== '0' && transaction.incomeCategoryId !== defaultCategoryId && transaction.incomeCategoryId !== initCategoryId) { return true; } } else if (transaction.type === TransactionType.Transfer) { const defaultCategoryId = getFirstVisibleCategoryId(allCategories[CategoryType.Transfer]); if (transaction.transferCategoryId && transaction.transferCategoryId !== '0' && transaction.transferCategoryId !== defaultCategoryId && transaction.transferCategoryId !== initCategoryId) { return true; } } } if (transaction.hideAmount) { return true; } if (transaction.tagIds && transaction.tagIds.length > 0) { return !initTagIds || !isArray1SubsetOfArray2(transaction.tagIds, initTagIds.split(',')); } if (transaction.pictures && transaction.pictures.length > 0) { return true; } if (transaction.comment && transaction.comment.trim()) { return true; } return false; } function saveTransactionDraft(transaction?: Transaction, initAmount?: number, initCategoryId?: string, initAccountId?: string, initTagIds?: string, firstVisibleAccountId?: string): void { if (settingsStore.appSettings.autoSaveTransactionDraft !== 'enabled' && settingsStore.appSettings.autoSaveTransactionDraft !== 'confirmation') { clearTransactionDraft(); return; } if (transaction) { if (!isTransactionDraftModified(transaction, initAmount, initCategoryId, initAccountId, initTagIds, firstVisibleAccountId)) { clearTransactionDraft(); return; } transactionDraft.value = transaction.toTransactionDraft(); } updateUserTransactionDraft(transactionDraft.value); } function clearTransactionDraft(): void { transactionDraft.value = null; clearUserTransactionDraft(); } function setTransactionSuitableDestinationAmount(transaction: Transaction, oldSourceAmount: number, newSourceAmount: number, oldSourceAccountId?: string, oldDestinationAccountId?: string): void { if (transaction.type === TransactionType.Expense || transaction.type === TransactionType.Income) { transaction.destinationAmount = newSourceAmount; } else if (transaction.type === TransactionType.Transfer) { const sourceAccount = accountsStore.allAccountsMap[transaction.sourceAccountId]; const destinationAccount = accountsStore.allAccountsMap[transaction.destinationAccountId]; if (!sourceAccount || !destinationAccount) { return; } const oldSourceAccount = oldSourceAccountId ? accountsStore.allAccountsMap[oldSourceAccountId] : sourceAccount; const oldDestinationAccount = oldDestinationAccountId ? accountsStore.allAccountsMap[oldDestinationAccountId] : destinationAccount; let oldValueToCompare = oldSourceAmount; let newValueToSet = newSourceAmount; if (oldSourceAccount && oldDestinationAccount && oldSourceAccount.currency !== oldDestinationAccount.currency) { const decimalNumberCount = getCurrencyFraction(oldDestinationAccount.currency); const exchangedOldValue = exchangeRatesStore.getExchangedAmount(oldSourceAmount, oldSourceAccount.currency, oldDestinationAccount.currency); if (isNumber(decimalNumberCount) && isNumber(exchangedOldValue)) { oldValueToCompare = Math.trunc(exchangedOldValue); oldValueToCompare = getAmountWithDecimalNumberCount(oldValueToCompare, decimalNumberCount); } } if (sourceAccount.currency !== destinationAccount.currency) { const decimalNumberCount = getCurrencyFraction(destinationAccount.currency); const exchangedNewValue = exchangeRatesStore.getExchangedAmount(newSourceAmount, sourceAccount.currency, destinationAccount.currency); if (isNumber(decimalNumberCount) && isNumber(exchangedNewValue)) { newValueToSet = Math.trunc(exchangedNewValue); newValueToSet = getAmountWithDecimalNumberCount(newValueToSet, decimalNumberCount); } else { return; } } if ((transaction.destinationAmount === oldValueToCompare || transaction.destinationAmount === 0) && (TRANSACTION_MIN_AMOUNT <= newValueToSet && newValueToSet <= TRANSACTION_MAX_AMOUNT)) { transaction.destinationAmount = newValueToSet; } } } function updateTransactionListInvalidState(invalidState: boolean): void { transactionListStateInvalid.value = invalidState; } function updateTransactionReconciliationStatementInvalidState(invalidState: boolean): void { transactionReconciliationStatementStateInvalid.value = invalidState; } function resetTransactions(): void { transactionsFilter.value.dateType = DateRange.All.type; transactionsFilter.value.maxTime = 0; transactionsFilter.value.minTime = 0; transactionsFilter.value.type = 0; transactionsFilter.value.categoryIds = ''; transactionsFilter.value.accountIds = ''; transactionsFilter.value.tagFilter = ''; transactionsFilter.value.amountFilter = ''; transactionsFilter.value.keyword = ''; transactions.value = []; transactionsNextTimeId.value = 0; transactionListStateInvalid.value = true; transactionReconciliationStatementStateInvalid.value = true; } function clearTransactions(): void { transactions.value = []; transactionsNextTimeId.value = 0; transactionListStateInvalid.value = true; } function initTransactionListFilter(filter: TransactionListPartialFilter): void { if (filter && isNumber(filter.dateType)) { transactionsFilter.value.dateType = filter.dateType; } else { transactionsFilter.value.dateType = DateRange.All.type; } if (filter && isNumber(filter.maxTime)) { transactionsFilter.value.maxTime = filter.maxTime; } else { transactionsFilter.value.maxTime = 0; } if (filter && isNumber(filter.minTime)) { transactionsFilter.value.minTime = filter.minTime; } else { transactionsFilter.value.minTime = 0; } if (filter && isNumber(filter.type)) { transactionsFilter.value.type = filter.type; } else { transactionsFilter.value.type = 0; } if (filter && isString(filter.categoryIds)) { transactionsFilter.value.categoryIds = filter.categoryIds; } else { transactionsFilter.value.categoryIds = ''; } if (filter && isString(filter.accountIds)) { transactionsFilter.value.accountIds = filter.accountIds; } else { transactionsFilter.value.accountIds = ''; } if (filter && isString(filter.tagFilter)) { transactionsFilter.value.tagFilter = filter.tagFilter; } else { transactionsFilter.value.tagFilter = ''; } if (filter && isString(filter.amountFilter)) { transactionsFilter.value.amountFilter = filter.amountFilter; } else { transactionsFilter.value.amountFilter = ''; } if (filter && isString(filter.keyword)) { transactionsFilter.value.keyword = filter.keyword; } else { transactionsFilter.value.keyword = ''; } } function updateTransactionListFilter(filter: TransactionListPartialFilter): boolean { let changed = false; if (filter && isNumber(filter.dateType) && transactionsFilter.value.dateType !== filter.dateType) { transactionsFilter.value.dateType = filter.dateType; changed = true; } if (filter && isNumber(filter.maxTime) && transactionsFilter.value.maxTime !== filter.maxTime) { transactionsFilter.value.maxTime = filter.maxTime; changed = true; } if (filter && isNumber(filter.minTime) && transactionsFilter.value.minTime !== filter.minTime) { transactionsFilter.value.minTime = filter.minTime; changed = true; } if (filter && isNumber(filter.type) && transactionsFilter.value.type !== filter.type) { transactionsFilter.value.type = filter.type; changed = true; } if (filter && isString(filter.categoryIds) && transactionsFilter.value.categoryIds !== filter.categoryIds) { transactionsFilter.value.categoryIds = filter.categoryIds; changed = true; } if (filter && isString(filter.accountIds) && transactionsFilter.value.accountIds !== filter.accountIds) { if (DateRange.isBillingCycle(transactionsFilter.value.dateType) && (!accountsStore.getAccountStatementDate(filter.accountIds) || accountsStore.getAccountStatementDate(filter.accountIds) !== accountsStore.getAccountStatementDate(transactionsFilter.value.accountIds))) { transactionsFilter.value.dateType = DateRange.Custom.type; } transactionsFilter.value.accountIds = filter.accountIds; changed = true; } if (filter && isString(filter.tagFilter) && transactionsFilter.value.tagFilter !== filter.tagFilter) { transactionsFilter.value.tagFilter = filter.tagFilter; changed = true; } if (filter && isString(filter.amountFilter) && transactionsFilter.value.amountFilter !== filter.amountFilter) { transactionsFilter.value.amountFilter = filter.amountFilter; changed = true; } if (filter && isString(filter.keyword) && transactionsFilter.value.keyword !== filter.keyword) { transactionsFilter.value.keyword = filter.keyword; changed = true; } return changed; } function getTransactionListPageParams(pageType: number): string { const querys: string[] = []; querys.push('pageType=' + pageType); if (transactionsFilter.value.type) { querys.push('type=' + transactionsFilter.value.type); } if (transactionsFilter.value.accountIds) { querys.push('accountIds=' + transactionsFilter.value.accountIds); } if (transactionsFilter.value.categoryIds) { querys.push('categoryIds=' + transactionsFilter.value.categoryIds); } if (transactionsFilter.value.tagFilter) { querys.push('tagFilter=' + transactionsFilter.value.tagFilter); } querys.push('dateType=' + transactionsFilter.value.dateType); if (DateRange.isBillingCycle(transactionsFilter.value.dateType) || transactionsFilter.value.dateType === DateRange.Custom.type) { querys.push('maxTime=' + transactionsFilter.value.maxTime); querys.push('minTime=' + transactionsFilter.value.minTime); } if (transactionsFilter.value.amountFilter) { querys.push('amountFilter=' + encodeURIComponent(transactionsFilter.value.amountFilter)); } if (transactionsFilter.value.keyword) { querys.push('keyword=' + encodeURIComponent(transactionsFilter.value.keyword)); } return querys.join('&'); } function getExportTransactionDataRequestByTransactionFilter(): ExportTransactionDataRequest { return { maxTime: transactionsFilter.value.maxTime, minTime: transactionsFilter.value.minTime, type: transactionsFilter.value.type, categoryIds: transactionsFilter.value.categoryIds, accountIds: transactionsFilter.value.accountIds, tagFilter: transactionsFilter.value.tagFilter, amountFilter: transactionsFilter.value.amountFilter, keyword: transactionsFilter.value.keyword }; } function loadTransactions({ reload, count, page, withCount, autoExpand, defaultCurrency }: { reload?: boolean, count?: number, page?: number, withCount?: boolean, autoExpand: boolean, defaultCurrency: string }): Promise { let actualMaxTime = transactionsNextTimeId.value; if (reload && transactionsFilter.value.maxTime > 0) { actualMaxTime = transactionsFilter.value.maxTime * 1000 + 999; } else if (reload && transactionsFilter.value.maxTime <= 0) { actualMaxTime = 0; } return new Promise((resolve, reject) => { services.getTransactions({ maxTime: actualMaxTime, minTime: transactionsFilter.value.minTime * 1000, count: count || 50, page: page || 1, withCount: !!withCount, type: transactionsFilter.value.type, categoryIds: transactionsFilter.value.categoryIds, accountIds: transactionsFilter.value.accountIds, tagFilter: transactionsFilter.value.tagFilter, amountFilter: transactionsFilter.value.amountFilter, keyword: transactionsFilter.value.keyword }).then(response => { const data = response.data; if (!data || !data.success || !data.result) { if (reload) { loadTransactionList({ transactionPageWrapper: EMPTY_TRANSACTION_RESULT, reload: reload, autoExpand: autoExpand, defaultCurrency: defaultCurrency }); if (!transactionListStateInvalid.value) { updateTransactionListInvalidState(true); } } reject({ message: 'Unable to retrieve transaction list' }); return; } const transactionPageWrapper: TransactionPageWrapper = { items: Transaction.ofMulti(data.result.items), totalCount: data.result.totalCount }; loadTransactionList({ transactionPageWrapper: transactionPageWrapper, reload: !!reload, autoExpand: autoExpand, defaultCurrency: defaultCurrency, nextTimeSequenceId: data.result.nextTimeSequenceId }); if (reload) { if (transactionListStateInvalid.value) { updateTransactionListInvalidState(false); } } resolve(transactionPageWrapper); }).catch(error => { logger.error('failed to load transaction list', error); if (reload) { loadTransactionList({ transactionPageWrapper: EMPTY_TRANSACTION_RESULT, reload: reload, autoExpand: autoExpand, defaultCurrency: defaultCurrency }); if (!transactionListStateInvalid.value) { updateTransactionListInvalidState(true); } } if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { reject({ message: 'Unable to retrieve transaction list' }); } else { reject(error); } }); }); } function loadMonthlyAllTransactions({ year, month, autoExpand, defaultCurrency }: { year: number, month: number, autoExpand: boolean, defaultCurrency: string }): Promise { return new Promise((resolve, reject) => { services.getAllTransactionsByMonth({ year: year, month: month, type: transactionsFilter.value.type, categoryIds: transactionsFilter.value.categoryIds, accountIds: transactionsFilter.value.accountIds, tagFilter: transactionsFilter.value.tagFilter, amountFilter: transactionsFilter.value.amountFilter, keyword: transactionsFilter.value.keyword }).then(response => { const data = response.data; if (!data || !data.success || !data.result) { loadTransactionList({ transactionPageWrapper: EMPTY_TRANSACTION_RESULT, reload: true, autoExpand: autoExpand, defaultCurrency: defaultCurrency }); if (!transactionListStateInvalid.value) { updateTransactionListInvalidState(true); } reject({ message: 'Unable to retrieve transaction list' }); return; } const transactionPageWrapper: TransactionPageWrapper = { items: Transaction.ofMulti(data.result.items), totalCount: data.result.totalCount }; loadTransactionList({ transactionPageWrapper: transactionPageWrapper, reload: true, autoExpand: autoExpand, defaultCurrency: defaultCurrency }); if (transactionListStateInvalid.value) { updateTransactionListInvalidState(false); } resolve(transactionPageWrapper); }).catch(error => { logger.error('failed to load monthly all transaction list', error); loadTransactionList({ transactionPageWrapper: EMPTY_TRANSACTION_RESULT, reload: true, autoExpand: autoExpand, defaultCurrency: defaultCurrency }); if (!transactionListStateInvalid.value) { updateTransactionListInvalidState(true); } if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { reject({ message: 'Unable to retrieve transaction list' }); } else { reject(error); } }); }); } function getReconciliationStatements({ accountId, startTime, endTime }: { accountId: string, startTime: number, endTime: number }): Promise { return new Promise((resolve, reject) => { services.getReconciliationStatements({ accountId: accountId, startTime: startTime, endTime: endTime }).then(response => { const data = response.data; if (!data || !data.success || !data.result) { if (!transactionReconciliationStatementStateInvalid.value) { updateTransactionReconciliationStatementInvalidState(true); } reject({ message: 'Unable to retrieve reconciliation statements' }); return; } if (transactionReconciliationStatementStateInvalid.value) { updateTransactionReconciliationStatementInvalidState(false); } resolve(data.result); }).catch(error => { logger.error('failed to load reconciliation statements', error); if (!transactionReconciliationStatementStateInvalid.value) { updateTransactionReconciliationStatementInvalidState(true); } if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { reject({ message: 'Unable to retrieve reconciliation statements' }); } else { reject(error); } }); }); } function getTransaction({ transactionId, withPictures }: { transactionId: string, withPictures?: boolean }): Promise { return new Promise((resolve, reject) => { if (!isDefined(withPictures)) { withPictures = true; } services.getTransaction({ id: transactionId, withPictures: withPictures }).then(response => { const data = response.data; if (!data || !data.success || !data.result) { reject({ message: 'Unable to retrieve transaction' }); return; } const transaction = Transaction.of(data.result); resolve(transaction); }).catch(error => { logger.error('failed to load transaction info', error); if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { reject({ message: 'Unable to retrieve transaction' }); } else { reject(error); } }); }); } function saveTransaction({ transaction, defaultCurrency, isEdit, clientSessionId }: { transaction: Transaction, defaultCurrency: string, isEdit: boolean, clientSessionId: string }): Promise { return new Promise((resolve, reject) => { let promise: ApiResponsePromise; if (transaction.type !== TransactionType.Expense && transaction.type !== TransactionType.Income && transaction.type !== TransactionType.Transfer && transaction.type !== TransactionType.ModifyBalance) { reject({ message: 'An error occurred' }); return; } else if (!isEdit && transaction.type === TransactionType.ModifyBalance) { reject({ message: 'An error occurred' }); return; } if (!isEdit) { promise = services.addTransaction(transaction.toCreateRequest(clientSessionId)); } else { promise = services.modifyTransaction(transaction.toModifyRequest()); } promise.then(response => { const data = response.data; if (!data || !data.success || !data.result) { if (!isEdit) { reject({ message: 'Unable to add transaction' }); } else { reject({ message: 'Unable to save transaction' }); } } const transaction = Transaction.of(data.result); if (!isEdit) { if (!transactionListStateInvalid.value) { updateTransactionListInvalidState(true); } } else { updateTransactionInTransactionList({ currentTransaction: transaction, defaultCurrency: defaultCurrency }); } if (!transactionReconciliationStatementStateInvalid.value) { updateTransactionReconciliationStatementInvalidState(true); } if (!accountsStore.accountListStateInvalid) { accountsStore.updateAccountListInvalidState(true); } if (!overviewStore.transactionOverviewStateInvalid) { overviewStore.updateTransactionOverviewInvalidState(true); } if (!statisticsStore.transactionStatisticsStateInvalid) { statisticsStore.updateTransactionStatisticsInvalidState(true); } if (!explorersStore.transactionExplorerStateInvalid) { explorersStore.updateTransactionExplorerInvalidState(true); } resolve(transaction); }).catch(error => { logger.error('failed to save transaction', error); if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { if (!isEdit) { reject({ message: 'Unable to add transaction' }); } else { reject({ message: 'Unable to save transaction' }); } } else { reject(error); } }); }); } function moveAllTransactionsBetweenAccounts({ fromAccountId, toAccountId }: { fromAccountId: string, toAccountId: string }): Promise { return new Promise((resolve, reject) => { services.moveAllTransactionsBetweenAccounts({ fromAccountId, toAccountId }).then(response => { const data = response.data; if (!data || !data.success || !data.result) { reject({ message: 'Unable to move transactions' }); return; } if (!transactionListStateInvalid.value) { updateTransactionListInvalidState(true); } if (!transactionReconciliationStatementStateInvalid.value) { updateTransactionReconciliationStatementInvalidState(true); } if (!accountsStore.accountListStateInvalid) { accountsStore.updateAccountListInvalidState(true); } if (!overviewStore.transactionOverviewStateInvalid) { overviewStore.updateTransactionOverviewInvalidState(true); } if (!statisticsStore.transactionStatisticsStateInvalid) { statisticsStore.updateTransactionStatisticsInvalidState(true); } if (!explorersStore.transactionExplorerStateInvalid) { explorersStore.updateTransactionExplorerInvalidState(true); } resolve(data.result); }).catch(error => { logger.error('failed to move transactions', error); if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { reject({ message: 'Unable to move transactions' }); } else { reject(error); } }); }); } function deleteTransaction({ transaction, defaultCurrency, beforeResolve }: { transaction: TransactionInfoResponse, defaultCurrency: string, beforeResolve?: BeforeResolveFunction }): Promise { return new Promise((resolve, reject) => { services.deleteTransaction({ id: transaction.id }).then(response => { const data = response.data; if (!data || !data.success || !data.result) { reject({ message: 'Unable to delete this transaction' }); return; } if (beforeResolve) { beforeResolve(() => { removeTransactionFromTransactionList({ currentTransaction: transaction, defaultCurrency: defaultCurrency }); }); } else { removeTransactionFromTransactionList({ currentTransaction: transaction, defaultCurrency: defaultCurrency }); } if (!transactionReconciliationStatementStateInvalid.value) { updateTransactionReconciliationStatementInvalidState(true); } if (!accountsStore.accountListStateInvalid) { accountsStore.updateAccountListInvalidState(true); } if (!overviewStore.transactionOverviewStateInvalid) { overviewStore.updateTransactionOverviewInvalidState(true); } if (!statisticsStore.transactionStatisticsStateInvalid) { statisticsStore.updateTransactionStatisticsInvalidState(true); } if (!explorersStore.transactionExplorerStateInvalid) { explorersStore.updateTransactionExplorerInvalidState(true); } resolve(data.result); }).catch(error => { logger.error('failed to delete transaction', error); if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { reject({ message: 'Unable to delete this transaction' }); } else { reject(error); } }); }); } function recognizeReceiptImage({ imageFile, cancelableUuid }: { imageFile: File, cancelableUuid?: string }): Promise { return new Promise((resolve, reject) => { services.recognizeReceiptImage({ imageFile, cancelableUuid }).then(response => { const data = response.data; if (!data || !data.success || !data.result) { reject({ message: 'Unable to recognize image' }); return; } resolve(data.result); }).catch(error => { if (error.canceled) { reject(error); } logger.error('failed to recognize image', error); if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { reject({ message: 'Unable to recognize image' }); } else { reject(error); } }); }); } function cancelRecognizeReceiptImage(cancelableUuid: string): void { services.cancelRequest(cancelableUuid); } function parseImportDsvFile({ fileType, fileEncoding, importFile }: { fileType: string, fileEncoding?: string, importFile: File }): Promise { return new Promise((resolve, reject) => { services.parseImportDsvFile({ fileType, fileEncoding, importFile }).then(response => { const data = response.data; if (!data || !data.success || !data.result) { reject({ message: 'Unable to parse import file' }); return; } resolve(data.result); }).catch(error => { logger.error('Unable to parse import file', error); if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { reject({ message: 'Unable to parse import file' }); } else { reject(error); } }); }); } function parseImportTransaction({ fileType, additionalOptions, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoSeparator, geoOrder, tagSeparator }: { fileType: string, additionalOptions?: ImportFileTypeSupportedAdditionalOptions, fileEncoding?: string, importFile: File, columnMapping?: Record, transactionTypeMapping?: Record, hasHeaderLine?: boolean, timeFormat?: string, timezoneFormat?: string, amountDecimalSeparator?: string, amountDigitGroupingSymbol?: string, geoSeparator?: string, geoOrder?: string, tagSeparator?: string }): Promise { return new Promise((resolve, reject) => { services.parseImportTransaction({ fileType, additionalOptions, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoSeparator, geoOrder, tagSeparator }).then(response => { const data = response.data; if (!data || !data.success || !data.result) { reject({ message: 'Unable to parse import file' }); return; } resolve(data.result); }).catch(error => { logger.error('Unable to parse import file', error); if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { reject({ message: 'Unable to parse import file' }); } else { reject(error); } }); }); } function importTransactions({ transactions, clientSessionId }: { transactions: ImportTransaction[], clientSessionId: string }): Promise { const submitTransactions: TransactionCreateRequest[] = []; if (transactions) { for (const transaction of transactions) { const submitTransaction = transaction.toCreateRequest(); submitTransactions.push(submitTransaction); } } return new Promise((resolve, reject) => { services.importTransactions({ transactions: submitTransactions, clientSessionId: clientSessionId }).then(response => { const data = response.data; if (!data || !data.success || !data.result) { reject({ message: 'Unable to import transactions' }); return; } resolve(data.result); }).catch(error => { logger.error('Unable to import transactions', error); if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { reject({ message: 'Unable to import transactions' }); } else { reject(error); } }); }); } function getImportTransactionsProcess({ clientSessionId }: { clientSessionId: string }): Promise { return new Promise((resolve, reject) => { services.getImportTransactionsProcess(clientSessionId).then(response => { const data = response.data; if (!data || !data.success || !data.result) { reject({ message: 'Unable to get transactions import process' }); return; } resolve(data.result); }).catch(error => { logger.error('Unable to get transactions import process', error); if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { reject({ message: 'Unable to get transactions import process' }); } else { reject(error); } }); }); } function uploadTransactionPicture({ pictureFile, clientSessionId }: { pictureFile: File, clientSessionId?: string }): Promise { return new Promise((resolve, reject) => { services.uploadTransactionPicture({ pictureFile, clientSessionId }).then(response => { const data = response.data; if (!data || !data.success || !data.result) { reject({ message: 'Unable to upload transaction picture' }); return; } resolve(data.result); }).catch(error => { logger.error('Unable to upload transaction picture', error); if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { reject({ message: 'Unable to upload transaction picture' }); } else { reject(error); } }); }); } function removeUnusedTransactionPicture({ pictureInfo }: { pictureInfo: TransactionPictureInfoBasicResponse }): Promise { return new Promise((resolve, reject) => { services.removeUnusedTransactionPicture({ id: pictureInfo.pictureId }).then(response => { const data = response.data; if (!data || !data.success || !data.result) { reject({ message: 'Unable to remove transaction picture' }); return; } resolve(data.result); }).catch(error => { logger.error('failed to remove transaction picture', error); if (error.response && error.response.data && error.response.data.errorMessage) { reject({ error: error.response.data }); } else if (!error.processed) { reject({ message: 'Unable to remove transaction picture' }); } else { reject(error); } }); }); } function getTransactionPictureUrl(pictureInfo?: TransactionPictureInfoBasicResponse | null, disableBrowserCache?: boolean | string): string | undefined { if (!pictureInfo || !pictureInfo.originalUrl) { return undefined; } return services.getTransactionPictureUrlWithToken(pictureInfo.originalUrl, disableBrowserCache); } function collapseMonthInTransactionList({ monthList, collapse }: { monthList: TransactionMonthList, collapse: boolean }): void { if (monthList) { monthList.opened = !collapse; } } return { // states transactionDraft, transactionsFilter, transactions, transactionsNextTimeId, transactionListStateInvalid, transactionReconciliationStatementStateInvalid, // computed states allFilterCategoryIds, allFilterAccountIds, allFilterTagIds, allFilterCategoryIdsCount, allFilterAccountIdsCount, allFilterTagIdsCount, noTransaction, hasMoreTransaction, // functions initTransactionDraft, isTransactionDraftModified, saveTransactionDraft, clearTransactionDraft, setTransactionSuitableDestinationAmount, updateTransactionListInvalidState, updateTransactionReconciliationStatementInvalidState, resetTransactions, clearTransactions, initTransactionListFilter, updateTransactionListFilter, getTransactionListPageParams, getExportTransactionDataRequestByTransactionFilter, loadTransactions, loadMonthlyAllTransactions, getReconciliationStatements, getTransaction, saveTransaction, moveAllTransactionsBetweenAccounts, deleteTransaction, recognizeReceiptImage, cancelRecognizeReceiptImage, parseImportDsvFile, parseImportTransaction, importTransactions, getImportTransactionsProcess, uploadTransactionPicture, removeUnusedTransactionPicture, getTransactionPictureUrl, collapseMonthInTransactionList }; });