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:
@@ -11,6 +11,7 @@ import type {
|
||||
SessionResponse
|
||||
} from './enable-banking';
|
||||
import { EnableBankingError } from './enable-banking';
|
||||
import { findSumMatch } from './reconcile';
|
||||
|
||||
export type MappedTransaction = {
|
||||
externalId: string;
|
||||
@@ -86,6 +87,9 @@ function dayDiff(a: string, b: string) {
|
||||
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
|
||||
}
|
||||
|
||||
export const isOpeningBalance = (description: string) =>
|
||||
/^(start|opening) balance/i.test(description.trim());
|
||||
|
||||
export type ExistingRow = { id: number; date: string; amountCents: number; description: string };
|
||||
|
||||
/**
|
||||
@@ -143,13 +147,22 @@ type TrackerAccount = {
|
||||
/**
|
||||
* Decides which tracker account each bank account feeds. Order: explicit override
|
||||
* (uid/IBAN -> tracker account name), same IBAN, joint heuristic -> the joint-scoped
|
||||
* account, first remaining EUR current account -> "Revolut". Others are skipped.
|
||||
* account (Revolut only), first remaining EUR current account -> `defaultAccount`
|
||||
* ("Revolut" unless given). Others are skipped.
|
||||
*/
|
||||
export function planAccountMapping(
|
||||
bankAccounts: BankAccount[],
|
||||
tracker: TrackerAccount[],
|
||||
overrides: Record<string, string> = {}
|
||||
overrides: Record<string, string> = {},
|
||||
options: {
|
||||
/** Tracker account for the first personal EUR current account (default "Revolut"). */
|
||||
defaultAccount?: string;
|
||||
/** Route joint-looking accounts to the joint-scoped tracker account (Revolut only). */
|
||||
joint?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const defaultAccount = (options.defaultAccount ?? 'Revolut').toLowerCase();
|
||||
const joint = options.joint ?? defaultAccount === 'revolut';
|
||||
const byName = new Map(tracker.map((a) => [a.name.toLowerCase(), a]));
|
||||
const used = new Set<number>();
|
||||
const plan: { bank: BankAccount; tracker: TrackerAccount | null; reason: string }[] = [];
|
||||
@@ -188,6 +201,7 @@ export function planAccountMapping(
|
||||
const rest = pending.filter(
|
||||
(bank) =>
|
||||
!(
|
||||
joint &&
|
||||
eur(bank) &&
|
||||
looksJoint(bank) &&
|
||||
take(
|
||||
@@ -201,7 +215,7 @@ export function planAccountMapping(
|
||||
if (
|
||||
eur(bank) &&
|
||||
bank.cash_account_type !== 'SVGS' &&
|
||||
take(bank, byName.get('revolut'), 'personal EUR account')
|
||||
take(bank, byName.get(defaultAccount), 'personal EUR account')
|
||||
)
|
||||
continue;
|
||||
plan.push({ bank, tracker: null, reason: 'no match (use --map uid=Account)' });
|
||||
@@ -274,6 +288,8 @@ export type SyncOptions = {
|
||||
/** Match against the whole history of hand-entered rows (one-time), not just the window. */
|
||||
reconcile?: boolean;
|
||||
from?: string;
|
||||
/** Fetch the longest history the bank serves (automatic on an account's first sync). */
|
||||
full?: boolean;
|
||||
accountName?: string;
|
||||
log?: (line: string) => void;
|
||||
today?: string;
|
||||
@@ -285,7 +301,11 @@ export type AccountSyncResult = {
|
||||
booked: number;
|
||||
inserted: number;
|
||||
matched: number;
|
||||
/** Hand-entered rows replaced because they equal the sum of 2-3 bank payments. */
|
||||
absorbed: number;
|
||||
alreadyKnown: number;
|
||||
/** Oldest booked date the bank returned. */
|
||||
historyFrom?: string;
|
||||
balance?: string;
|
||||
/** Bank balance minus the tracker's own rows, absorbed by the adjustment row. */
|
||||
adjustmentCents?: number;
|
||||
@@ -318,6 +338,7 @@ export async function syncBankAccounts(
|
||||
booked: 0,
|
||||
inserted: 0,
|
||||
matched: 0,
|
||||
absorbed: 0,
|
||||
alreadyKnown: 0
|
||||
};
|
||||
results.push(result);
|
||||
@@ -325,22 +346,31 @@ export async function syncBankAccounts(
|
||||
const [{ last }] = await sql<{ last: string | null }[]>`
|
||||
select max(date)::text as last from money.transactions
|
||||
where account_id = ${account.id} and source = 'enablebanking'`;
|
||||
const [{ first }] = await sql<{ first: string | null }[]>`
|
||||
select min(date)::text as first from money.transactions where account_id = ${account.id}`;
|
||||
const dateFrom = options.from ?? (last ? addDays(last, -7) : (first ?? addDays(today, -89)));
|
||||
// First sync of an account (or --full): ask for the longest history the bank serves.
|
||||
const longest = options.full || (!last && !options.from);
|
||||
const dateFrom = options.from ?? (last ? addDays(last, -7) : undefined);
|
||||
let raw: BankTransaction[];
|
||||
try {
|
||||
raw = await client.transactions(account.external_uid, { dateFrom });
|
||||
raw = await client.transactions(
|
||||
account.external_uid,
|
||||
longest ? { strategy: 'longest', dateFrom: options.from } : { dateFrom }
|
||||
);
|
||||
} catch (error) {
|
||||
// Many banks only serve ~90 days of history after the first hour of consent.
|
||||
if (error instanceof EnableBankingError && error.code === 'WRONG_TRANSACTIONS_PERIOD') {
|
||||
log(`${account.name}: ${dateFrom} is too far back; retrying from 89 days ago`);
|
||||
log(
|
||||
`${account.name}: ${dateFrom ?? 'full history'} is not available; retrying from 89 days ago`
|
||||
);
|
||||
raw = await client.transactions(account.external_uid, { dateFrom: addDays(today, -89) });
|
||||
} else throw error;
|
||||
}
|
||||
result.fetched = raw.length;
|
||||
const mapped = raw.map(mapBankTransaction).filter((t): t is MappedTransaction => t !== null);
|
||||
result.booked = mapped.length;
|
||||
result.historyFrom = mapped.reduce<string | undefined>(
|
||||
(min, t) => (!min || t.date < min ? t.date : min),
|
||||
undefined
|
||||
);
|
||||
if (!mapped.length) {
|
||||
await syncBalance(sql, client, account, result, options, today, log);
|
||||
continue;
|
||||
@@ -355,11 +385,14 @@ export async function syncBankAccounts(
|
||||
).map((r) => r.external_id)
|
||||
);
|
||||
const minDate = mapped.reduce((m, t) => (t.date < m ? t.date : m), mapped[0].date);
|
||||
const candidates = await sql<ExistingRow[]>`
|
||||
select id, date::text as date, amount_cents as "amountCents", description
|
||||
from money.transactions
|
||||
where account_id = ${account.id} and external_id is null
|
||||
and date >= ${options.reconcile ? '0001-01-01' : addDays(minDate, -1)}`;
|
||||
// Opening-balance rows are not payments; never match or replace them.
|
||||
const candidates = (
|
||||
await sql<ExistingRow[]>`
|
||||
select id, date::text as date, amount_cents as "amountCents", description
|
||||
from money.transactions
|
||||
where account_id = ${account.id} and external_id is null
|
||||
and date >= ${options.reconcile ? '0001-01-01' : addDays(minDate, -1)}`
|
||||
).filter((row) => !isOpeningBalance(row.description));
|
||||
const used = new Set<number>();
|
||||
const inserts: MappedTransaction[] = [];
|
||||
const updates: { id: number; tx: MappedTransaction }[] = [];
|
||||
@@ -376,10 +409,31 @@ export async function syncBankAccounts(
|
||||
updates.push({ id: match.id, tx });
|
||||
} else inserts.push(tx);
|
||||
}
|
||||
// A hand entry can stand for several bank payments ("To Joint 209" = 200 + 3 + 6):
|
||||
// replace it with the real payments instead of keeping both.
|
||||
const absorbed: { row: ExistingRow; parts: MappedTransaction[] }[] = [];
|
||||
const takenInserts = new Set<number>();
|
||||
for (const row of candidates) {
|
||||
if (used.has(row.id)) continue;
|
||||
const parts = findSumMatch(row, inserts, takenInserts);
|
||||
if (!parts) continue;
|
||||
parts.forEach((index) => takenInserts.add(index));
|
||||
used.add(row.id);
|
||||
absorbed.push({ row, parts: parts.map((index) => inserts[index]) });
|
||||
}
|
||||
for (const { row, parts } of absorbed)
|
||||
log(
|
||||
`${account.name}: hand entry #${row.id} ${row.date} "${row.description}" ${row.amountCents} = ` +
|
||||
parts.map((p) => `${p.date} ${p.description} ${p.amountCents}`).join(' + ') +
|
||||
(options.dryRun ? ' (would replace)' : ' (replaced)')
|
||||
);
|
||||
result.matched = updates.length;
|
||||
result.absorbed = absorbed.length;
|
||||
result.inserted = inserts.length;
|
||||
if (!options.dryRun) {
|
||||
await sql.begin(async (q) => {
|
||||
for (const { row } of absorbed)
|
||||
await q`delete from money.transactions where id = ${row.id} and external_id is null`;
|
||||
for (const { id, tx } of updates) {
|
||||
// Keep Stan's own description/category; attach the bank identity.
|
||||
await q`
|
||||
@@ -407,14 +461,22 @@ export async function syncBankAccounts(
|
||||
}
|
||||
}
|
||||
if (!options.dryRun) {
|
||||
const failed = results.filter((r) => r.error).map((r) => `${r.account}: ${r.error}`);
|
||||
const expired = failed.some((f) => /EXPIRED_SESSION|REVOKED_SESSION|CLOSED_SESSION/.test(f));
|
||||
await sql`
|
||||
update money.bank_sessions set last_synced_at = now(),
|
||||
last_error = ${failed.length ? failed.join('\n').slice(0, 2000) : null},
|
||||
status = case when ${expired} then 'expired'
|
||||
when valid_until is not null and valid_until < now() then 'expired' else status end
|
||||
where status = 'authorized'`;
|
||||
// Each bank has its own session; only touch the sessions whose accounts were synced.
|
||||
const sessions = await sql<{ id: number; accounts: { uid: string | null }[] }[]>`
|
||||
select id, accounts from money.bank_sessions where status = 'authorized'`;
|
||||
for (const session of sessions) {
|
||||
const uids = new Set(session.accounts.map((a) => a.uid).filter(Boolean));
|
||||
const mine = results.filter((_, index) => uids.has(selected[index].external_uid));
|
||||
if (!mine.length) continue;
|
||||
const failed = mine.filter((r) => r.error).map((r) => `${r.account}: ${r.error}`);
|
||||
const expired = failed.some((f) => /EXPIRED_SESSION|REVOKED_SESSION|CLOSED_SESSION/.test(f));
|
||||
await sql`
|
||||
update money.bank_sessions set last_synced_at = now(),
|
||||
last_error = ${failed.length ? failed.join('\n').slice(0, 2000) : null},
|
||||
status = case when ${expired} then 'expired'
|
||||
when valid_until is not null and valid_until < now() then 'expired' else status end
|
||||
where id = ${session.id}`;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user