diff --git a/packages/desktop-client/e2e/data/ynab5-demo-budget.json b/packages/desktop-client/e2e/data/ynab5-demo-budget.json index 54ae0aa6c0..fe236e8bb9 100644 --- a/packages/desktop-client/e2e/data/ynab5-demo-budget.json +++ b/packages/desktop-client/e2e/data/ynab5-demo-budget.json @@ -108,6 +108,12 @@ "name": "Work", "transfer_account_id": null, "deleted": false + }, + { + "id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "name": "Schedule Payee", + "transfer_account_id": null, + "deleted": false } ], "payee_locations": [], @@ -167,6 +173,12 @@ "hidden": false, "deleted": false }, + { + "id": "6d28a243-3670-4c96-8334-216e31ea9468", + "name": "Category Group", + "hidden": false, + "deleted": false + }, { "id": "F5751985-3290-41E7-B17F-6DBE979F315D", "name": "Bills", @@ -726,6 +738,30 @@ "goal_overall_funded": null, "goal_overall_left": null, "deleted": false + }, + { + "id": "419ae801-27c8-424b-8f39-9611825803db", + "category_group_id": "6d28a243-3670-4c96-8334-216e31ea9468", + "name": "Category", + "hidden": false, + "original_category_group_id": null, + "note": null, + "budgeted": 0, + "activity": 0, + "balance": 0, + "goal_type": null, + "goal_day": null, + "goal_cadence": null, + "goal_cadence_frequency": null, + "goal_creation_month": null, + "goal_target": 0, + "goal_target_month": null, + "goal_percentage_complete": null, + "goal_months_to_budget": null, + "goal_under_funded": null, + "goal_overall_funded": null, + "goal_overall_left": null, + "deleted": false } ], "months": [ @@ -1870,8 +1906,243 @@ "deleted": false } ], - "scheduled_transactions": [], - "scheduled_subtransactions": [] + "scheduled_transactions": [ + { + "id": "1db8beb8-ef31-4a07-b9a5-0648b1e3071a", + "date_first": "2025-08-05", + "date_next": "2025-08-05", + "frequency": "every4Weeks", + "amount": -100000, + "memo": "Scheduled - repeated every four weeks", + "flag_color": null, + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "2cb5676a-9b6e-4fff-aaf5-7ace218bb918", + "date_first": "2025-08-03", + "date_next": "2025-08-17", + "frequency": "everyOtherWeek", + "amount": -100000, + "memo": "Scheduled - repeated every other week", + "flag_color": null, + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "34157157-8ad5-46b4-aa67-36f2035478ce", + "date_first": "2025-08-05", + "date_next": "2025-08-05", + "frequency": "everyOtherYear", + "amount": -100000, + "memo": "Scheduled - repeated every other year", + "flag_color": null, + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "64a5e1ee-ac5f-4fd7-b955-818ed97c0886", + "date_first": "2025-08-05", + "date_next": "2025-08-05", + "frequency": "every4Months", + "amount": -100000, + "memo": "Scheduled - repeated every four months", + "flag_color": null, + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "6a77929b-a54f-4401-9fc0-e3be672fe946", + "date_first": "2025-08-03", + "date_next": "2025-08-18", + "frequency": "twiceAMonth", + "amount": -100000, + "memo": "Scheduled - repeated twice a month", + "flag_color": null, + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "700739ce-35a2-4fb5-9522-70d152b73a81", + "date_first": "2025-08-05", + "date_next": "2025-08-05", + "frequency": "monthly", + "amount": -100000, + "memo": "Scheduled - repeated monthly", + "flag_color": null, + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "8afb5da0-e189-46bc-b41a-c3603588a950", + "date_first": "2025-08-03", + "date_next": "2025-08-10", + "frequency": "weekly", + "amount": -100000, + "memo": "Scheduled - repeated weekly", + "flag_color": "blue", + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "906ca596-9f93-4c73-aaf2-9ca1f8db8a86", + "date_first": "2025-08-04", + "date_next": "2025-08-04", + "frequency": "never", + "amount": -100000, + "memo": "Scheduled - not repeated", + "flag_color": "red", + "flag_name": "One-off", + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "a06f9cef-ec00-4561-9546-22513e0e11bb", + "date_first": "2025-08-05", + "date_next": "2025-08-05", + "frequency": "twiceAYear", + "amount": -100000, + "memo": "Scheduled - repeated twice a year", + "flag_color": null, + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "d5bf68a6-5026-47a8-a40f-7ecd2f9ba4da", + "date_first": "2025-08-05", + "date_next": "2025-08-05", + "frequency": "yearly", + "amount": -100000, + "memo": "Scheduled - repeated yearly", + "flag_color": null, + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "e842e6b8-096f-4152-8acd-9566cbee293b", + "date_first": "2025-08-05", + "date_next": "2025-08-05", + "frequency": "everyOtherMonth", + "amount": -100000, + "memo": "Scheduled - repeated every other month", + "flag_color": null, + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "ec72c5af-00c9-4ea0-aaa8-6863471beea8", + "date_first": "2025-08-05", + "date_next": "2025-08-05", + "frequency": "every3Months", + "amount": -100000, + "memo": "Scheduled - repeated every three months", + "flag_color": null, + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "f80e4d81-b640-4cac-a50c-39e6400e23a6", + "date_first": "2025-08-03", + "date_next": "2025-08-04", + "frequency": "daily", + "amount": -100000, + "memo": "Scheduled - repeated daily", + "flag_color": "purple", + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "4b8f0a2e-9c7a-4f8e-9dcb-6a20b3d54f0e", + "date_first": "2025-08-06", + "date_next": "2025-09-06", + "frequency": "monthly", + "amount": -100000, + "memo": "Scheduled - split categories monthly", + "flag_color": "green", + "flag_name": "Split", + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": null, + "transfer_account_id": null, + "deleted": false + }, + { + "id": "6e9dcaa6-0e96-4b08-90f7-2f8f12b7e6b6", + "date_first": "2025-08-07", + "date_next": "2025-08-21", + "frequency": "everyOtherWeek", + "amount": -50000, + "memo": "Scheduled - transfer to Saving", + "flag_color": "orange", + "flag_name": "Transfer", + "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", + "payee_id": "8d3017e0-2aa6-4fe2-b011-c53c9f147eb6", + "category_id": null, + "transfer_account_id": "125f339b-2a63-481e-84c0-f04d898905d2", + "deleted": false + } + ], + "scheduled_subtransactions": [ + { + "id": "2b5c23f6-109c-4f0f-8ee5-8b76407fc99f", + "scheduled_transaction_id": "4b8f0a2e-9c7a-4f8e-9dcb-6a20b3d54f0e", + "amount": -60000, + "memo": "split part a", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "419ae801-27c8-424b-8f39-9611825803db", + "transfer_account_id": null, + "deleted": false + }, + { + "id": "24a4b78f-5a83-4891-8205-b8bb3f9ddf34", + "scheduled_transaction_id": "4b8f0a2e-9c7a-4f8e-9dcb-6a20b3d54f0e", + "amount": -40000, + "memo": "split part b", + "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", + "category_id": "36120d44-6c61-4402-985a-891a8d267858", + "transfer_account_id": null, + "deleted": false + } + ] }, "server_knowledge": 58 } diff --git a/packages/desktop-client/e2e/onboarding.test.ts b/packages/desktop-client/e2e/onboarding.test.ts index 0365a5f0c0..964310c9dc 100644 --- a/packages/desktop-client/e2e/onboarding.test.ts +++ b/packages/desktop-client/e2e/onboarding.test.ts @@ -64,6 +64,32 @@ test.describe('Onboarding', () => { await navigation.goToAccountPage('Saving'); await expect(accountPage.accountBalance).toHaveText('250.00'); + + await navigation.goToSchedulesPage(); + const scheduleRows = page.getByTestId('table').getByTestId('row'); + const scheduleNames = [ + 'Scheduled - repeated every four weeks', + 'Scheduled - repeated every other week', + 'Scheduled - repeated every other year', + 'Scheduled - repeated every four months', + 'Scheduled - repeated twice a month', + 'Scheduled - repeated monthly', + 'Scheduled - repeated weekly', + 'Scheduled - not repeated', + 'Scheduled - repeated twice a year', + 'Scheduled - repeated yearly', + 'Scheduled - repeated every other month', + 'Scheduled - repeated every three months', + 'Scheduled - repeated daily', + 'Scheduled - split categories monthly', + 'Scheduled - transfer to Saving', + ]; + + for (const scheduleName of scheduleNames) { + await expect(scheduleRows.filter({ hasText: scheduleName })).toHaveCount( + 1, + ); + } }); test('creates a new budget file by importing Actual budget', async () => { diff --git a/packages/loot-core/src/server/importers/ynab5-types.ts b/packages/loot-core/src/server/importers/ynab5-types.ts index 86c124ee0c..f4e35fe48c 100644 --- a/packages/loot-core/src/server/importers/ynab5-types.ts +++ b/packages/loot-core/src/server/importers/ynab5-types.ts @@ -1,30 +1,116 @@ +// Source: https://api.ynab.com/papi/open_api_spec.yaml +// Schema refs use #/components/schemas/... + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/BudgetDetail export type Budget = { - name?: string; + id: string; + name: string; budget_name?: string; + last_modified_on?: string; + first_month?: string; + last_month?: string; + date_format?: DateFormat; + currency_format?: CurrencyFormat; accounts: Account[]; payees: Payee[]; + payee_locations: PayeeLocation[]; category_groups: CategoryGroup[]; categories: Category[]; - transactions: Transaction[]; + months: MonthDetail[]; + transactions: TransactionSummary[]; subtransactions: Subtransaction[]; - months: Month[]; + scheduled_transactions: ScheduledTransactionSummary[]; + scheduled_subtransactions: ScheduledSubtransaction[]; }; +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/DateFormat +// Description: The date format setting for the budget. In some cases the format will +// not be available and will be specified as null. +export type DateFormat = + | 'YYYY/MM/DD' + | 'YYYY-MM-DD' + | 'DD-MM-YYYY' + | 'DD/MM/YYYY' + | 'DD.MM.YYYY' + | 'MM/DD/YYYY' + | 'YYYY.MM.DD' + | null; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/CurrencyFormat +// Description: The currency format setting for the budget. In some cases the format will +// not be available and will be specified as null. +export type CurrencyFormat = { + iso_code: string; + example_format: string; + decimal_digits: number; + decimal_separator: string; + symbol_first: boolean; + group_separator: string; + currency_symbol: string; + display_symbol: boolean; +} | null; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/Account export type Account = { id: string; name: string; + type: AccountType; on_budget: boolean; - deleted: boolean; closed: boolean; + note?: string | null; + balance: number; + cleared_balance: number; + uncleared_balance: number; + transfer_payee_id: string | null; + direct_import_linked?: boolean; + direct_import_in_error?: boolean; + last_reconciled_at?: string | null; + debt_original_balance?: number | null; + debt_interest_rates?: LoanAccountPeriodicValue | null; + debt_minimum_payments?: LoanAccountPeriodicValue | null; + debt_escrow_amounts?: LoanAccountPeriodicValue | null; + deleted: boolean; }; +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/LoanAccountPeriodicValue +export type LoanAccountPeriodicValue = Record | null; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/AccountType +// Description: The type of account. +export type AccountType = + | 'checking' + | 'savings' + | 'cash' + | 'creditCard' + | 'lineOfCredit' + | 'otherAsset' + | 'otherLiability' + | 'mortgage' + | 'autoLoan' + | 'studentLoan' + | 'personalLoan' + | 'medicalDebt' + | 'otherDebt'; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/Payee export type Payee = { id: string; name: string; + transfer_account_id?: string | null; deleted: boolean; transfer_acct?: string; }; +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/PayeeLocation +export type PayeeLocation = { + id: string; + payee_id: string; + latitude: string; + longitude: string; + deleted: boolean; +}; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/CategoryGroup export type CategoryGroup = { id: string; name: string; @@ -33,47 +119,172 @@ export type CategoryGroup = { note?: string; }; +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/Category export type Category = { id: string; category_group_id: string; + category_group_name?: string; name: string; deleted: boolean; hidden: boolean; + original_category_group_id?: string | null; note?: string; + budgeted: number; + activity: number; + balance: number; + goal_type?: GoalType | null; + goal_needs_whole_amount?: boolean | null; + goal_day?: number | null; + goal_cadence?: number | null; + goal_cadence_frequency?: number | null; + goal_creation_month?: string | null; + goal_target?: number | null; + goal_target_month?: string | null; + goal_percentage_complete?: number | null; + goal_months_to_budget?: number | null; + goal_under_funded?: number | null; + goal_overall_funded?: number | null; + goal_overall_left?: number | null; + goal_snoozed_at?: string | null; }; -export type Transaction = { +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/Category +// Description: The type of goal, if the category has a goal. +// (TB='Target Category Balance', TBD='Target Category Balance by Date', MF='Monthly +// Funding', NEED='Plan Your Spending') +export type GoalType = 'TB' | 'TBD' | 'MF' | 'NEED' | 'DEBT'; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/TransactionSummary +export type TransactionSummary = { id: string; - account_id: string; date: string; - payee_id: string; - import_id: string; - category_id: string; - transfer_account_id: string; - transfer_transaction_id: string; - memo: string; - cleared: string; amount: number; + memo?: string | null; + cleared: TransactionClearedStatus; + approved: boolean; + flag_color?: TransactionFlagColor | null; + flag_name?: TransactionFlagName | null; + account_id: string; + payee_id?: string | null; + category_id?: string | null; + transfer_account_id?: string | null; + transfer_transaction_id?: string | null; + matched_transaction_id?: string | null; + import_id?: string | null; + import_payee_name?: string | null; + import_payee_name_original?: string | null; + debt_transaction_type?: DebtTransactionType | null; deleted: boolean; }; +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/TransactionClearedStatus +// Description: The cleared status of the transaction. +export type TransactionClearedStatus = 'cleared' | 'uncleared' | 'reconciled'; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/TransactionFlagColor +// Description: The transaction flag. +export type TransactionFlagColor = + | 'red' + | 'orange' + | 'yellow' + | 'green' + | 'blue' + | 'purple' + | '' + | null; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/TransactionFlagName +// Description: The customized name of a transaction flag. +export type TransactionFlagName = string | null; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/TransactionSummary +// Description: If the transaction is a debt/loan account transaction, the type of transaction +export type DebtTransactionType = + | 'payment' + | 'refund' + | 'fee' + | 'interest' + | 'escrow' + | 'balanceAdjustment' + | 'credit' + | 'charge'; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/SubTransaction export type Subtransaction = { id: string; transaction_id: string; - category_id: string; - memo: string; amount: number; - transfer_account_id: string; - payee_id: string; + memo?: string | null; + payee_id?: string | null; + payee_name?: string | null; + category_id?: string | null; + category_name?: string | null; + transfer_account_id?: string | null; + transfer_transaction_id?: string | null; + deleted: boolean; }; -export type Month = { - month: string; - categories: MonthCategory[]; -}; - -export type MonthCategory = { - category_group_id: string; +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/ScheduledTransactionSummary +export type ScheduledTransactionSummary = { id: string; - budgeted: number; + date_first: string; + date_next: string; + frequency: ScheduledTransactionFrequency; + amount: number; + memo?: string | null; + flag_color?: TransactionFlagColor | null; + flag_name?: TransactionFlagName | null; + account_id: string; + payee_id?: string | null; + category_id?: string | null; + transfer_account_id?: string | null; + deleted: boolean; +}; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/ScheduledSubTransaction +export type ScheduledSubtransaction = { + id: string; + scheduled_transaction_id: string; + amount: number; + memo?: string | null; + payee_id?: string | null; + payee_name?: string | null; + category_id?: string | null; + category_name?: string | null; + transfer_account_id?: string | null; + deleted: boolean; +}; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/ScheduledTransactionFrequency +// Description: The scheduled transaction frequency. +export type ScheduledTransactionFrequency = + | 'never' + | 'daily' + | 'weekly' + | 'everyOtherWeek' + | 'twiceAMonth' + | 'every4Weeks' + | 'monthly' + | 'everyOtherMonth' + | 'every3Months' + | 'every4Months' + | 'twiceAYear' + | 'yearly' + | 'everyOtherYear'; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/MonthSummary +export type MonthSummary = { + month: string; + note?: string | null; + income: number; + budgeted: number; + activity: number; + to_be_budgeted: number; + age_of_money?: number | null; + deleted: boolean; +}; + +// Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/MonthDetail +export type MonthDetail = MonthSummary & { + categories: Category[]; }; diff --git a/packages/loot-core/src/server/importers/ynab5.ts b/packages/loot-core/src/server/importers/ynab5.ts index 786d86a5d1..6242c32d20 100644 --- a/packages/loot-core/src/server/importers/ynab5.ts +++ b/packages/loot-core/src/server/importers/ynab5.ts @@ -9,17 +9,146 @@ import { v4 as uuidv4 } from 'uuid'; import { logger } from '../../platform/server/log'; import * as monthUtils from '../../shared/months'; +import { q } from '../../shared/query'; import { groupBy, sortByKey } from '../../shared/util'; +import { + type RecurConfig, + type RecurPattern, + type RuleEntity, +} from '../../types/models'; +import { ruleModel } from '../transactions/transaction-rules'; -import type * as YNAB5 from './ynab5-types'; +import type { + Budget, + Payee, + ScheduledSubtransaction, + ScheduledTransactionSummary, + Subtransaction, + TransactionSummary, +} from './ynab5-types'; function amountFromYnab(amount: number) { - // ynabs multiplies amount by 1000 and actual by 100 + // YNAB multiplies amount by 1000 and Actual by 100 // so, this function divides by 10 return Math.round(amount / 10); } -function importAccounts(data: YNAB5.Budget, entityIdMap: Map) { +function getDayOfMonth(date: string) { + return monthUtils.parseDate(date).getDate(); +} + +function getYnabMonthlyPatterns(dateFirst: string): RecurPattern[] | undefined { + if (getDayOfMonth(dateFirst) !== 31) { + return undefined; + } + + return [ + { + type: 'day', + value: -1, + }, + ]; +} + +// Use Actual's "specific days" to avoid drifting every 15 days. +// This approximates YNAB's "second occurrence is 15 days after the chosen day" +// by locking to two day-of-month values. +function getYnabTwiceMonthlyPatterns(dateFirst: string): RecurPattern[] { + const firstDay = getDayOfMonth(dateFirst); + // Compute the second occurrence as 15 calendar days after the first. + const secondDay = getDayOfMonth(monthUtils.addDays(dateFirst, 15)); + + return [ + { type: 'day', value: firstDay === 31 ? -1 : firstDay }, + { type: 'day', value: secondDay === 31 ? -1 : secondDay }, + ]; +} + +function mapYnabFrequency( + frequency: string, + dateFirst: string, +): { + frequency: RecurConfig['frequency']; + interval?: number; + patterns?: RecurPattern[]; +} { + switch (frequency) { + case 'daily': + return { frequency: 'daily' }; + case 'weekly': + return { frequency: 'weekly' }; + case 'monthly': + return { + frequency: 'monthly', + patterns: getYnabMonthlyPatterns(dateFirst), + }; + case 'yearly': + return { frequency: 'yearly' }; + case 'everyOtherWeek': + return { frequency: 'weekly', interval: 2 }; + case 'every4Weeks': + return { frequency: 'weekly', interval: 4 }; + case 'everyOtherMonth': + return { + frequency: 'monthly', + interval: 2, + patterns: getYnabMonthlyPatterns(dateFirst), + }; + case 'every3Months': + return { + frequency: 'monthly', + interval: 3, + patterns: getYnabMonthlyPatterns(dateFirst), + }; + case 'every4Months': + return { + frequency: 'monthly', + interval: 4, + patterns: getYnabMonthlyPatterns(dateFirst), + }; + case 'everyOtherYear': + return { frequency: 'yearly', interval: 2 }; + case 'twiceAMonth': { + return { + frequency: 'monthly', + patterns: getYnabTwiceMonthlyPatterns(dateFirst), + }; + } + case 'twiceAYear': { + return { + frequency: 'monthly', + interval: 6, + patterns: getYnabMonthlyPatterns(dateFirst), + }; + } + default: + throw new Error(`Unsupported scheduled frequency: ${frequency}`); + } +} + +function getScheduleDateValue( + scheduled: ScheduledTransactionSummary, +): RecurConfig | string { + const dateFirst = scheduled.date_first; + const frequency = scheduled.frequency; + + if (frequency === 'never') { + return scheduled.date_next; + } + + const mapped = mapYnabFrequency(frequency, dateFirst); + return { + frequency: mapped.frequency, + interval: mapped.interval, + patterns: mapped.patterns, + skipWeekend: false, + weekendSolveMode: 'after', + endMode: 'never', + start: dateFirst, + }; +} + +function importAccounts(data: Budget, entityIdMap: Map) { return Promise.all( data.accounts.map(async account => { if (!account.deleted) { @@ -35,7 +164,7 @@ function importAccounts(data: YNAB5.Budget, entityIdMap: Map) { } async function importCategories( - data: YNAB5.Budget, + data: Budget, entityIdMap: Map, ) { // Hidden categories are put in its own group by YNAB, @@ -167,7 +296,7 @@ async function importCategories( } } -function importPayees(data: YNAB5.Budget, entityIdMap: Map) { +function importPayees(data: Budget, entityIdMap: Map) { return Promise.all( data.payees.map(async payee => { if (!payee.deleted) { @@ -180,8 +309,242 @@ function importPayees(data: YNAB5.Budget, entityIdMap: Map) { ); } +async function importScheduledTransactions( + data: Budget, + entityIdMap: Map, +) { + const scheduledTransactions = data.scheduled_transactions; + const scheduledSubtransactionsGrouped = groupBy( + data.scheduled_subtransactions, + 'scheduled_transaction_id', + ); + if (scheduledTransactions.length === 0) { + return; + } + + const payees = await actual.getPayees(); + const payeesByTransferAcct = payees + .filter(payee => payee?.transfer_acct) + .map(payee => [payee.transfer_acct, payee] as [string, Payee]); + const payeeTransferAcctHashMap = new Map(payeesByTransferAcct); + const scheduleCategoryMap = new Map(); + const scheduleSplitsMap = new Map(); + const schedulePayeeMap = new Map(); + + async function createScheduleWithUniqueName(params: { + name: string; + posts_transaction: boolean; + payee: string; + account: string; + amount: number; + amountOp: 'is'; + date: RecurConfig | string; + }) { + const baseName = params.name; + const MAX_RETRY = 50; + let count = 1; + + while (true) { + try { + return await actual.createSchedule({ ...params, name: params.name }); + } catch (e) { + if (count >= MAX_RETRY) { + throw Error(e.message); + } + params.name = `${baseName} (${count})`; + count += 1; + } + } + } + + async function getRuleForSchedule( + scheduleId: string, + ): Promise { + const { data: ruleId } = (await actual.aqlQuery( + q('schedules').filter({ id: scheduleId }).calculate('rule'), + )) as { data: string | null }; + if (!ruleId) { + return null; + } + + const { data: ruleData } = (await actual.aqlQuery( + q('rules').filter({ id: ruleId }).select('*'), + )) as { data: Array> }; + const ruleRow = ruleData?.[0]; + if (!ruleRow) { + return null; + } + + return ruleModel.toJS(ruleRow); + } + + for (const scheduled of scheduledTransactions) { + if (scheduled.deleted) { + continue; + } + + const mappedAccountId = entityIdMap.get(scheduled.account_id); + if (!mappedAccountId) { + continue; + } + + const scheduleDate = getScheduleDateValue(scheduled); + + let mappedPayeeId: string | undefined; + if (scheduled.transfer_account_id) { + const mappedTransferAccountId = entityIdMap.get( + scheduled.transfer_account_id, + ); + mappedPayeeId = mappedTransferAccountId + ? payeeTransferAcctHashMap.get(mappedTransferAccountId)?.id + : undefined; + } else if (scheduled.payee_id) { + mappedPayeeId = entityIdMap.get(scheduled.payee_id); + } + + if (!mappedPayeeId) { + continue; + } + + const scheduleId = await createScheduleWithUniqueName({ + name: scheduled.memo, + posts_transaction: false, + payee: mappedPayeeId, + account: mappedAccountId, + amount: amountFromYnab(scheduled.amount), + amountOp: 'is', + date: scheduleDate, + }); + schedulePayeeMap.set(scheduleId, mappedPayeeId); + + const scheduleNotes = buildTransactionNotes(scheduled); + if (scheduleNotes) { + const rule = await getRuleForSchedule(scheduleId); + if (rule) { + const actions = rule.actions ? [...rule.actions] : []; + actions.push({ + op: 'set', + field: 'notes', + value: scheduleNotes, + }); + + await actual.updateRule(buildRuleUpdate(rule, actions)); + } + } + + const scheduledSubtransactions = + scheduledSubtransactionsGrouped + .get(scheduled.id) + ?.filter(subtransaction => !subtransaction.deleted) || []; + + if (scheduledSubtransactions.length > 0) { + scheduleSplitsMap.set(scheduleId, scheduledSubtransactions); + } else if (!scheduled.transfer_account_id && scheduled.category_id) { + const mappedCategoryId = entityIdMap.get(scheduled.category_id); + if (mappedCategoryId) { + scheduleCategoryMap.set(scheduleId, mappedCategoryId); + } + } + } + + if (scheduleCategoryMap.size > 0 || scheduleSplitsMap.size > 0) { + for (const [scheduleId, categoryId] of scheduleCategoryMap.entries()) { + const rule = await getRuleForSchedule(scheduleId); + if (!rule) { + continue; + } + + const actions = rule.actions ? [...rule.actions] : []; + actions.push({ + op: 'set', + field: 'category', + value: categoryId, + }); + + await actual.updateRule(buildRuleUpdate(rule, actions)); + } + + for (const [scheduleId, subtransactions] of scheduleSplitsMap.entries()) { + const rule = await getRuleForSchedule(scheduleId); + if (!rule) { + continue; + } + + const actions = rule.actions ? [...rule.actions] : []; + const parentPayeeId = schedulePayeeMap.get(scheduleId); + + subtransactions.forEach((subtransaction, index) => { + const splitIndex = index + 1; + + actions.push({ + op: 'set-split-amount', + value: amountFromYnab(subtransaction.amount), + options: { splitIndex, method: 'fixed-amount' }, + }); + + if (subtransaction.memo) { + actions.push({ + op: 'set', + field: 'notes', + value: subtransaction.memo, + options: { splitIndex }, + }); + } + + if (subtransaction.transfer_account_id) { + const mappedTransferAccountId = entityIdMap.get( + subtransaction.transfer_account_id, + ); + const transferPayeeId = mappedTransferAccountId + ? payeeTransferAcctHashMap.get(mappedTransferAccountId)?.id + : undefined; + if (transferPayeeId) { + actions.push({ + op: 'set', + field: 'payee', + value: transferPayeeId, + options: { splitIndex }, + }); + } + } else if (subtransaction.payee_id) { + const mappedPayeeId = entityIdMap.get(subtransaction.payee_id); + if (mappedPayeeId) { + actions.push({ + op: 'set', + field: 'payee', + value: mappedPayeeId, + options: { splitIndex }, + }); + } + } else if (parentPayeeId) { + actions.push({ + op: 'set', + field: 'payee', + value: parentPayeeId, + options: { splitIndex }, + }); + } + + if (!subtransaction.transfer_account_id && subtransaction.category_id) { + const mappedCategoryId = entityIdMap.get(subtransaction.category_id); + if (mappedCategoryId) { + actions.push({ + op: 'set', + field: 'category', + value: mappedCategoryId, + options: { splitIndex }, + }); + } + } + }); + + await actual.updateRule(buildRuleUpdate(rule, actions)); + } + } +} + async function importTransactions( - data: YNAB5.Budget, + data: Budget, entityIdMap: Map, ) { const payees = await actual.getPayees(); @@ -199,12 +562,10 @@ async function importTransactions( const payeesByTransferAcct = payees .filter(payee => payee?.transfer_acct) - .map(payee => [payee.transfer_acct, payee] as [string, YNAB5.Payee]); - const payeeTransferAcctHashMap = new Map( - payeesByTransferAcct, - ); - const orphanTransferMap = new Map(); - const orphanSubtransfer = [] as YNAB5.Subtransaction[]; + .map(payee => [payee.transfer_acct, payee] as [string, Payee]); + const payeeTransferAcctHashMap = new Map(payeesByTransferAcct); + const orphanTransferMap = new Map(); + const orphanSubtransfer = [] as Subtransaction[]; const orphanSubtransferTrxId = [] as string[]; const orphanSubtransferAcctIdByTrxIdMap = new Map(); const orphanSubtransferDateByTrxIdMap = new Map(); @@ -262,7 +623,7 @@ async function importTransactions( } return map; }, - new Map(), + new Map(), ); // The comparator will be used to order transfer transactions and their @@ -270,11 +631,11 @@ async function importTransactions( // for every list index in the transactions list, the related subtransaction // will be at the same index. const orphanTransferComparator = ( - a: YNAB5.Transaction | YNAB5.Subtransaction, - b: YNAB5.Transaction | YNAB5.Subtransaction, + a: TransactionSummary | Subtransaction, + b: TransactionSummary | Subtransaction, ) => { - // a and b can be a YNAB5.Transaction (having a date attribute) or a - // YNAB5.Subtransaction (missing that date attribute) + // a and b can be a TransactionSummary (having a date attribute) or a + // Subtransaction (missing that date attribute) const date_a = 'date' in a @@ -377,7 +738,7 @@ async function importTransactions( category: entityIdMap.get(transaction.category_id) || null, cleared: ['cleared', 'reconciled'].includes(transaction.cleared), reconciled: transaction.cleared === 'reconciled', - notes: transaction.memo || null, + notes: buildTransactionNotes(transaction), imported_id: transaction.import_id || null, transfer_id: entityIdMap.get(transaction.transfer_transaction_id) || @@ -402,10 +763,11 @@ async function importTransactions( }; // Handle transactions and subtransactions payee - const transactionPayeeUpdate = ( - trx: YNAB5.Transaction | YNAB5.Subtransaction, + function transactionPayeeUpdate( + trx: TransactionSummary | Subtransaction, newTrx, - ) => { + fallbackPayeeId?: string | null, + ) { if (trx.transfer_account_id) { const mappedTransferAccountId = entityIdMap.get( trx.transfer_account_id, @@ -413,13 +775,15 @@ async function importTransactions( newTrx.payee = payeeTransferAcctHashMap.get( mappedTransferAccountId, )?.id; - } else { + } else if (trx.payee_id) { newTrx.payee = entityIdMap.get(trx.payee_id); newTrx.imported_payee = data.payees.find( p => !p.deleted && p.id === trx.payee_id, )?.name; + } else if (fallbackPayeeId) { + newTrx.payee = fallbackPayeeId; } - }; + } transactionPayeeUpdate(transaction, newTransaction); if (newTransaction.subtransactions) { @@ -427,7 +791,11 @@ async function importTransactions( const newSubtransaction = newTransaction.subtransactions.find( newSubtrans => newSubtrans.id === entityIdMap.get(subtrans.id), ); - transactionPayeeUpdate(subtrans, newSubtransaction); + transactionPayeeUpdate( + subtrans, + newSubtransaction, + newTransaction.payee, + ); }); } @@ -450,10 +818,7 @@ async function importTransactions( ); } -async function importBudgets( - data: YNAB5.Budget, - entityIdMap: Map, -) { +async function importBudgets(data: Budget, entityIdMap: Map) { // There should be info in the docs to deal with // no credit card category and how YNAB and Actual // handle differently the amount To be Budgeted @@ -499,7 +864,7 @@ async function importBudgets( // Utils -export async function doImport(data: YNAB5.Budget) { +export async function doImport(data: Budget) { const entityIdMap = new Map(); logger.log('Importing Accounts...'); @@ -514,13 +879,16 @@ export async function doImport(data: YNAB5.Budget) { logger.log('Importing Transactions...'); await importTransactions(data, entityIdMap); + logger.log('Importing Scheduled Transactions...'); + await importScheduledTransactions(data, entityIdMap); + logger.log('Importing Budgets...'); await importBudgets(data, entityIdMap); logger.log('Setting up...'); } -export function parseFile(buffer: Buffer): YNAB5.Budget { +export function parseFile(buffer: Buffer): Budget { let data = JSON.parse(buffer.toString()); if (data.data) { data = data.data; @@ -532,7 +900,7 @@ export function parseFile(buffer: Buffer): YNAB5.Budget { return data; } -export function getBudgetName(_filepath: string, data: YNAB5.Budget) { +export function getBudgetName(_filepath: string, data: Budget) { return data.budget_name || data.name; } @@ -557,3 +925,32 @@ function findIdByName( ) { return findByNameIgnoreCase(categories, name)?.id; } + +type FlaggedMemoTransaction = { + memo?: string | null; + flag_name?: string | null; + flag_color?: string | null; +}; + +function buildTransactionNotes(transaction: FlaggedMemoTransaction) { + const normalizedMemo = transaction.memo?.trim() ?? ''; + const normalizedFlag = + transaction.flag_name?.trim() ?? transaction.flag_color?.trim() ?? ''; + const notes = `${normalizedMemo} ${ + normalizedFlag ? `#${normalizedFlag}` : '' + }`.trim(); + return notes.length > 0 ? notes : null; +} + +function buildRuleUpdate( + rule: RuleEntity, + actions: RuleEntity['actions'], +): RuleEntity { + return { + id: rule.id, + stage: rule.stage ?? null, + conditionsOp: rule.conditionsOp ?? 'and', + conditions: rule.conditions, + actions, + }; +} diff --git a/upcoming-release-notes/6844.md b/upcoming-release-notes/6844.md new file mode 100644 index 0000000000..21fdfc61eb --- /dev/null +++ b/upcoming-release-notes/6844.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [StephenBrown2] +--- + +Import nYNAB scheduled transactions into Actual schedules