Compare commits

..

11 Commits

18 changed files with 397 additions and 26 deletions

View File

@@ -1,5 +1,53 @@
# Changelog # Changelog
## [Backend 0.9.2] - 2026-08-20
### Added
- Extended import integration coverage to verify source positions for same-date operations.
## [Backend 0.9.1] - 2026-08-20
### Added
- Added runnable coverage for date and amount sorting tie-breakers.
## [Backend 0.9.0] - 2026-08-20
### Added
- Preserved source-array transaction positions and used them as stable tie-breakers for same-date history sorting.
## [Backend 0.8.3] - 2026-08-20
### Fixed
- Invalid non-string trade identifiers now return validation errors instead of server errors.
## [Backend 0.8.2] - 2026-08-20
### Added
- Added a database integration test for repeated portfolio imports and trade deduplication.
## [Backend 0.8.1] - 2026-08-20
### Fixed
- Hardened portfolio import validation, preserved decimal precision, added fallback trade keys, and covered validation with a runnable test.
## [Backend 0.8.0 / Shared 0.4.0] - 2026-08-20
### Added
- Added portfolio JSON import for broker reports with idempotent trades, position snapshots, and separate portfolio tables.
## [Backend 0.7.5 / Shared 0.3.1] - 2026-08-20
### Added
- Creating a category rule now applies it atomically to all matching unconfirmed transactions and returns the affected count.
## [Backend 0.7.4] - 2026-08-20 ## [Backend 0.7.4] - 2026-08-20
### Fixed ### Fixed

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/backend", "name": "@family-budget/backend",
"version": "0.7.4", "version": "0.9.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",
@@ -9,6 +9,9 @@
"migrate": "tsx src/db/migrate.ts", "migrate": "tsx src/db/migrate.ts",
"migrate:prod": "node dist/db/migrate.js", "migrate:prod": "node dist/db/migrate.js",
"test:analytics": "tsx src/services/analyticsSemantics.test.ts", "test:analytics": "tsx src/services/analyticsSemantics.test.ts",
"test:portfolio": "tsx src/services/portfolio.test.ts",
"test:portfolio:db": "NODE_ENV=test tsx src/services/portfolio.integration.test.ts",
"test:transactions": "tsx src/services/transactions.test.ts",
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts", "test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
"test:import:db": "NODE_ENV=test tsx src/services/import.integration.test.ts", "test:import:db": "NODE_ENV=test tsx src/services/import.integration.test.ts",
"test:llm": "tsx src/scripts/testLlm.ts" "test:llm": "tsx src/scripts/testLlm.ts"

View File

