money: tag interest/dividend payouts on bank sync and Revolut import (category interest|dividend, never overwrites a set category)
All checks were successful
Deploy production / deploy (push) Successful in 1m14s

This commit is contained in:
2026-10-26 07:48:47 +00:00
parent 0121071af2
commit d3b9bb9ba2
8 changed files with 171 additions and 5 deletions

View File

@@ -15,6 +15,8 @@
// category 'transfer' (e.g. Personal <-> Joint legs)
// --tag-matched-transfers with --transfer-pattern: also set category 'transfer' on the
// existing tracker rows those statement rows matched
// Interest/dividend payouts (src/lib/server/investment-income.ts) get category 'interest' /
// 'dividend' on insert; matched existing rows get it only when their category is empty.
// --apply write (needs a money-schema backup in ~/backups from the last 2 hours)
// --drop-start-balance with --apply: delete the account's "Start balance" row(s), only if the
// statement opens at €0 and, after the import, the only difference
@@ -38,6 +40,7 @@ import {
planImport,
rowsFromTable,
selectRows,
statementCategory,
statementExternalId,
type StatementRow,
type TrackerRow
@@ -293,12 +296,23 @@ try {
insert into money.transactions
(user_id, account_id, date, value_date, description, amount_cents, external_id, source, booking_status, category, raw)
values (${account.user_id}, ${account.id}, ${r.date}, ${r.startedDate}, ${r.description.slice(0, 500)},
${r.amountCents}, ${statementExternalId(r, hash)}, 'csv', 'BOOK', ${isTransfer(r) ? 'transfer' : null},
${r.amountCents}, ${statementExternalId(r, hash)}, 'csv', 'BOOK', ${isTransfer(r) ? 'transfer' : statementCategory(r)},
${q.json({ revolut: { type: r.type, product: r.product, started: r.startedDate, completed: r.completedAt, description: r.description, fee_cents: r.feeCents, currency: r.currency, balance_cents: r.balanceCents, file_line: r.line } })})
on conflict do nothing`;
inserted += res.count;
}
console.log(`Inserted ${inserted} rows into ${account.name}.`);
// Interest/dividend payouts that matched an existing row: tag it, unless it already
// has a category (hand-set categories are never overwritten).
let income = 0;
for (const d of plan.duplicates) {
const category = isTransfer(d.row) ? null : statementCategory(d.row);
if (!category) continue;
const res = await q`update money.transactions set category = ${category}
where id = ${d.match.id} and account_id = ${account.id} and category is null`;
income += res.count;
}
if (income) console.log(`Tagged ${income} existing interest/dividend rows.`);
if (transferRe && flag('--tag-matched-transfers')) {
const ids = plan.duplicates.filter((d) => isTransfer(d.row)).map((d) => d.match.id);
const tagged = ids.length

View File

@@ -24,6 +24,24 @@ const tx = (overrides: Partial<BankTransaction> = {}): BankTransaction => ({
});
describe('bank transaction mapping', () => {
it('tags interest and dividend payouts, nothing else', () => {
const credit = (remittance: string[], code?: string) =>
mapBankTransaction(
tx({
credit_debit_indicator: 'CRDT',
creditor: undefined,
debtor: { name: 'Revolut' },
remittance_information: remittance,
bank_transaction_code: code ? { code } : undefined
})
)!.category;
expect(credit(['Savings vault interest'])).toBe('interest');
expect(credit(['Interest PAID EUR Class R IE000AZVL3K0'])).toBe('interest');
expect(credit(['Revolut'], 'INTEREST')).toBe('interest');
expect(credit(['Dividend Payment: VWRL'])).toBe('dividend');
expect(credit(['From JUHEUI KIM & STAN RUNGE'], 'TRANSFER')).toBeNull();
expect(mapBankTransaction(tx({ remittance_information: ['Interest'] }))!.category).toBeNull();
});
it('parses decimal amounts exactly', () => {
expect(amountToCents('12.34')).toBe(1234);
expect(amountToCents('-0.5')).toBe(-50);

View File

@@ -11,6 +11,7 @@ import type {
SessionResponse
} from './enable-banking';
import { EnableBankingError } from './enable-banking';
import { investmentIncomeCategory } from './investment-income';
import { findSumMatch } from './reconcile';
export type MappedTransaction = {
@@ -21,6 +22,8 @@ export type MappedTransaction = {
counterparty: string | null;
amountCents: number;
bookingStatus: string;
/** 'interest' / 'dividend' for investment income payouts, else null. */
category: string | null;
raw: BankTransaction;
};
@@ -79,6 +82,17 @@ export function mapBankTransaction(tx: BankTransaction): MappedTransaction | nul
counterparty,
amountCents,
bookingStatus: tx.status ?? 'BOOK',
category: investmentIncomeCategory({
amountCents,
kind: tx.bank_transaction_code?.code,
texts: [
description,
...remittance,
tx.bank_transaction_code?.description,
tx.bank_transaction_code?.sub_code,
tx.note
]
}),
raw: tx
};
}
@@ -435,10 +449,11 @@ export async function syncBankAccounts(
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.
// Keep Stan's own description/category (only fill an empty one); attach the bank identity.
await q`
update money.transactions set external_id = ${tx.externalId},
counterparty = coalesce(counterparty, ${tx.counterparty}),
category = coalesce(category, ${tx.category}),
booking_status = ${tx.bookingStatus}, value_date = ${tx.valueDate},
raw = ${q.json(tx.raw as never)}
where id = ${id} and external_id is null`;
@@ -447,10 +462,10 @@ export async function syncBankAccounts(
await q`
insert into money.transactions
(user_id, account_id, date, description, amount_cents, external_id, source,
booking_status, counterparty, raw, value_date)
booking_status, counterparty, category, raw, value_date)
values (${account.user_id}, ${account.id}, ${tx.date}, ${tx.description},
${tx.amountCents}, ${tx.externalId}, 'enablebanking', ${tx.bookingStatus},
${tx.counterparty}, ${q.json(tx.raw as never)}, ${tx.valueDate})
${tx.counterparty}, ${tx.category}, ${q.json(tx.raw as never)}, ${tx.valueDate})
on conflict (account_id, external_id) do nothing`;
}
});

View File

@@ -48,7 +48,11 @@ export type BankTransaction = {
creditor_account?: { iban?: string | null } | null;
debtor_account?: { iban?: string | null } | null;
remittance_information?: string[] | null;
bank_transaction_code?: { description?: string | null } | null;
bank_transaction_code?: {
code?: string | null;
sub_code?: string | null;
description?: string | null;
} | null;
merchant_category_code?: string | null;
note?: string | null;
[key: string]: unknown;

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { investmentIncomeCategory as cat } from './investment-income';
const pos = (texts: string[], kind?: string) => cat({ amountCents: 123, kind, texts });
describe('investment income tagging', () => {
it('tags interest payouts from real and Revolut wording', () => {
for (const text of [
'Interest',
'Knab: Rente',
'Interest PAID EUR Class R IE000AZVL3K0',
'Savings Interest',
'Savings vault interest',
'Gross interest',
'Net interest',
'Interest earned',
'Rentebijschrijving'
])
expect(pos([text]), text).toBe('interest');
expect(pos(['Revolut'], 'Interest')).toBe('interest');
expect(pos(['Revolut', 'Interest earned on your Savings'], 'TRANSFER')).toBe('interest');
});
it('tags dividend payouts', () => {
expect(pos(['Dividend Payment: GOOGL'])).toBe('dividend');
expect(pos(['VWRL dividend'])).toBe('dividend');
expect(pos(['Dividenduitkering'])).toBe('dividend');
expect(pos(['Broker'], 'DIVIDEND')).toBe('dividend');
});
it('ignores costs, reinvestments, card rows and look-alikes', () => {
expect(cat({ amountCents: -1043, texts: ['Interest Reinvested Class R EUR'] })).toBeNull();
expect(cat({ amountCents: -2, texts: ['Service Fee Charged EUR Class'] })).toBeNull();
expect(cat({ amountCents: -500, texts: ['Dividend tax'] })).toBeNull();
expect(cat({ amountCents: 0, texts: ['Interest'] })).toBeNull();
expect(pos(['Interest Cafe'], 'Card Refund')).toBeNull();
expect(pos(['Interest Cafe Utrecht'])).toBeNull();
expect(pos(['From Juhee'])).toBeNull();
expect(pos(['Tikkie: Rente lening terug'])).toBeNull();
expect(pos(['Rentenaar BV'])).toBeNull();
expect(pos(['Salaris Cargill'])).toBeNull();
});
});

View File

@@ -0,0 +1,49 @@
// Tags investment income on import so the €10k/month goal bar (goal-metrics/core.ts,
// INVESTMENT_INCOME_CATEGORIES) can find it: positive interest payouts -> 'interest',
// positive dividend payouts -> 'dividend'. Pure, shared by the bank sync and statement imports.
export type IncomeCategory = 'interest' | 'dividend';
/** Revolut "Type" / bank transaction codes that are never income payouts. */
const NOT_INCOME_KIND = /^(card[ _-]?(payment|refund)|exchange|atm|fee|charge)$/i;
const DIVIDEND = /\bdividend(?:s|en|uitkering)?\b/i;
/**
* Interest wording seen in real rows and Revolut exports:
* "Interest" (ABN), "Knab: Rente", Revolut "Interest PAID EUR Class R IE000…",
* "Savings Interest", "Savings vault interest", "Gross/Net interest", "Rentebijschrijving".
* Payout-only: "Interest Reinvested" / "Service Fee Charged" are negative and so never match.
*/
const INTEREST = [
// Whole description is (a bank prefix plus) the word interest/rente, optionally qualified.
/^(?:[\p{L}0-9 .&'-]{1,30}:\s*)?(?:(?:gross|net|savings|vault|credit)\s+)?(?:interest|rente|creditrente)(?:\s+(?:paid|earned|payout|payment|credited|bijschrijving|uitbetaling)\b.*)?$/iu,
/^interest\s+paid\b/i,
/\b(?:savings|vault|pocket|spaar)\s*(?:account\s+)?interest\b/i,
/\binterest\s+(?:earned|payout|credited)\b/i,
/\brente(?:bijschrijving|uitkering|vergoeding)\b/i
];
export type IncomeInput = {
amountCents: number;
/** Revolut statement "Type" or the bank's transaction code. */
kind?: string | null;
/** Description, remittance lines, bank code description, note… */
texts: (string | null | undefined)[];
};
export function investmentIncomeCategory({
amountCents,
kind,
texts
}: IncomeInput): IncomeCategory | null {
if (!(amountCents > 0)) return null;
const k = kind?.trim() ?? '';
if (k && NOT_INCOME_KIND.test(k)) return null;
if (DIVIDEND.test(k)) return 'dividend';
if (/\binterest\b/i.test(k)) return 'interest';
const lines = texts.map((t) => t?.replace(/\s+/g, ' ').trim()).filter((t): t is string => !!t);
if (lines.some((t) => DIVIDEND.test(t))) return 'dividend';
if (lines.some((t) => INTEREST.some((re) => re.test(t)))) return 'interest';
return null;
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
statementCategory,
checkBalances,
parseAmount,
parseRevolutCsv,
@@ -21,6 +22,19 @@ Card Payment,Current,2025-08-05 10:00:00,2025-08-05 11:00:00,Kiosk,-3.50,0.00,EU
`;
describe('revolut statement', () => {
it('tags interest and dividend rows by type or description', () => {
const row = (type: string, description: string, amountCents = 512) => ({
type,
description,
amountCents
});
expect(statementCategory(row('Interest', 'Savings vault interest'))).toBe('interest');
expect(statementCategory(row('', 'Interest PAID EUR Class R IE000AZVL3K0'))).toBe('interest');
expect(statementCategory(row('Dividend', 'VWRL'))).toBe('dividend');
expect(statementCategory(row('Transfer', 'To EUR Savings', -10000))).toBeNull();
expect(statementCategory(row('Card Payment', 'Interest Cafe'))).toBeNull();
expect(statementCategory(row('Transfer', 'From Juhee'))).toBeNull();
});
it('parses amounts in both notations', () => {
expect(parseAmount('1,234.56')).toBe(123456);
expect(parseAmount('-1.234,56')).toBe(-123456);

View File

@@ -1,3 +1,5 @@
import { investmentIncomeCategory } from './investment-income';
// Revolut account statement parsing + import planning (pure; DB work is in scripts/revolut-import.ts).
// Handles the app's CSV export (English or Dutch headers, , or ; separated), the Excel export
// (rows passed in as a string table) and, defensively, the text of the PDF statement.
@@ -341,3 +343,12 @@ export function statementExternalId(row: StatementRow, hash: (s: string) => stri
).slice(0, 24)
);
}
/** 'interest' / 'dividend' for a statement row that pays investment income, else null. */
export function statementCategory(row: Pick<StatementRow, 'amountCents' | 'type' | 'description'>) {
return investmentIncomeCategory({
amountCents: row.amountCents,
kind: row.type,
texts: [row.description]
});
}