UNPKG

actual-moneymoney

Version:

An importer for syncing MoneyMoney accounts and transactions to Actual.

177 lines (176 loc) 10.6 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import { format, subMonths } from 'date-fns'; import { getTransactions, } from 'moneymoney'; import { DATE_FORMAT } from './shared.js'; class Importer { constructor(config, budgetConfig, actualApi, logger, accountMap, payeeTransformer) { this.config = config; this.budgetConfig = budgetConfig; this.actualApi = actualApi; this.logger = logger; this.accountMap = accountMap; this.payeeTransformer = payeeTransformer; } importTransactions(_a) { return __awaiter(this, arguments, void 0, function* ({ accountRefs, from, to: toDate, isDryRun = false, }) { var _b; const fromDate = from !== null && from !== void 0 ? from : subMonths(new Date(), 1); const earliestImportDate = this.budgetConfig.earliestImportDate ? new Date(this.budgetConfig.earliestImportDate) : null; const importDate = earliestImportDate && earliestImportDate > fromDate ? earliestImportDate : fromDate; if (earliestImportDate && earliestImportDate > fromDate) { this.logger.warn(`Earliest import date is set to ${format(earliestImportDate, DATE_FORMAT)}. Using this date instead of ${format(fromDate, DATE_FORMAT)}.`); } this.logger.debug(`Cleared status synchronization is ${this.config.import.synchronizeClearedStatus ? 'enabled' : 'disabled'}`); let monMonTransactions = yield getTransactions({ from: importDate, to: toDate, }); if (monMonTransactions.length === 0) { this.logger.info(`No transactions found in MoneyMoney since ${format(importDate, DATE_FORMAT)}.`); return; } if (!this.config.import.importUncheckedTransactions) { monMonTransactions = monMonTransactions.filter((t) => t.booked); } if (this.config.import.ignorePatterns !== undefined) { const ignorePatterns = this.config.import.ignorePatterns; monMonTransactions = monMonTransactions.filter((t) => { var _a, _b, _c; let isIgnored = ((_a = ignorePatterns.commentPatterns) !== null && _a !== void 0 ? _a : []).some((pattern) => { var _a; return (_a = t.comment) === null || _a === void 0 ? void 0 : _a.includes(pattern); }); isIgnored || (isIgnored = ((_b = ignorePatterns.payeePatterns) !== null && _b !== void 0 ? _b : []).some((pattern) => t.name.includes(pattern))); isIgnored || (isIgnored = ((_c = ignorePatterns.purposePatterns) !== null && _c !== void 0 ? _c : []).some((pattern) => { var _a; return (_a = t.purpose) === null || _a === void 0 ? void 0 : _a.includes(pattern); })); if (isIgnored) { this.logger.debug(`Ignoring transaction ${t.id} (${t.name}) due to ignore patterns`); } return !isIgnored; }); } this.logger.debug(`Found ${monMonTransactions.length} total transactions in MoneyMoney since ${format(importDate, DATE_FORMAT)}`); const monMonTransactionMap = monMonTransactions.reduce((acc, transaction) => { if (!acc[transaction.accountUuid]) { acc[transaction.accountUuid] = []; } acc[transaction.accountUuid].push(transaction); return acc; }, {}); for (const [monMonAccountUuid, monMonTransactions] of Object.entries(monMonTransactionMap)) { this.logger.debug(`Found ${monMonTransactions.length} transactions for account ${monMonAccountUuid}`); } const accountMapping = this.accountMap.getMap(accountRefs); // Iterate over account mapping for (const [monMonAccount, actualAccount] of accountMapping) { const monMonTransactions = (_b = monMonTransactionMap[monMonAccount.uuid]) !== null && _b !== void 0 ? _b : []; let createTransactions = []; for (const monMonTransaction of monMonTransactions) { createTransactions.push(yield this.convertToActualTransaction(monMonTransaction)); } const existingActualTransactions = yield this.actualApi.getTransactions(actualAccount.id); this.logger.debug(`Found ${existingActualTransactions.length} existing transactions for Actual account '${actualAccount.name}'`); // Push start transaction if no transactions exist if (existingActualTransactions.length === 0) { const startTransaction = { date: format(monMonTransactions.length > 0 ? monMonTransactions[monMonTransactions.length - 1] .valueDate : new Date(), DATE_FORMAT), amount: this.getStartingBalanceForAccount(monMonAccount, monMonTransactions), imported_id: `${monMonAccount.uuid}-start`, cleared: true, notes: 'Starting balance', imported_payee: 'Starting balance', }; this.logger.debug(`No existing transactions found for Actual account '${actualAccount.name}'. Adding start transaction with amount ${startTransaction.amount}...`); createTransactions.push(startTransaction); } // Filter out transactions that already exist in Actual createTransactions = createTransactions.filter((transaction) => __awaiter(this, void 0, void 0, function* () { const transactionExists = existingActualTransactions.some((existingTransaction) => existingTransaction.imported_id === transaction.imported_id); return !transactionExists; })); if (createTransactions.length === 0) { this.logger.debug(`No new transactions found for Actual account '${actualAccount.name}'. Skipping...`); continue; } this.logger.debug(`Considering ${createTransactions.length} transactions for Actual account '${actualAccount.name}'...`); if (this.payeeTransformer && !isDryRun) { this.logger.debug(`Cleaning up payee names for ${createTransactions.length} transaction/s using OpenAI...`); const transactionPayees = createTransactions.map((t) => t.imported_payee); const transformedPayees = yield this.payeeTransformer.transformPayees(transactionPayees); if (transformedPayees !== null) { createTransactions.forEach((t) => { t.payee_name = transformedPayees[t.imported_payee]; }); } else { this.logger.warn('Payee transformation failed. Using default payee names...'); createTransactions.forEach((t) => { t.payee_name = t.imported_payee; }); } } else { this.logger.debug(`Payee transformation is disabled. Using default payee names...`); createTransactions.forEach((t) => { t.payee_name = t.imported_payee; }); } if (!isDryRun) { const result = yield this.actualApi.importTransactions(actualAccount.id, createTransactions); if (result.errors && result.errors.length > 0) { this.logger.error('Some errors occurred during import:'); for (let i = 0; i < result.errors.length; i++) { this.logger.error(`Error ${i + 1}: ${result.errors[i].message}`); } } const addedCount = result.added.length; const updatedCount = result.updated.length; this.logger.info(`Transaction import to account '${actualAccount.name}' successful`, [ `Added ${addedCount} new transaction.`, `Updated ${updatedCount} existing transaction.`, ]); } } }); } convertToActualTransaction(transaction) { return __awaiter(this, void 0, void 0, function* () { return { date: format(transaction.valueDate, 'yyyy-MM-dd'), amount: Math.round(transaction.amount * 100), imported_id: this.getIdForMoneyMoneyTransaction(transaction), imported_payee: transaction.name, cleared: this.config.import.synchronizeClearedStatus ? transaction.booked : undefined, notes: transaction.purpose, // payee_name: transaction.name, }; }); } getIdForMoneyMoneyTransaction(transaction) { return `${transaction.accountUuid}-${transaction.id}`; } getStartingBalanceForAccount(account, transactions) { const monMonAccountBalance = account.balance[0][0]; const totalExpenses = transactions.reduce((acc, transaction) => acc + (transaction.booked ? transaction.amount : 0), 0); const startingBalance = Math.round((monMonAccountBalance - totalExpenses) * 100); return startingBalance; } } export default Importer;