Files
ezbookkeeping/src/stores/transaction.ts

1468 lines
63 KiB
TypeScript

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 { 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 { 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 {
getTimezoneOffsetMinutes,
getBrowserTimezoneOffsetMinutes,
getActualUnixTimeForStore,
parseDateTimeFromUnixTime
} from '@/lib/datetime.ts';
import { getAmountWithDecimalNumberCount } from '@/lib/numeral.ts';
import { getCurrencyFraction } from '@/lib/currency.ts';
import { getFirstAvailableCategoryId } 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<string, TransactionTotalAmount>;
}
export const useTransactionsStore = defineStore('transactions', () => {
const settingsStore = useSettingsStore();
const userStore = useUserStore();
const accountsStore = useAccountsStore();
const transactionCategoriesStore = useTransactionCategoriesStore();
const overviewStore = useOverviewStore();
const statisticsStore = useStatisticsStore();
const exchangeRatesStore = useExchangeRatesStore();
const transactionDraft = ref<TransactionDraft | null>(getUserTransactionDraft());
const transactionsFilter = ref<TransactionListFilter>({
dateType: DateRange.All.type,
maxTime: 0,
minTime: 0,
type: 0,
categoryIds: '',
accountIds: '',
tagFilter: '',
amountFilter: '',
keyword: ''
});
const transactions = ref<TransactionMonthList[]>([]);
const transactionsNextTimeId = ref<number>(0);
const transactionListStateInvalid = ref<boolean>(true);
const transactionReconciliationStatementStateInvalid = ref<boolean>(true);
const allFilterCategoryIds = computed<Record<string, boolean>>(() => splitItemsToMap(transactionsFilter.value.categoryIds, ','));
const allFilterAccountIds = computed<Record<string, boolean>>(() => splitItemsToMap(transactionsFilter.value.accountIds, ','));
const allFilterTagIds = computed<Record<string, boolean>>(() => {
const tagFilters: TransactionTagFilter[] = TransactionTagFilter.parse(transactionsFilter.value.tagFilter);
const allTagIdsMap: Record<string, boolean> = {};
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<number>(() => countSplitItems(transactionsFilter.value.categoryIds, ','));
const allFilterAccountIdsCount = computed<number>(() => countSplitItems(transactionsFilter.value.accountIds, ','));
const allFilterTagIdsCount = computed<number>(() => getObjectOwnFieldCount(allFilterTagIds.value));
const noTransaction = computed<boolean>(() => {
for (const transactionMonthList of transactions.value) {
for (const transaction of transactionMonthList.items) {
if (transaction) {
return false;
}
}
}
return true;
});
const hasMoreTransaction = computed<boolean>(() => {
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) {
const currentUtcOffset = getTimezoneOffsetMinutes(settingsStore.appSettings.timeZone);
let currentMonthListIndex = -1;
let currentMonthList: TransactionMonthList | null = null;
for (const [item, index] of itemAndIndex(transactionPageWrapper.items)) {
fillTransactionObject(item, currentUtcOffset);
const transactionTime = parseDateTimeFromUnixTime(item.time, item.utcOffset, currentUtcOffset);
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 currentUtcOffset = getTimezoneOffsetMinutes(settingsStore.appSettings.timeZone);
const transactionTime = parseDateTimeFromUnixTime(currentTransaction.time, currentTransaction.utcOffset, currentUtcOffset);
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, currentUtcOffset);
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<string, TransactionTotalAmount> = {};
const allAccountIdsMap: Record<string, boolean> = {};
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, currentUtcOffset: number): void {
if (!transaction) {
return;
}
const transactionTime = parseDateTimeFromUnixTime(transaction.time, transaction.utcOffset, currentUtcOffset);
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 = getFirstAvailableCategoryId(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 = getFirstAvailableCategoryId(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 = getFirstAvailableCategoryId(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, oldValue: number, newValue: number): void {
if (transaction.type === TransactionType.Expense || transaction.type === TransactionType.Income) {
transaction.destinationAmount = newValue;
} else if (transaction.type === TransactionType.Transfer) {
const sourceAccount = accountsStore.allAccountsMap[transaction.sourceAccountId];
const destinationAccount = accountsStore.allAccountsMap[transaction.destinationAccountId];
if (sourceAccount && destinationAccount && sourceAccount.currency !== destinationAccount.currency) {
const decimalNumberCount = getCurrencyFraction(destinationAccount.currency);
const exchangedOldValue = exchangeRatesStore.getExchangedAmount(oldValue, sourceAccount.currency, destinationAccount.currency);
const exchangedNewValue = exchangeRatesStore.getExchangedAmount(newValue, sourceAccount.currency, destinationAccount.currency);
if (isNumber(decimalNumberCount) && isNumber(exchangedOldValue)) {
oldValue = Math.trunc(exchangedOldValue);
oldValue = getAmountWithDecimalNumberCount(oldValue, decimalNumberCount);
}
if (isNumber(decimalNumberCount) && isNumber(exchangedNewValue)) {
newValue = Math.trunc(exchangedNewValue);
newValue = getAmountWithDecimalNumberCount(newValue, decimalNumberCount);
} else {
return;
}
}
if ((!sourceAccount || !destinationAccount || transaction.destinationAmount === oldValue || transaction.destinationAmount === 0) &&
(TRANSACTION_MIN_AMOUNT <= newValue && newValue <= TRANSACTION_MAX_AMOUNT)) {
transaction.destinationAmount = newValue;
}
}
}
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<TransactionPageWrapper> {
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<TransactionPageWrapper> {
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<TransactionReconciliationStatementResponse> {
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<Transaction> {
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<Transaction> {
return new Promise((resolve, reject) => {
const actualTime = getActualUnixTimeForStore(transaction.time, transaction.utcOffset, getBrowserTimezoneOffsetMinutes());
let promise: ApiResponsePromise<TransactionInfoResponse>;
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, actualTime));
} else {
promise = services.modifyTransaction(transaction.toModifyRequest(actualTime));
}
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);
}
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<boolean> {
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);
}
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<boolean> {
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);
}
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<RecognizedReceiptImageResponse> {
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<string[][]> {
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, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoSeparator, geoOrder, tagSeparator }: { fileType: string, fileEncoding?: string, importFile: File, columnMapping?: Record<number, number>, transactionTypeMapping?: Record<string, TransactionType>, hasHeaderLine?: boolean, timeFormat?: string, timezoneFormat?: string, amountDecimalSeparator?: string, amountDigitGroupingSymbol?: string, geoSeparator?: string, geoOrder?: string, tagSeparator?: string }): Promise<ImportTransactionResponsePageWrapper> {
return new Promise((resolve, reject) => {
services.parseImportTransaction({ fileType, 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<number> {
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<number | null> {
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<TransactionPictureInfoBasicResponse> {
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<boolean> {
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
};
});