actual-moneymoney
Version:
An importer for syncing MoneyMoney accounts and transactions to Actual.
188 lines (187 loc) • 11 kB
JavaScript
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 { getTransactions, } from 'moneymoney';
import { formatDate } from './date.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 defaultFromDate = new Date();
defaultFromDate.setMonth(defaultFromDate.getMonth() - 1);
const fromDate = from !== null && from !== void 0 ? from : defaultFromDate;
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 ${formatDate(earliestImportDate)}. Using this date instead of ${formatDate(fromDate)}.`);
}
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 ${formatDate(importDate)}.`);
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 ${formatDate(importDate)}`);
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: formatDate(monMonTransactions.length > 0
? monMonTransactions[monMonTransactions.length - 1]
.valueDate
: new Date()),
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(isDryRun
? `Skipping payee transformation in dry run mode, using default payee names...`
: `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* () {
const transactionNotes = [
transaction.purpose,
transaction.comment && this.config.import.importComments
? `${this.config.import.commentPrefix}${transaction.comment}`
: undefined,
]
.filter(Boolean)
.join(' | ');
return {
date: formatDate(transaction.valueDate),
amount: Math.round(transaction.amount * 100),
imported_id: this.getIdForMoneyMoneyTransaction(transaction),
imported_payee: transaction.name,
cleared: this.config.import.synchronizeClearedStatus
? transaction.booked
: undefined,
notes: transactionNotes,
// 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;