diff options
Diffstat (limited to 'backend/importers/actualbudget/src')
20 files changed, 769 insertions, 0 deletions
diff --git a/backend/importers/actualbudget/src/getters.rs b/backend/importers/actualbudget/src/getters.rs new file mode 100644 index 0000000..88a660d --- /dev/null +++ b/backend/importers/actualbudget/src/getters.rs @@ -0,0 +1,61 @@ +use crate::models::actualbudget::{ + account::Account, category::Category, date::Date, transaction::Transaction, + zero_budget::ZeroBudget, +}; +use sqlite::Connection; + +pub fn get_first_transaction_date(connection: &Connection) -> Date { + let mut statement = connection + .prepare("select min(date) as min_date from transactions;") + .unwrap(); + match statement.next() { + Ok(sqlite::State::Row) => Date::from_date_row(&statement, "min_date"), + err => panic!("Could not find first transaction date: {:?}", err), + } +} + +pub fn get_accounts(connection: &Connection) -> Vec<Account> { + let mut statement = connection.prepare("select * from accounts;").unwrap(); + let mut accounts = Vec::<Account>::new(); + while let Ok(sqlite::State::Row) = statement.next() { + accounts.push(Account::from_row(&statement)); + } + accounts +} + +pub fn get_categories(connection: &Connection) -> Vec<Category> { + let mut statement = connection.prepare("select * from categories;").unwrap(); + let mut categories = Vec::<Category>::new(); + while let Ok(sqlite::State::Row) = statement.next() { + categories.push(Category::from_row(&statement)); + } + categories +} + +pub fn get_zero_budgets(connection: &Connection) -> Vec<ZeroBudget> { + let mut statement = connection.prepare("select * from zero_budgets;").unwrap(); + let mut zero_budgets = Vec::<ZeroBudget>::new(); + while let Ok(sqlite::State::Row) = statement.next() { + zero_budgets.push(ZeroBudget::from_row(&statement)); + } + zero_budgets +} + +pub fn get_category_transfers_account(connection: &Connection) -> Account { + let mut statement = connection + .prepare("select * from accounts where name = 'Category transfers';") + .unwrap(); + match statement.next() { + Ok(sqlite::State::Row) => Account::from_row(&statement), + _ => panic!("Could not determine category transfers account"), + } +} + +pub fn get_transactions(connection: &Connection) -> Vec<Transaction> { + let mut statement = connection.prepare("select * from transactions;").unwrap(); + let mut transactions = Vec::<Transaction>::new(); + while let Ok(sqlite::State::Row) = statement.next() { + transactions.push(Transaction::from_row(&statement)); + } + transactions +} diff --git a/backend/importers/actualbudget/src/inserters.rs b/backend/importers/actualbudget/src/inserters.rs new file mode 100644 index 0000000..179de97 --- /dev/null +++ b/backend/importers/actualbudget/src/inserters.rs @@ -0,0 +1,120 @@ +use crate::models::budgeting_app::{ + account::Account, budget_update::BudgetUpdate, category::Category, + category_transfer::CategoryTransfer, transaction::Transaction, + transaction_categorisation::TransactionCategorisation, +}; +use sqlite::Connection; + +pub fn insert_accounts(connection: &Connection, accounts: &[Account]) { + for account in accounts { + let str = format!( + " + insert into + accounts(id, name, opening_balance, opening_date) + values({0}, '{1}', {2}, '{3}'); + ", + account.id, + escape_str(&account.name), + account.opening_balance, + account.opening_date + ); + connection.execute(str).unwrap(); + } +} + +pub fn insert_categories(connection: &Connection, categories: &[Category]) { + for category in categories { + let str = format!( + " + insert into + categories(id, name) + values({0}, '{1}'); + ", + category.id, + escape_str(&category.name), + ); + connection.execute(str).unwrap(); + } +} + +pub fn insert_budget_updates(connection: &Connection, budget_updates: &[BudgetUpdate]) { + for budget_update in budget_updates { + let str = format!( + " + insert into + budget_updates(id, category_id, date, new_budget, new_period) + values({0}, {1}, '{2}', {3}, {4}); + ", + budget_update.id, + budget_update.category_id, + budget_update.date, + budget_update.new_budget, + budget_update.new_period, + ); + connection.execute(str).unwrap(); + } +} + +pub fn insert_transactions(connection: &Connection, transactions: &[Transaction]) { + for transaction in transactions { + let str = format!( + " + insert into + transactions(id, description, payee, quantity, date, account_id) + values({0}, '{1}', '{2}', {3}, '{4}', {5}); + ", + transaction.id, + escape_str(&transaction.description), + escape_str(&transaction.payee), + transaction.quantity, + transaction.date, + transaction.account_id, + ); + connection + .execute(str.clone()) + .unwrap_or_else(|_| panic!("{}", &str.clone())); + } +} + +pub fn insert_transaction_categorisations( + connection: &Connection, + transaction_categorisations: &[TransactionCategorisation], +) { + for transaction_categorisation in transaction_categorisations { + let str = format!( + " + insert into + transaction_categorisations(id, description, quantity, transaction_id, category_id) + values({0}, '{1}', {2}, {3}, {4}); + ", + transaction_categorisation.id, + escape_str(&transaction_categorisation.description), + transaction_categorisation.quantity, + transaction_categorisation.transaction_id, + transaction_categorisation.category_id, + ); + connection.execute(str).unwrap(); + } +} + +pub fn insert_category_transfers(connection: &Connection, category_transfers: &[CategoryTransfer]) { + for category_transfer in category_transfers { + let str = format!( + " + insert into + category_transfers(id, description, quantity, from_category_id, to_category_id) + values({0}, '{1}', {2}, {3}, {4}); + ", + category_transfer.id, + escape_str(&category_transfer.description), + category_transfer.quantity, + category_transfer.from_category_id, + category_transfer.to_category_id, + ); + connection.execute(str).unwrap(); + } +} + +fn escape_str(str: &str) -> String { + str.replace("'", "''") +} diff --git a/backend/importers/actualbudget/src/main.rs b/backend/importers/actualbudget/src/main.rs new file mode 100644 index 0000000..495216e --- /dev/null +++ b/backend/importers/actualbudget/src/main.rs @@ -0,0 +1,57 @@ +pub mod getters; +pub mod inserters; +pub mod models; +pub mod transformers; + +use getters::*; +use inserters::*; +use sqlite::Connection; + +fn main() { + let connection_src = sqlite::open("in.sqlite").unwrap(); + let connection_dest = sqlite::open("out.sqlite").unwrap(); + + let actualbudget_first_transaction_date = get_first_transaction_date(&connection_src); + let actualbudget_accounts = get_accounts(&connection_src); + let actualbudget_categories = get_categories(&connection_src); + let actualbudget_zero_budgets = get_zero_budgets(&connection_src); + let actualbudget_category_transfers_account = get_category_transfers_account(&connection_src); + let actualbudget_transactions = get_transactions(&connection_src); + + let first_transaction_date = transformers::transform_date(&actualbudget_first_transaction_date); + let accounts = + transformers::transform_accounts(&actualbudget_accounts, &first_transaction_date); + let categories = transformers::transform_categories(&actualbudget_categories); + let budget_updates = transformers::transform_budget_updates(&actualbudget_zero_budgets); + let (transactions, transaction_categorisations) = transformers::transform_transactions( + &actualbudget_transactions, + &actualbudget_category_transfers_account, + ); + let category_transfers = transformers::transform_category_transfers( + &actualbudget_transactions, + &actualbudget_category_transfers_account, + ); + + clear_destination(&connection_dest); + insert_accounts(&connection_dest, &accounts); + insert_categories(&connection_dest, &categories); + insert_budget_updates(&connection_dest, &budget_updates); + insert_transactions(&connection_dest, &transactions); + insert_transaction_categorisations(&connection_dest, &transaction_categorisations); + insert_category_transfers(&connection_dest, &category_transfers); +} + +fn clear_destination(connection: &Connection) { + connection + .execute( + " + delete from accounts; + delete from budget_updates; + delete from categories; + delete from transaction_categorisations; + delete from category_transfers; + delete from transactions; + ", + ) + .unwrap(); +} diff --git a/backend/importers/actualbudget/src/models/actualbudget/account.rs b/backend/importers/actualbudget/src/models/actualbudget/account.rs new file mode 100644 index 0000000..727c082 --- /dev/null +++ b/backend/importers/actualbudget/src/models/actualbudget/account.rs @@ -0,0 +1,15 @@ +use sqlite::Statement; + +pub struct Account { + pub id: String, + pub name: String, +} + +impl Account { + pub fn from_row(statement: &Statement) -> Account { + Account { + id: statement.read("id").unwrap(), + name: statement.read("name").unwrap(), + } + } +} diff --git a/backend/importers/actualbudget/src/models/actualbudget/category.rs b/backend/importers/actualbudget/src/models/actualbudget/category.rs new file mode 100644 index 0000000..21fdf80 --- /dev/null +++ b/backend/importers/actualbudget/src/models/actualbudget/category.rs @@ -0,0 +1,15 @@ +use sqlite::Statement; + +pub struct Category { + pub id: String, + pub name: String, +} + +impl Category { + pub fn from_row(statement: &Statement) -> Category { + Category { + id: statement.read("id").unwrap(), + name: statement.read("name").unwrap(), + } + } +} diff --git a/backend/importers/actualbudget/src/models/actualbudget/date.rs b/backend/importers/actualbudget/src/models/actualbudget/date.rs new file mode 100644 index 0000000..bd37c46 --- /dev/null +++ b/backend/importers/actualbudget/src/models/actualbudget/date.rs @@ -0,0 +1,69 @@ +use sqlite::Statement; + +#[derive(Clone, PartialEq, Eq)] +pub struct Date { + value: i64, + last_date_part: DatePart, +} + +#[derive(Clone, PartialOrd, PartialEq, Eq)] +enum DatePart { + MonthOfYear, + DayOfMonth, +} + +// actualbudget represents dates as numbers which "look like" the corresponding +// ISO string when written in decimal. E.g. 20240901 represents 1 Sep 2024. It +// also uses month specifiers, e.g. 202409 to represent Sep 2024. In this case, +// we interpret this as the first day of the month, e.g. 2024-09-01. + +impl Date { + pub fn from_date_row(statement: &Statement, column: &str) -> Date { + Date { + value: statement.read::<i64, _>(column).unwrap(), + last_date_part: DatePart::DayOfMonth, + } + } + + pub fn from_month_row(statement: &Statement, column: &str) -> Date { + Date { + value: statement.read::<i64, _>(column).unwrap() * 100 + 1, + last_date_part: DatePart::MonthOfYear, + } + } + + pub fn to_iso(&self) -> String { + format!("{0}-{1}-{2}", self.year(), self.month(), self.day()) + } + + pub fn year(&self) -> String { + self.value.to_string()[0..4].to_string() + } + + pub fn month(&self) -> String { + self.value.to_string()[4..6].to_string() + } + + pub fn day(&self) -> String { + self.dayified_value().to_string()[6..8].to_string() + } + + fn dayified_value(&self) -> i64 { + match self.last_date_part { + DatePart::DayOfMonth => self.value, + DatePart::MonthOfYear => self.value * 100 + 1, + } + } +} + +impl Ord for Date { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.dayified_value().cmp(&other.dayified_value()) + } +} + +impl PartialOrd for Date { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(other)) + } +} diff --git a/backend/importers/actualbudget/src/models/actualbudget/mod.rs b/backend/importers/actualbudget/src/models/actualbudget/mod.rs new file mode 100644 index 0000000..5ca472f --- /dev/null +++ b/backend/importers/actualbudget/src/models/actualbudget/mod.rs @@ -0,0 +1,5 @@ +pub mod account; +pub mod category; +pub mod date; +pub mod transaction; +pub mod zero_budget; diff --git a/backend/importers/actualbudget/src/models/actualbudget/transaction.rs b/backend/importers/actualbudget/src/models/actualbudget/transaction.rs new file mode 100644 index 0000000..0a21cfe --- /dev/null +++ b/backend/importers/actualbudget/src/models/actualbudget/transaction.rs @@ -0,0 +1,43 @@ +use sqlite::Statement; + +use super::date::Date; + +#[derive(Clone)] +pub struct Transaction { + pub id: String, + pub is_parent: bool, + pub is_child: bool, + pub account_id: String, + pub category_id: String, + pub amount: i64, + pub payee: String, + pub notes: String, + pub date: Date, + pub parent_id: Option<String>, +} + +impl Transaction { + pub fn from_row(statement: &Statement) -> Transaction { + Transaction { + id: statement.read("id").unwrap(), + is_parent: statement.read::<i64, &str>("isParent").unwrap() == 1, + is_child: statement.read::<i64, &str>("isChild").unwrap() == 1, + account_id: statement.read("acct").unwrap(), + category_id: statement + .read::<Option<String>, &str>("category") + .map(Option::<String>::unwrap_or_default) + .unwrap(), + amount: statement.read("amount").unwrap(), + payee: statement + .read::<Option<String>, &str>("description") + .map(Option::<String>::unwrap_or_default) + .unwrap(), + notes: statement + .read::<Option<String>, &str>("notes") + .map(Option::<String>::unwrap_or_default) + .unwrap(), + date: Date::from_date_row(statement, "date"), + parent_id: statement.read("parent_id").unwrap(), + } + } +} diff --git a/backend/importers/actualbudget/src/models/actualbudget/zero_budget.rs b/backend/importers/actualbudget/src/models/actualbudget/zero_budget.rs new file mode 100644 index 0000000..87c88e7 --- /dev/null +++ b/backend/importers/actualbudget/src/models/actualbudget/zero_budget.rs @@ -0,0 +1,30 @@ +use sqlite::Statement; + +use super::date::Date; + +#[derive(Clone)] +pub struct ZeroBudget { + pub id: String, + pub month: Date, + pub category_id: String, + pub amount: i64, + pub do_carry_over: bool, +} + +impl ZeroBudget { + pub fn from_row(statement: &Statement) -> ZeroBudget { + ZeroBudget { + id: statement.read("id").unwrap(), + month: Date::from_month_row(statement, "month"), + category_id: statement.read("category").unwrap(), + amount: statement.read("amount").unwrap(), + do_carry_over: match statement.read::<i64, &str>("carryover").unwrap() { + 0 => false, + 1 => true, + carryover => { + panic!("carryover column was neither 0 nor 1, but instead {carryover}") + } + }, + } + } +} diff --git a/backend/importers/actualbudget/src/models/budgeting_app/DateTimeUtc.rs b/backend/importers/actualbudget/src/models/budgeting_app/DateTimeUtc.rs new file mode 100644 index 0000000..e44fa8c --- /dev/null +++ b/backend/importers/actualbudget/src/models/budgeting_app/DateTimeUtc.rs @@ -0,0 +1,57 @@ +use chrono::{DateTime, Datelike, Utc}; +use diesel::{ + deserialize::{FromSql, FromSqlRow}, + expression::AsExpression, + serialize::ToSql, + sql_types::Text, + sqlite::Sqlite, +}; +use serde::{Deserialize, Serialize}; + +#[derive( + Eq, + PartialEq, + Ord, + PartialOrd, + Serialize, + Deserialize, + Copy, + Clone, + Debug, + AsExpression, + FromSqlRow, +)] +#[diesel(sql_type=Text)] +pub struct DateTimeUtc(DateTime<Utc>); + +impl DateTimeUtc { + pub fn day_diff(&self, other: &DateTimeUtc) -> u32 { + self.0 + .num_days_from_ce() + .abs_diff(other.0.num_days_from_ce()) + } + + pub fn new(date_time_utc: DateTime<Utc>) -> Self { + Self(date_time_utc) + } +} + +impl ToSql<Text, Sqlite> for DateTimeUtc { + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, Sqlite>, + ) -> diesel::serialize::Result { + out.set_value((*self).0.to_rfc3339()); + Ok(diesel::serialize::IsNull::No) + } +} + +impl FromSql<Text, Sqlite> for DateTimeUtc { + fn from_sql( + bytes: <Sqlite as diesel::backend::Backend>::RawValue<'_>, + ) -> diesel::deserialize::Result<Self> { + let str: String = FromSql::<Text, Sqlite>::from_sql(bytes)?; + let date_time = str.as_str().parse::<DateTime<Utc>>()?; + Ok(DateTimeUtc(date_time)) + } +} diff --git a/backend/importers/actualbudget/src/models/budgeting_app/account.rs b/backend/importers/actualbudget/src/models/budgeting_app/account.rs new file mode 100644 index 0000000..165b55c --- /dev/null +++ b/backend/importers/actualbudget/src/models/budgeting_app/account.rs @@ -0,0 +1,8 @@ +use super::date_time_utc::DateTimeUtc; + +pub struct Account { + pub id: i32, + pub name: String, + pub opening_balance: i32, + pub opening_date: DateTimeUtc, +} diff --git a/backend/importers/actualbudget/src/models/budgeting_app/budget_update.rs b/backend/importers/actualbudget/src/models/budgeting_app/budget_update.rs new file mode 100644 index 0000000..9958446 --- /dev/null +++ b/backend/importers/actualbudget/src/models/budgeting_app/budget_update.rs @@ -0,0 +1,10 @@ +use super::date_time_utc::DateTimeUtc; + +#[derive(Clone)] +pub struct BudgetUpdate { + pub id: i32, + pub category_id: i32, + pub date: DateTimeUtc, + pub new_budget: i32, + pub new_period: i32, +} diff --git a/backend/importers/actualbudget/src/models/budgeting_app/category.rs b/backend/importers/actualbudget/src/models/budgeting_app/category.rs new file mode 100644 index 0000000..3f51dfd --- /dev/null +++ b/backend/importers/actualbudget/src/models/budgeting_app/category.rs @@ -0,0 +1,4 @@ +pub struct Category { + pub id: i32, + pub name: String, +} diff --git a/backend/importers/actualbudget/src/models/budgeting_app/category_transfer.rs b/backend/importers/actualbudget/src/models/budgeting_app/category_transfer.rs new file mode 100644 index 0000000..0097429 --- /dev/null +++ b/backend/importers/actualbudget/src/models/budgeting_app/category_transfer.rs @@ -0,0 +1,7 @@ +pub struct CategoryTransfer { + pub id: i32, + pub description: String, + pub quantity: i32, + pub from_category_id: i32, + pub to_category_id: i32, +} diff --git a/backend/importers/actualbudget/src/models/budgeting_app/date_time_utc.rs b/backend/importers/actualbudget/src/models/budgeting_app/date_time_utc.rs new file mode 100644 index 0000000..69cdfe2 --- /dev/null +++ b/backend/importers/actualbudget/src/models/budgeting_app/date_time_utc.rs @@ -0,0 +1,20 @@ +use std::{fmt::Display, str::FromStr}; + +use chrono::{DateTime, Utc}; + +#[derive(Clone)] +pub struct DateTimeUtc(DateTime<Utc>); + +impl DateTimeUtc { + pub fn new(date: &str) -> Self { + let mut date_time = String::from(date); + date_time.push_str("T00:00:00.000Z"); + Self(DateTime::<Utc>::from_str(&date_time).unwrap()) + } +} + +impl Display for DateTimeUtc { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} diff --git a/backend/importers/actualbudget/src/models/budgeting_app/mod.rs b/backend/importers/actualbudget/src/models/budgeting_app/mod.rs new file mode 100644 index 0000000..bca0f99 --- /dev/null +++ b/backend/importers/actualbudget/src/models/budgeting_app/mod.rs @@ -0,0 +1,7 @@ +pub mod account; +pub mod budget_update; +pub mod category; +pub mod category_transfer; +pub mod date_time_utc; +pub mod transaction; +pub mod transaction_categorisation; diff --git a/backend/importers/actualbudget/src/models/budgeting_app/transaction.rs b/backend/importers/actualbudget/src/models/budgeting_app/transaction.rs new file mode 100644 index 0000000..e679e1e --- /dev/null +++ b/backend/importers/actualbudget/src/models/budgeting_app/transaction.rs @@ -0,0 +1,11 @@ +use super::date_time_utc::DateTimeUtc; + +#[derive(Clone)] +pub struct Transaction { + pub id: i32, + pub description: String, + pub payee: String, + pub quantity: i32, + pub date: DateTimeUtc, + pub account_id: i32, +} diff --git a/backend/importers/actualbudget/src/models/budgeting_app/transaction_categorisation.rs b/backend/importers/actualbudget/src/models/budgeting_app/transaction_categorisation.rs new file mode 100644 index 0000000..ca7e7f5 --- /dev/null +++ b/backend/importers/actualbudget/src/models/budgeting_app/transaction_categorisation.rs @@ -0,0 +1,7 @@ +pub struct TransactionCategorisation { + pub id: i32, + pub description: String, + pub quantity: i32, + pub transaction_id: i32, + pub category_id: i32, +} diff --git a/backend/importers/actualbudget/src/models/mod.rs b/backend/importers/actualbudget/src/models/mod.rs new file mode 100644 index 0000000..cf1b71a --- /dev/null +++ b/backend/importers/actualbudget/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod actualbudget; +pub mod budgeting_app; diff --git a/backend/importers/actualbudget/src/transformers.rs b/backend/importers/actualbudget/src/transformers.rs new file mode 100644 index 0000000..678ea05 --- /dev/null +++ b/backend/importers/actualbudget/src/transformers.rs @@ -0,0 +1,221 @@ +use core::panic; +use std::hash::{DefaultHasher, Hash, Hasher}; + +use crate::models::{ + actualbudget, + budgeting_app::{ + account::Account, budget_update::BudgetUpdate, category::Category, + category_transfer::CategoryTransfer, date_time_utc::DateTimeUtc, transaction::Transaction, + transaction_categorisation::TransactionCategorisation, + }, +}; + +pub fn transform_date(actualbudget_date: &actualbudget::date::Date) -> DateTimeUtc { + DateTimeUtc::new(&actualbudget_date.to_iso()) +} + +pub fn transform_accounts( + actualbudget_accounts: &[actualbudget::account::Account], + first_transaction_date: &DateTimeUtc, +) -> Vec<Account> { + let mut accounts = Vec::<Account>::new(); + for account in actualbudget_accounts { + accounts.push(transform_account(account, first_transaction_date)); + } + accounts +} + +fn transform_account( + actualbudget_account: &actualbudget::account::Account, + opening_date: &DateTimeUtc, +) -> Account { + Account { + id: transform_id(&actualbudget_account.id.clone()), + name: actualbudget_account.name.clone(), + opening_balance: 0, + opening_date: opening_date.clone(), + } +} + +pub fn transform_categories( + actualbudget_categories: &[actualbudget::category::Category], +) -> Vec<Category> { + let mut categories = Vec::<Category>::new(); + for actualbudget_category in actualbudget_categories { + categories.push(Category { + id: transform_id(&actualbudget_category.id.clone()), + name: actualbudget_category.name.clone(), + }) + } + categories +} + +fn transform_id(actualbudget_id: &str) -> i32 { + let mut hasher = DefaultHasher::new(); + actualbudget_id.hash(&mut hasher); + (hasher.finish() % std::convert::TryInto::<u64>::try_into(i32::MAX).unwrap()) + .try_into() + .unwrap() +} + +pub fn transform_budget_updates( + actualbudget_zero_budgets: &[actualbudget::zero_budget::ZeroBudget], +) -> Vec<BudgetUpdate> { + let mut budget_updates = Vec::<BudgetUpdate>::new(); + for zero_budget in actualbudget_zero_budgets { + let first_zero_budget_in_category = get_first_zero_budget_in_category( + actualbudget_zero_budgets, + &zero_budget.category_id.clone(), + ); + if first_zero_budget_in_category.is_some_and(|zb| zb.id == zero_budget.id) { + continue; + } + budget_updates.push(BudgetUpdate { + id: transform_id(&zero_budget.id.clone()), + category_id: transform_id(&zero_budget.category_id.clone()), + date: transform_date(&zero_budget.month.clone()), + new_budget: zero_budget.amount.try_into().unwrap(), + new_period: 30, + }) + } + budget_updates +} + +fn get_first_zero_budget_in_category( + actualbudget_zero_budgets: &[actualbudget::zero_budget::ZeroBudget], + actualbudget_category_id: &str, +) -> Option<actualbudget::zero_budget::ZeroBudget> { + let mut category_zero_budgets = actualbudget_zero_budgets.to_vec(); + category_zero_budgets.retain(|zb| zb.category_id == actualbudget_category_id); + category_zero_budgets + .iter() + .min_by_key(|zb| zb.month.clone()) + .cloned() +} + +pub fn transform_transactions( + actualbudget_transactions: &[actualbudget::transaction::Transaction], + actualbudget_category_transfers_account: &actualbudget::account::Account, +) -> (Vec<Transaction>, Vec<TransactionCategorisation>) { + let mut transactions = Vec::<Transaction>::new(); + let mut transaction_categorisations = Vec::<TransactionCategorisation>::new(); + + let mut actualbudget_transactions = actualbudget_transactions.to_vec(); + actualbudget_transactions + .retain(|t| t.category_id.clone() != actualbudget_category_transfers_account.id.clone()); + let mut parent_actualbudget_transactions = actualbudget_transactions.to_vec(); + parent_actualbudget_transactions.retain(|t| t.is_parent); + let mut child_actualbudget_transactions = actualbudget_transactions.to_vec(); + child_actualbudget_transactions.retain(|t| t.is_child); + let mut atomic_actualbudget_transactions = actualbudget_transactions.to_vec(); + atomic_actualbudget_transactions.retain(|t| !t.is_parent && !t.is_child); + + for atomic_actualbudget_transaction in atomic_actualbudget_transactions { + let transaction_id = transform_id(&atomic_actualbudget_transaction.id); + + transactions.push(Transaction { + id: transaction_id, + description: atomic_actualbudget_transaction.notes, + payee: atomic_actualbudget_transaction.payee, + quantity: atomic_actualbudget_transaction.amount.try_into().unwrap(), + date: transform_date(&atomic_actualbudget_transaction.date), + account_id: transform_id(&atomic_actualbudget_transaction.account_id), + }); + + transaction_categorisations.push(TransactionCategorisation { + id: transaction_id, + description: String::new(), + quantity: atomic_actualbudget_transaction.amount.try_into().unwrap(), + transaction_id, + category_id: transform_id(&atomic_actualbudget_transaction.category_id), + }); + } + + for parent_actualbudget_transaction in parent_actualbudget_transactions { + let mut child_actualbudget_transactions = child_actualbudget_transactions.to_vec(); + child_actualbudget_transactions.retain(|t| { + t.parent_id + .clone() + .is_some_and(|parent_id| parent_id == parent_actualbudget_transaction.id.clone()) + }); + + let transaction_id = transform_id(&parent_actualbudget_transaction.id); + + transactions.push(Transaction { + id: transaction_id, + description: parent_actualbudget_transaction.notes, + payee: parent_actualbudget_transaction.payee, + quantity: parent_actualbudget_transaction.amount.try_into().unwrap(), + date: transform_date(&parent_actualbudget_transaction.date), + account_id: transform_id(&parent_actualbudget_transaction.account_id), + }); + + for child_actualbudget_transaction in child_actualbudget_transactions { + transaction_categorisations.push(TransactionCategorisation { + id: transform_id(&child_actualbudget_transaction.id), + description: child_actualbudget_transaction.notes, + quantity: child_actualbudget_transaction.amount.try_into().unwrap(), + transaction_id, + category_id: transform_id(&child_actualbudget_transaction.category_id), + }); + } + } + + (transactions, transaction_categorisations) +} + +pub fn transform_category_transfers( + actualbudget_transactions: &[actualbudget::transaction::Transaction], + actualbudget_category_transfers_account: &actualbudget::account::Account, +) -> Vec<CategoryTransfer> { + let mut category_transfers = Vec::<CategoryTransfer>::new(); + + let mut actualbudget_category_transfers = actualbudget_transactions.to_vec(); + actualbudget_category_transfers + .retain(|t| t.category_id.clone() == actualbudget_category_transfers_account.id.clone()); + let mut actualbudget_parent_category_transfers = actualbudget_category_transfers.to_vec(); + actualbudget_parent_category_transfers.retain(|t| t.is_parent); + let mut actualbudget_child_category_transfers = actualbudget_category_transfers.to_vec(); + actualbudget_child_category_transfers.retain(|t| t.is_child); + + for actualbudget_parent_category_transfer in actualbudget_parent_category_transfers { + let mut actualbudget_child_category_transfers = + actualbudget_child_category_transfers.to_vec(); + actualbudget_child_category_transfers.retain(|t| { + t.parent_id + .clone() + .is_some_and(|parent_id| parent_id == actualbudget_parent_category_transfer.id) + }); + + if actualbudget_child_category_transfers.len() != 2 { + panic!("Category transfers should always have exactly two children."); + } + + let category_1 = actualbudget_child_category_transfers[0].clone(); + let category_2 = actualbudget_child_category_transfers[1].clone(); + + // To avoid duplicates + if category_1.amount >= 0 { + continue; + } + + if actualbudget_child_category_transfers[0].amount + != -actualbudget_child_category_transfers[1].amount + { + panic!("Category transfer children should sum to zero."); + } + + category_transfers.push(CategoryTransfer { + id: transform_id(&actualbudget_parent_category_transfer.id), + description: actualbudget_parent_category_transfer.notes, + quantity: actualbudget_child_category_transfers[0] + .amount + .try_into() + .unwrap(), + from_category_id: transform_id(&category_1.id), + to_category_id: transform_id(&category_2.id), + }); + } + + category_transfers +} |