@@ -19,6 +19,7 @@ import accountsRouter from './routes/accounts';
import categoriesRouter from './routes/categories'; import categoriesRouter from './routes/categories';
import categoryRulesRouter from './routes/categoryRules'; import categoryRulesRouter from './routes/categoryRules';
import analyticsRouter from './routes/analytics'; import analyticsRouter from './routes/analytics';
import portfolioRouter from './routes/portfolio';
const app = express(); const app = express();
app.set('trust proxy', 1); app.set('trust proxy', 1);
@@ -43,6 +44,7 @@ app.use('/api/accounts', accountsRouter);
app.use('/api/categories', categoriesRouter); app.use('/api/categories', categoriesRouter);
app.use('/api/category-rules', categoryRulesRouter); app.use('/api/category-rules', categoryRulesRouter);
app.use('/api/analytics', analyticsRouter); app.use('/api/analytics', analyticsRouter);
app.use('/api/import/portfolio', portfolioRouter);
app.use( app.use(
( (

View File

@@ -238,6 +238,64 @@ const migrations: { name: string; sql: string }[] = [
CHECK (status IN ('active', 'closed')); CHECK (status IN ('active', 'closed'));
`, `,
}, },
{
name: '008_portfolio_tables',
sql: `
CREATE TABLE IF NOT EXISTS portfolio_reports (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id),
source_hash TEXT NOT NULL,
report_period_from DATE NOT NULL,
report_period_to DATE NOT NULL,
reported_at TIMESTAMPTZ,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (account_id, source_hash)
);
CREATE TABLE IF NOT EXISTS portfolio_positions (
id BIGSERIAL PRIMARY KEY,
report_id BIGINT NOT NULL REFERENCES portfolio_reports(id) ON DELETE CASCADE,
instrument TEXT NOT NULL,
isin TEXT,
quantity NUMERIC NOT NULL,
price NUMERIC,
valuation NUMERIC
);
CREATE TABLE IF NOT EXISTS portfolio_trades (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id),
source_id TEXT NOT NULL,
operation_id UUID NOT NULL,
instrument TEXT NOT NULL,
isin TEXT,
concluded_at TIMESTAMPTZ NOT NULL,
side TEXT NOT NULL,
quantity NUMERIC NOT NULL,
price_currency TEXT,
price NUMERIC,
settlement_currency TEXT,
settlement_amount NUMERIC,
nkd NUMERIC,
settlement_commission NUMERIC,
trade_commission NUMERIC,
order_id TEXT,
trade_id TEXT,
venue TEXT,
comment TEXT,
UNIQUE (account_id, source_id)
);
CREATE INDEX IF NOT EXISTS ix_portfolio_trades_account_date
ON portfolio_trades(account_id, concluded_at DESC);
`,
},
{
name: '009_transaction_source_position',
sql: `
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS source_position BIGINT NOT NULL DEFAULT 0;
UPDATE transactions SET source_position = id WHERE source_position = 0;
CREATE INDEX IF NOT EXISTS ix_transactions_date_position
ON transactions(operation_at, source_position, id);
`,
},
]; ];
export async function runMigrations(): Promise<void> { export async function runMigrations(): Promise<void> {

View File

@@ -0,0 +1,22 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import * as portfolioService from '../services/portfolio';
const router = Router();
router.post(
'/',
asyncHandler(async (req, res) => {
try {
res.status(201).json(await portfolioService.importPortfolio(req.body));
} catch (error) {
if (error instanceof Error && /Invalid|Duplicate|must be|required|numeric/.test(error.message)) {
res.status(422).json({ error: 'VALIDATION_ERROR', message: error.message });
return;
}
throw error;
}
}),
);
export default router;

View File

@@ -55,22 +55,38 @@ export async function getRules(params: GetCategoryRulesParams): Promise<Category
export async function createRule( export async function createRule(
body: CreateCategoryRuleRequest, body: CreateCategoryRuleRequest,
): Promise<CategoryRule> { ): Promise<CategoryRule & { applied: number }> {
const pattern = body.pattern.trim(); const pattern = body.pattern.trim();
const matchType = body.matchType ?? 'contains'; const matchType = body.matchType ?? 'contains';
const priority = body.priority ?? 100; const priority = body.priority ?? 100;
const requiresConfirmation = body.requiresConfirmation ?? false; const requiresConfirmation = body.requiresConfirmation ?? false;
const { rows } = await pool.query( const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation) `INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5) RETURNING *`,
RETURNING *`,
[pattern, matchType, body.categoryId, priority, requiresConfirmation], [pattern, matchType, body.categoryId, priority, requiresConfirmation],
); );
const match = matchType === 'starts_with'
const catResult = await pool.query('SELECT name FROM categories WHERE id = $1', [body.categoryId]); ? `t.description ILIKE $1 || '%'`
: `t.description ILIKE '%' || $1 || '%'`;
const applied = await client.query(
`UPDATE transactions t SET category_id = $2, is_category_confirmed = $3, updated_at = NOW()
WHERE (t.category_id IS NULL OR t.is_category_confirmed = FALSE) AND ${match}`,
[pattern, body.categoryId, !requiresConfirmation],
);
const catResult = await client.query('SELECT name FROM categories WHERE id = $1', [body.categoryId]);
rows[0].category_name = catResult.rows[0].name; rows[0].category_name = catResult.rows[0].name;
return toRule(rows[0]); await client.query('COMMIT');
return { ...toRule(rows[0]), applied: applied.rowCount ?? 0 };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} }
export async function updateRule( export async function updateRule(

View File

@@ -2,18 +2,20 @@ import assert from 'node:assert/strict';
import { pool } from '../db/pool'; import { pool } from '../db/pool';
import { importStatement } from './import'; import { importStatement } from './import';
const makeStatement = (sourceId: string) => ({ const makeStatement = (sourceIds: string[]) => ({
schemaVersion: '1.0', bank: 'TEST', schemaVersion: '1.0', bank: 'TEST',
statement: { accountNumber: 'metadata-test', currency: 'RUB', openingBalance: 0, closingBalance: 100, exportedAt: '2026-08-20T12:00:00+03:00' }, statement: { accountNumber: 'metadata-test', currency: 'RUB', openingBalance: 0, closingBalance: 100, exportedAt: '2026-08-20T12:00:00+03:00' },
transactions: [{ operationAt: '2026-08-20T10:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение', sourceId }], transactions: sourceIds.map((sourceId) => ({ operationAt: '2026-08-20T10:00:00+03:00', amountSigned: 100, commission: 0, description: 'Пополнение', sourceId })),
}); });
async function run(): Promise<void> { async function run(): Promise<void> {
try { try {
await importStatement(makeStatement('first')); await importStatement(makeStatement(['first', 'first-second']));
const positions = await pool.query("SELECT source_position FROM transactions t JOIN accounts a ON a.id = t.account_id WHERE a.bank = 'TEST' AND a.account_number = 'metadata-test' ORDER BY source_position");
assert.deepEqual(positions.rows.map((row) => Number(row.source_position)), [0, 1]);
const account = await pool.query("UPDATE accounts SET account_type = 'savings', status = 'closed' WHERE bank = 'TEST' AND account_number = 'metadata-test' RETURNING id"); const account = await pool.query("UPDATE accounts SET account_type = 'savings', status = 'closed' WHERE bank = 'TEST' AND account_number = 'metadata-test' RETURNING id");
assert.equal(account.rows.length, 1); assert.equal(account.rows.length, 1);
await importStatement(makeStatement('second')); await importStatement(makeStatement(['second']));
const result = await pool.query('SELECT account_type, status FROM accounts WHERE id = $1', [account.rows[0].id]); const result = await pool.query('SELECT account_type, status FROM accounts WHERE id = $1', [account.rows[0].id]);
assert.deepEqual(result.rows[0], { account_type: 'savings', status: 'closed' }); assert.deepEqual(result.rows[0], { account_type: 'savings', status: 'closed' });
console.log('import metadata SQL: OK'); console.log('import metadata SQL: OK');

View File

@@ -219,7 +219,7 @@ export async function importStatement(
// Insert transactions // Insert transactions
const insertedIds: number[] = []; const insertedIds: number[] = [];
for (const tx of data.transactions) { for (const [sourcePosition, tx] of data.transactions.entries()) {
const fp = computeFingerprint(data.statement.accountNumber, tx); const fp = computeFingerprint(data.statement.accountNumber, tx);
const isCashbackCommissionImport = const isCashbackCommissionImport =
tx.amountSigned === 0 && tx.amountSigned === 0 &&
@@ -235,11 +235,11 @@ export async function importStatement(
const result = await client.query( const result = await client.query(
`INSERT INTO transactions `INSERT INTO transactions
(account_id, operation_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id) (account_id, operation_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id, source_position)
VALUES ($1, COALESCE($2::uuid, gen_random_uuid()), $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, COALESCE($2::uuid, gen_random_uuid()), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
RETURNING id`, RETURNING id`,
[accountId, tx.operationId ?? null, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId], [accountId, tx.operationId ?? null, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId, sourcePosition],
); );
if (result.rows.length > 0) { if (result.rows.length > 0) {

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import { pool } from '../db/pool';
import { importPortfolio } from './portfolio';
const payload = {
schemaVersion: 'broker-portfolio-1.0', bank: 'PORTFOLIO_TEST', accountNumber: 'portfolio-test',
reportPeriod: { from: '2026-08-01', to: '2026-08-20' }, reportedAt: '2026-08-20T00:00:00+03:00',
positions: [{ instrument: 'Test Bond', isin: 'RU0000000001', quantity: '1.000', price: '100.123456', valuation: '100.123456' }],
trades: [{ instrument: 'Test Bond', isin: 'RU0000000001', concludedAt: '2026-08-10T10:00:00+03:00', side: 'Покупка', quantity: '1.000', settlementAmount: '100.123456' }],
};
async function run(): Promise<void> {
try {
const first = await importPortfolio(payload);
const second = await importPortfolio(payload);
assert.equal(first.importedTrades, 1);
assert.equal(second.duplicateTrades, 1);
const rows = await pool.query('SELECT COUNT(*)::int AS count FROM portfolio_trades WHERE account_id = $1', [first.accountId]);
assert.equal(rows.rows[0].count, 1);
console.log('portfolio import SQL: OK');
} finally {
await pool.query("DELETE FROM portfolio_trades WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'PORTFOLIO_TEST' AND account_number = 'portfolio-test')");
await pool.query("DELETE FROM portfolio_reports WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'PORTFOLIO_TEST' AND account_number = 'portfolio-test')");
await pool.query("DELETE FROM accounts WHERE bank = 'PORTFOLIO_TEST' AND account_number = 'portfolio-test'");
await pool.end();
}
}
run();

View File

@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { deriveTradeSourceId, validatePortfolio } from './portfolio';
const trade = {
instrument: 'Bond', isin: 'RU0000000001', concludedAt: '2026-08-20T10:00:00+03:00',
side: 'Покупка', quantity: '1.000', settlementAmount: '1234.50',
};
assert.equal(deriveTradeSourceId(trade), 'RU0000000001|2026-08-20T10:00:00+03:00|Покупка|1.000|1234.50');
assert.throws(() => validatePortfolio(null), /object/);
assert.throws(() => validatePortfolio({ schemaVersion: 'broker-portfolio-1.0', bank: 'B', accountNumber: 'A', reportPeriod: { from: '2026-08-20', to: '2026-08-20' }, reportedAt: null, positions: [], trades: [{ ...trade, sourceId: 42 }] }), /sourceId/);
assert.throws(() => validatePortfolio({ ...{ schemaVersion: 'broker-portfolio-1.0', bank: 'B', accountNumber: 'A', reportPeriod: { from: '2026-08-21', to: '2026-08-20' }, reportedAt: null, positions: [], trades: [] } }), /period/);
console.log('portfolio validation: OK');

View File

@@ -0,0 +1,103 @@
import crypto from 'crypto';
import { pool } from '../db/pool';
import type { ImportPortfolioResponse, PortfolioFile, PortfolioTrade } from '@family-budget/shared';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function decimalValue(value: unknown, field: string, required = false): string | null {
if (value == null || value === '') {
if (required) throw new Error(`${field} is required`);
return null;
}
const result = String(value).replace(/\s/g, '').replace(',', '.');
if (!/^-?(?:\d+\.?\d*|\.\d+)$/.test(result)) throw new Error(`${field} must be numeric`);
return result;
}
export function deriveTradeSourceId(trade: PortfolioTrade): string {
if (trade.sourceId?.trim()) return trade.sourceId.trim();
return [trade.isin || trade.instrument, trade.concludedAt, trade.side, decimalValue(trade.quantity, 'quantity', true), decimalValue(trade.settlementAmount, 'settlementAmount') || ''].join('|');
}
export function validatePortfolio(body: unknown): asserts body is PortfolioFile {
if (!body || typeof body !== 'object') throw new Error('Portfolio file must be an object');
const data = body as PortfolioFile;
if (data.schemaVersion !== 'broker-portfolio-1.0' || typeof data.bank !== 'string' || !data.bank.trim() || typeof data.accountNumber !== 'string' || !data.accountNumber.trim()) throw new Error('Invalid portfolio file');
if (!data.reportPeriod?.from || !data.reportPeriod?.to || Number.isNaN(Date.parse(data.reportPeriod.from)) || Number.isNaN(Date.parse(data.reportPeriod.to)) || data.reportPeriod.from > data.reportPeriod.to) throw new Error('Invalid report period');
if (data.reportedAt !== null && (typeof data.reportedAt !== 'string' || Number.isNaN(Date.parse(data.reportedAt)))) throw new Error('Invalid reportedAt');
if (!Array.isArray(data.positions) || !Array.isArray(data.trades)) throw new Error('positions and trades must be arrays');
const sourceIds = new Set<string>();
for (const trade of data.trades) {
if (!trade || typeof trade.instrument !== 'string' || !trade.instrument.trim() || typeof trade.concludedAt !== 'string' || Number.isNaN(Date.parse(trade.concludedAt)) || typeof trade.side !== 'string' || !trade.side.trim()) throw new Error('Invalid trade');
if (trade.sourceId !== undefined && typeof trade.sourceId !== 'string') throw new Error('sourceId must be a string');
if (trade.operationId !== undefined && typeof trade.operationId !== 'string') throw new Error('operationId must be a string');
const sourceId = deriveTradeSourceId(trade);
if (sourceIds.has(sourceId)) throw new Error(`Duplicate sourceId: ${sourceId}`);
sourceIds.add(sourceId);
if (trade.operationId && !UUID_RE.test(trade.operationId)) throw new Error(`Invalid operationId: ${trade.sourceId}`);
decimalValue(trade.quantity, 'quantity', true);
}
for (const position of data.positions) {
if (!position || typeof position.instrument !== 'string' || !position.instrument.trim()) throw new Error('Invalid position');
decimalValue(position.quantity, 'position.quantity', true);
decimalValue(position.price, 'position.price');
decimalValue(position.valuation, 'position.valuation');
}
}
export async function importPortfolio(body: unknown, db: Pick<typeof pool, 'connect'> = pool): Promise<ImportPortfolioResponse> {
validatePortfolio(body);
const data = body;
const sourceHash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex');
const client = await db.connect();
try {
await client.query('BEGIN');
const accountResult = await client.query(
`INSERT INTO accounts (bank, account_number, currency, account_type)
VALUES ($1, $2, 'RUB', 'brokerage')
ON CONFLICT (bank, account_number) DO UPDATE SET account_type = COALESCE(accounts.account_type, 'brokerage')
RETURNING id`,
[data.bank, data.accountNumber],
);
const accountId = Number(accountResult.rows[0].id);
const reportResult = await client.query(
`INSERT INTO portfolio_reports (account_id, source_hash, report_period_from, report_period_to, reported_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (account_id, source_hash) DO NOTHING
RETURNING id`,
[accountId, sourceHash, data.reportPeriod.from, data.reportPeriod.to, data.reportedAt],
);
if (reportResult.rows.length === 0) {
const existing = await client.query('SELECT id FROM portfolio_reports WHERE account_id = $1 AND source_hash = $2', [accountId, sourceHash]);
await client.query('COMMIT');
return { accountId, reportId: Number(existing.rows[0].id), importedTrades: 0, duplicateTrades: data.trades.length, positions: 0 };
}
const reportId = Number(reportResult.rows[0].id);
for (const position of data.positions) {
await client.query(
`INSERT INTO portfolio_positions (report_id, instrument, isin, quantity, price, valuation)
VALUES ($1, $2, $3, $4, $5, $6)`,
[reportId, position.instrument, position.isin ?? null, decimalValue(position.quantity, 'position.quantity', true), decimalValue(position.price, 'position.price'), decimalValue(position.valuation, 'position.valuation')],
);
}
let importedTrades = 0;
for (const trade of data.trades) {
const sourceId = deriveTradeSourceId(trade);
const result = await client.query(
`INSERT INTO portfolio_trades
(account_id, source_id, operation_id, instrument, isin, concluded_at, side, quantity, price_currency, price, settlement_currency, settlement_amount, nkd, settlement_commission, trade_commission, order_id, trade_id, venue, comment)
VALUES ($1, $2, COALESCE($3::uuid, gen_random_uuid()), $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (account_id, source_id) DO NOTHING`,
[accountId, sourceId, trade.operationId ?? null, trade.instrument, trade.isin ?? null, trade.concludedAt, trade.side, decimalValue(trade.quantity, 'quantity', true), trade.priceCurrency ?? null, decimalValue(trade.price, 'price'), trade.settlementCurrency ?? null, decimalValue(trade.settlementAmount, 'settlementAmount'), decimalValue(trade.nkd, 'nkd'), decimalValue(trade.settlementCommission, 'settlementCommission'), decimalValue(trade.tradeCommission, 'tradeCommission'), trade.orderId ?? null, trade.tradeId ?? null, trade.venue ?? null, trade.comment ?? null],
);
importedTrades += result.rowCount ?? 0;
}
await client.query('COMMIT');
return { accountId, reportId, importedTrades, duplicateTrades: data.trades.length - importedTrades, positions: data.positions.length };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}

View File

@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { transactionOrderBy } from './transactions';
assert.equal(
transactionOrderBy('date', 'desc'),
't.operation_at DESC, t.source_position ASC, t.id ASC',
);
assert.equal(
transactionOrderBy('date', 'asc'),
't.operation_at ASC, t.source_position DESC, t.id DESC',
);
assert.match(transactionOrderBy('amount', 'desc'), /t\.amount_signed DESC.*t\.operation_at DESC.*t\.source_position ASC.*t\.id DESC/);
console.log('transaction ordering: OK');

View File

@@ -7,13 +7,18 @@ import type {
UpdateTransactionRequest, UpdateTransactionRequest,
} from '@family-budget/shared'; } from '@family-budget/shared';
export function transactionOrderBy(sortBy: 'date' | 'amount', sortOrder: 'asc' | 'desc'): string {
const direction = sortOrder === 'asc' ? 'ASC' : 'DESC';
if (sortBy === 'amount') return `t.amount_signed ${direction}, t.operation_at DESC, t.source_position ASC, t.id DESC`;
return `t.operation_at ${direction}, t.source_position ${direction === 'ASC' ? 'DESC' : 'ASC'}, t.id ${direction === 'ASC' ? 'DESC' : 'ASC'}`;
}
export async function getTransactions( export async function getTransactions(
params: GetTransactionsParams, params: GetTransactionsParams,
): Promise<PaginatedResponse<Transaction>> { ): Promise<PaginatedResponse<Transaction>> {
const page = params.page ?? 1; const page = params.page ?? 1;
const pageSize = [10, 50, 100].includes(params.pageSize ?? 50) ? (params.pageSize ?? 50) : 50; const pageSize = [10, 50, 100].includes(params.pageSize ?? 50) ? (params.pageSize ?? 50) : 50;
const sortBy = params.sortBy === 'amount' ? 't.amount_signed' : 't.operation_at'; const orderBy = transactionOrderBy(params.sortBy === 'amount' ? 'amount' : 'date', params.sortOrder === 'asc' ? 'asc' : 'desc');
const sortOrder = params.sortOrder === 'asc' ? 'ASC' : 'DESC';
const conditions: string[] = []; const conditions: string[] = [];
const values: unknown[] = []; const values: unknown[] = [];
@@ -84,7 +89,7 @@ export async function getTransactions(
JOIN accounts a ON a.id = t.account_id JOIN accounts a ON a.id = t.account_id
LEFT JOIN categories c ON c.id = t.category_id LEFT JOIN categories c ON c.id = t.category_id
${where} ${where}
ORDER BY ${sortBy} ${sortOrder} ORDER BY ${orderBy}
LIMIT $${idx++} OFFSET $${idx++}`, LIMIT $${idx++} OFFSET $${idx++}`,
[...values, pageSize, offset], [...values, pageSize, offset],
); );

View File

@@ -1,5 +1,6 @@
import type { import type {
CategoryRule, CategoryRule,
CreateCategoryRuleResponse,
GetCategoryRulesParams, GetCategoryRulesParams,
CreateCategoryRuleRequest, CreateCategoryRuleRequest,
UpdateCategoryRuleRequest, UpdateCategoryRuleRequest,
@@ -24,8 +25,8 @@ export async function getCategoryRules(
export async function createCategoryRule( export async function createCategoryRule(
data: CreateCategoryRuleRequest, data: CreateCategoryRuleRequest,
): Promise<CategoryRule> { ): Promise<CreateCategoryRuleResponse> {
return api.post<CategoryRule>('/api/category-rules', data); return api.post<CreateCategoryRuleResponse>('/api/category-rules', data);
} }
export async function updateCategoryRule( export async function updateCategoryRule(

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/shared", "name": "@family-budget/shared",
"version": "0.3.0", "version": "0.4.0",
"private": true, "private": true,
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -26,6 +26,10 @@ export interface CreateCategoryRuleRequest {
requiresConfirmation?: boolean; requiresConfirmation?: boolean;
} }
export interface CreateCategoryRuleResponse extends CategoryRule {
applied: number;
}
export interface UpdateCategoryRuleRequest { export interface UpdateCategoryRuleRequest {
pattern?: string; pattern?: string;
categoryId?: number; categoryId?: number;

View File

@@ -46,3 +46,50 @@ export interface ImportStatementResponse {
duplicatesSkipped: number; duplicatesSkipped: number;
totalInFile: number; totalInFile: number;
} }
export interface PortfolioFile {
schemaVersion: 'broker-portfolio-1.0';
bank: string;
accountNumber: string;
reportPeriod: { from: string; to: string };
reportedAt: string | null;
positions: PortfolioPosition[];
trades: PortfolioTrade[];
}
export interface PortfolioPosition {
instrument: string;
isin?: string | null;
quantity: string | number;
price?: string | number | null;
valuation?: string | number | null;
}
export interface PortfolioTrade {
sourceId?: string;
operationId?: string;
instrument: string;
isin?: string | null;
concludedAt: string;
side: string;
quantity: string | number;
priceCurrency?: string | null;
price?: string | number | null;
settlementCurrency?: string | null;
settlementAmount?: string | number | null;
nkd?: string | number | null;
settlementCommission?: string | number | null;
tradeCommission?: string | number | null;
orderId?: string | null;
tradeId?: string | null;
venue?: string | null;
comment?: string | null;
}
export interface ImportPortfolioResponse {
accountId: number;
reportId: number;
importedTrades: number;
duplicateTrades: number;
positions: number;
}

View File

@@ -24,6 +24,7 @@ export type {
export type { export type {
CategoryRule, CategoryRule,
CreateCategoryRuleResponse,
GetCategoryRulesParams, GetCategoryRulesParams,
CreateCategoryRuleRequest, CreateCategoryRuleRequest,
UpdateCategoryRuleRequest, UpdateCategoryRuleRequest,
@@ -38,6 +39,10 @@ export type {
StatementTransaction, StatementTransaction,
ImportStatementResponse, ImportStatementResponse,
Import, Import,
PortfolioFile,
PortfolioPosition,
PortfolioTrade,
ImportPortfolioResponse,
} from './import'; } from './import';
export type { export type {