Money tracker: Knab CSV import, multi-bank linking, sum reconcile, history range
Some checks failed
Deploy production / deploy (push) Failing after 23s
Some checks failed
Deploy production / deploy (push) Failing after 23s
- Knab statement exports: per-account ids (knab:<account>:<Referentie>), routed by IBAN, transfers between own accounts merged into one tracker account are dropped (interest kept) - Import: multiple files, hand-entered twins get the import id, hand entries equal to the sum of 2-3 payments within a day are replaced by them - bank-link --bank revolut|abn|knab with per-bank default account; ASPSP notes printed - bank-sync: longest history on an account first sync (--full to force), sum reconcile, per-session status so one bank failing does not mark the others - Timeline: 1 year / 3 years / All history range
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
// Link Revolut (personal + joint) to the money tracker through Enable Banking.
|
||||
// Link a bank to the money tracker through Enable Banking. Each bank gets its own session;
|
||||
// linking one never touches another bank's session.
|
||||
//
|
||||
// bun --env-file=$HOME/.config/money-sync.env scripts/bank-link.ts interactive consent
|
||||
// bun --env-file=$HOME/.config/money-sync.env scripts/bank-link.ts Revolut (personal + joint)
|
||||
// ... scripts/bank-link.ts --bank abn ABN AMRO -> "ABN Amro"
|
||||
// ... scripts/bank-link.ts --bank knab Knab -> "Knab"
|
||||
// ... scripts/bank-link.ts --check verify key + app id only
|
||||
// ... scripts/bank-link.ts --code '<redirect URL or code>' [--yes] finish a started consent
|
||||
// ... scripts/bank-link.ts --map <uid|IBAN>=<Tracker account> [--map ...] override the mapping
|
||||
// ... scripts/bank-link.ts --aspsp Revolut --country NL --user <auth user id>
|
||||
// ... scripts/bank-link.ts --aspsp '<ASPSP name>' --account <Tracker account> --country NL --user <id>
|
||||
//
|
||||
// Then sync within the hour: the first sync of an account asks for the longest history the
|
||||
// bank serves, and most banks only serve ~90 days once consent is older than an hour.
|
||||
//
|
||||
// Needs ENABLEBANKING_APP_ID, ENABLEBANKING_KEY_PATH, DB_URL (and optionally
|
||||
// ENABLEBANKING_REDIRECT_URL). Never prints the private key.
|
||||
@@ -33,8 +39,34 @@ args.forEach((arg, i) => {
|
||||
overrides[key.replace(/\s/g, '').toUpperCase()] = rest.join('=');
|
||||
});
|
||||
|
||||
// Enable Banking ASPSP names (GET /aspsps?country=NL) and the tracker account they feed.
|
||||
const BANKS: Record<string, { aspsp: string; account: string; hint: string }> = {
|
||||
revolut: {
|
||||
aspsp: 'Revolut',
|
||||
account: 'Revolut',
|
||||
hint: 'Tick BOTH your personal account and the joint account with Juhee.'
|
||||
},
|
||||
abn: {
|
||||
aspsp: 'ABN AMRO',
|
||||
account: 'ABN Amro',
|
||||
hint: 'Log in with the ABN AMRO app or identifier and approve your current account.'
|
||||
},
|
||||
knab: {
|
||||
aspsp: 'Knab',
|
||||
account: 'Knab',
|
||||
hint: 'Approve your Knab current account (savings accounts are not needed).'
|
||||
}
|
||||
};
|
||||
const bankKey = (value('--bank') ?? 'revolut').toLowerCase().replace(/[\s-]|amro/g, '');
|
||||
const preset = value('--aspsp') && !value('--bank') ? undefined : BANKS[bankKey];
|
||||
if (!preset && !value('--aspsp'))
|
||||
throw new Error(`Unknown --bank ${value('--bank')}; use ${Object.keys(BANKS).join(', ')}`);
|
||||
const client = createEnableBankingClient(configFromEnv());
|
||||
const aspsp = { name: value('--aspsp') ?? 'Revolut', country: value('--country') ?? 'NL' };
|
||||
const aspsp = {
|
||||
name: value('--aspsp') ?? preset!.aspsp,
|
||||
country: value('--country') ?? 'NL'
|
||||
};
|
||||
const defaultAccount = value('--account') ?? preset?.account ?? aspsp.name;
|
||||
const redirectUrl =
|
||||
process.env.ENABLEBANKING_REDIRECT_URL ??
|
||||
'https://stanrunge.dev/projects/money-tracker/bank-callback';
|
||||
@@ -47,7 +79,15 @@ const { aspsps } = await client.aspsps(aspsp.country);
|
||||
const bank = aspsps.find((a) => a.name.toLowerCase() === aspsp.name.toLowerCase());
|
||||
if (!bank) throw new Error(`${aspsp.name} (${aspsp.country}) is not available for this app`);
|
||||
console.log(
|
||||
`${bank.name} ${bank.country}: max consent ${Math.floor(bank.maximum_consent_validity / 86400)} days`
|
||||
`${bank.name} ${bank.country}${bank.bic ? ' ' + bank.bic : ''}: max consent ` +
|
||||
`${Math.floor(bank.maximum_consent_validity / 86400)} days${bank.beta ? ' (beta)' : ''}` +
|
||||
(bank.required_psu_headers?.length
|
||||
? `; requires PSU headers ${bank.required_psu_headers.join(', ')} (background syncs ` +
|
||||
'run as "PSU not present", which PSD2 limits to ~4 calls a day per account)'
|
||||
: '')
|
||||
);
|
||||
console.log(
|
||||
`Linked accounts go to tracker account "${defaultAccount}" unless --map says otherwise.`
|
||||
);
|
||||
if (flag('--check')) process.exit(0);
|
||||
|
||||
@@ -63,8 +103,8 @@ if (!pasted) {
|
||||
psuType: 'personal',
|
||||
state: randomUUID()
|
||||
});
|
||||
console.log('\n1. Open this link and approve access in the Revolut app.');
|
||||
console.log(' Tick BOTH your personal account and the joint account with Juhee:\n');
|
||||
console.log(`\n1. Open this link and approve access at ${bank.name}.`);
|
||||
console.log(` ${preset?.hint ?? 'Approve the account(s) you want to track.'}\n`);
|
||||
console.log(' ' + auth.url + '\n');
|
||||
console.log(`2. You'll land on ${redirectUrl}?code=...`);
|
||||
console.log(
|
||||
@@ -88,7 +128,10 @@ try {
|
||||
const tracker = await sql<
|
||||
{ id: number; name: string; scope: string; iban: string | null; external_uid: string | null }[]
|
||||
>`select id, name, scope, iban, external_uid from money.accounts where user_id = ${userId} order by id`;
|
||||
const plan = planAccountMapping(session.accounts, tracker, overrides);
|
||||
const plan = planAccountMapping(session.accounts, tracker, overrides, {
|
||||
defaultAccount,
|
||||
joint: bank.name.toLowerCase() === 'revolut'
|
||||
});
|
||||
for (const { bank: account, tracker: target, reason } of plan) {
|
||||
const iban = account.account_id?.iban ?? '(no IBAN)';
|
||||
console.log(
|
||||
@@ -103,8 +146,14 @@ try {
|
||||
process.exit(1);
|
||||
}
|
||||
await saveSession(sql, userId, session, plan);
|
||||
const linked = plan.filter((p) => p.tracker).map((p) => p.tracker!.name);
|
||||
const only = linked.length === 1 ? ` --account '${linked[0]}'` : '';
|
||||
console.log('Saved. Within the next hour (full history is only served right after consent):');
|
||||
console.log(
|
||||
'Saved. Next: bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts --reconcile --dry-run'
|
||||
` bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts --reconcile --dry-run${only}`
|
||||
);
|
||||
console.log(
|
||||
` bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts --reconcile${only}`
|
||||
);
|
||||
} finally {
|
||||
rl.close();
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
// run once after linking (with --dry-run first)
|
||||
// --from YYYY-MM-DD override the start date (default: last synced date - 7 days, or the
|
||||
// account's first transaction on the first run)
|
||||
// --full ask for the longest history the bank serves (automatic on an
|
||||
// account's first sync; only works within an hour of consent at most banks)
|
||||
// --account <name> only this tracker account
|
||||
//
|
||||
// Dedupe: unique (account_id, external_id); re-running is always safe.
|
||||
@@ -31,12 +33,15 @@ try {
|
||||
dryRun,
|
||||
reconcile: args.includes('--reconcile'),
|
||||
from,
|
||||
full: args.includes('--full'),
|
||||
accountName: value('--account')
|
||||
});
|
||||
for (const r of results) {
|
||||
console.log(
|
||||
`${r.account}: fetched ${r.fetched}, booked ${r.booked}, new ${r.inserted}, ` +
|
||||
`matched to existing ${r.matched}, already synced ${r.alreadyKnown}` +
|
||||
`matched to existing ${r.matched}, replaced sum entries ${r.absorbed}, ` +
|
||||
`already synced ${r.alreadyKnown}` +
|
||||
(r.historyFrom ? `, history from ${r.historyFrom}` : '') +
|
||||
(r.balance ? `, bank balance ${r.balance}` : '') +
|
||||
(r.error ? `\n ERROR ${r.error}` : '')
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user