diff --git a/scripts/revolut-import.ts b/scripts/revolut-import.ts index bbd9389..fbf2d89 100644 --- a/scripts/revolut-import.ts +++ b/scripts/revolut-import.ts @@ -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 diff --git a/src/lib/server/bank-sync.test.ts b/src/lib/server/bank-sync.test.ts index c733bd7..a5de85e 100644 --- a/src/lib/server/bank-sync.test.ts +++ b/src/lib/server/bank-sync.test.ts @@ -24,6 +24,24 @@ const tx = (overrides: Partial = {}): 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); diff --git a/src/lib/server/bank-sync.ts b/src/lib/server/bank-sync.ts index be476a2..b8ffbd5 100644 --- a/src/lib/server/bank-sync.ts +++ b/src/lib/server/bank-sync.ts @@ -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`; } }); diff --git a/src/lib/server/enable-banking.ts b/src/lib/server/enable-banking.ts index 69c9220..6d49480 100644 --- a/src/lib/server/enable-banking.ts +++ b/src/lib/server/enable-banking.ts @@ -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; diff --git a/src/lib/server/investment-income.test.ts b/src/lib/server/investment-income.test.ts new file mode 100644 index 0000000..14bba73 --- /dev/null +++ b/src/lib/server/investment-income.test.ts @@ -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(); + }); +}); diff --git a/src/lib/server/investment-income.ts b/src/lib/server/investment-income.ts new file mode 100644 index 0000000..1415528 --- /dev/null +++ b/src/lib/server/investment-income.ts @@ -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; +} diff --git a/src/lib/server/revolut-statement.test.ts b/src/lib/server/revolut-statement.test.ts index 3b9fc34..45108b7 100644 --- a/src/lib/server/revolut-statement.test.ts +++ b/src/lib/server/revolut-statement.test.ts @@ -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); diff --git a/src/lib/server/revolut-statement.ts b/src/lib/server/revolut-statement.ts index 702a90a..cc87c82 100644 --- a/src/lib/server/revolut-statement.ts +++ b/src/lib/server/revolut-statement.ts @@ -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) { + return investmentIncomeCategory({ + amountCents: row.amountCents, + kind: row.type, + texts: [row.description] + }); +}