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
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
41
src/lib/server/investment-income.test.ts
Normal file
41
src/lib/server/investment-income.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
49
src/lib/server/investment-income.ts
Normal file
49
src/lib/server/investment-income.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user