mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 03:32:54 -05:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
6
upcoming-release-notes/6844.md
Normal file
6
upcoming-release-notes/6844.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [StephenBrown2]
|
||||
---
|
||||
|
||||
Import nYNAB scheduled transactions into Actual schedules
|
||||
Reference in New Issue
Block a user