Include scheduled transactions in nYNAB imports (#6844)

* Include scheduled transactions in nYNAB imports

* Remove logs and restore schedule name from transaction memo

* Simplify rule actions

* Create schedules with unique names

* Set the note rather than append

* Update ynab5 demo budget and e2e test
This commit is contained in:
Stephen Brown II
2026-02-04 15:40:40 -06:00
committed by GitHub
parent dc5ce6ae96
commit 323c2beb0a
5 changed files with 969 additions and 58 deletions

View File

@@ -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
}

View File

@@ -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 () => {

View File

@@ -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<string, number> | 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[];
};

View File

@@ -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<string, string>) {
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<string, string>) {
return Promise.all(
data.accounts.map(async account => {
if (!account.deleted) {
@@ -35,7 +164,7 @@ function importAccounts(data: YNAB5.Budget, entityIdMap: Map<string, string>) {
}
async function importCategories(
data: YNAB5.Budget,
data: Budget,
entityIdMap: Map<string, string>,
) {
// Hidden categories are put in its own group by YNAB,
@@ -167,7 +296,7 @@ async function importCategories(
}
}
function importPayees(data: YNAB5.Budget, entityIdMap: Map<string, string>) {
function importPayees(data: Budget, entityIdMap: Map<string, string>) {
return Promise.all(
data.payees.map(async payee => {
if (!payee.deleted) {
@@ -180,8 +309,242 @@ function importPayees(data: YNAB5.Budget, entityIdMap: Map<string, string>) {
);
}
async function importScheduledTransactions(
data: Budget,
entityIdMap: Map<string, string>,
) {
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<string, Payee>(payeesByTransferAcct);
const scheduleCategoryMap = new Map<string, string>();
const scheduleSplitsMap = new Map<string, ScheduledSubtransaction[]>();
const schedulePayeeMap = new Map<string, string>();
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<RuleEntity | null> {
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<Record<string, unknown>> };
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<string, string>,
) {
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<string, YNAB5.Payee>(
payeesByTransferAcct,
);
const orphanTransferMap = new Map<string, YNAB5.Transaction[]>();
const orphanSubtransfer = [] as YNAB5.Subtransaction[];
.map(payee => [payee.transfer_acct, payee] as [string, Payee]);
const payeeTransferAcctHashMap = new Map<string, Payee>(payeesByTransferAcct);
const orphanTransferMap = new Map<string, TransactionSummary[]>();
const orphanSubtransfer = [] as Subtransaction[];
const orphanSubtransferTrxId = [] as string[];
const orphanSubtransferAcctIdByTrxIdMap = new Map<string, string>();
const orphanSubtransferDateByTrxIdMap = new Map<string, string>();
@@ -262,7 +623,7 @@ async function importTransactions(
}
return map;
},
new Map<string, YNAB5.Subtransaction[]>(),
new Map<string, Subtransaction[]>(),
);
// 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<string, string>,
) {
async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
// 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<string, string>();
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<T extends { id: string; name: string }>(
) {
return findByNameIgnoreCase<T>(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,
};
}

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [StephenBrown2]
---
Import nYNAB scheduled transactions into Actual schedules