diff --git a/backend/package.json b/backend/package.json index 7353b00..4ad50a7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/backend", - "version": "0.6.0", + "version": "0.6.1", "private": true, "scripts": { "dev": "tsx watch src/app.ts", @@ -8,6 +8,7 @@ "start": "node dist/app.js", "migrate": "tsx src/db/migrate.ts", "migrate:prod": "node dist/db/migrate.js", + "test:analytics": "tsx src/services/analyticsSemantics.test.ts", "test:llm": "tsx src/scripts/testLlm.ts" }, "dependencies": { diff --git a/backend/src/services/analytics.ts b/backend/src/services/analytics.ts index 1567cc4..dffeef5 100644 --- a/backend/src/services/analytics.ts +++ b/backend/src/services/analytics.ts @@ -1,4 +1,5 @@ import { pool } from '../db/pool'; +import { effectiveAmountCase } from './analyticsSemantics'; import type { AnalyticsSummaryResponse, TopCategory, @@ -14,14 +15,7 @@ interface BaseParams { onlyConfirmed?: boolean; } -const effectiveAmount = ` - CASE - WHEN t.amount_signed = 0 AND t.commission > 0 - AND t.description ILIKE '%зачисление%' - THEN t.commission - WHEN t.amount_signed < 0 THEN t.amount_signed - t.commission - ELSE t.amount_signed + t.commission - END`; +const effectiveAmount = effectiveAmountCase('t'); function analyticsTransactions(where: string): string { return ` diff --git a/backend/src/services/analyticsSemantics.test.ts b/backend/src/services/analyticsSemantics.test.ts new file mode 100644 index 0000000..cb2fe52 --- /dev/null +++ b/backend/src/services/analyticsSemantics.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { getEffectiveAmount, getNetAmount } from './analyticsSemantics'; + +assert.equal(getNetAmount('expense', [ + { amountSigned: -10_000, commission: 0 }, + { amountSigned: 4_000, commission: 0 }, +]), 6_000, 'возврат уменьшает расход своей категории'); + +assert.equal(getNetAmount('income', [ + { amountSigned: 20_000, commission: 0 }, + { amountSigned: -5_000, commission: 0 }, +]), 15_000, 'корректировка уменьшает доход'); + +assert.equal(getNetAmount('transfer', [ + { amountSigned: -10_000, commission: 0 }, + { amountSigned: 10_000, commission: 0 }, +]), 0, 'перевод не является доходом или расходом'); + +assert.equal( + getEffectiveAmount({ amountSigned: 0, commission: 500, description: 'Зачисление кэшбэка' }), + 500, + 'кэшбэк учитывается как поступление', +); + +console.log('analytics semantics: OK'); diff --git a/backend/src/services/analyticsSemantics.ts b/backend/src/services/analyticsSemantics.ts new file mode 100644 index 0000000..068fe48 --- /dev/null +++ b/backend/src/services/analyticsSemantics.ts @@ -0,0 +1,32 @@ +export type AnalyticType = 'expense' | 'income' | 'transfer'; + +interface AmountInput { + amountSigned: number; + commission: number; + description?: string; +} + +export function getEffectiveAmount({ amountSigned, commission, description = '' }: AmountInput): number { + if (amountSigned === 0 && commission > 0 && description.toLowerCase().includes('зачисление')) { + return commission; + } + return amountSigned < 0 ? amountSigned - commission : amountSigned + commission; +} + +export function getNetAmount(type: AnalyticType, amounts: AmountInput[]): number { + const amount = amounts.reduce((sum, item) => sum + getEffectiveAmount(item), 0); + if (type === 'expense') return Math.max(-amount, 0); + if (type === 'income') return Math.max(amount, 0); + return 0; +} + +export function effectiveAmountCase(alias: string): string { + return ` + CASE + WHEN ${alias}.amount_signed = 0 AND ${alias}.commission > 0 + AND ${alias}.description ILIKE '%зачисление%' + THEN ${alias}.commission + WHEN ${alias}.amount_signed < 0 THEN ${alias}.amount_signed - ${alias}.commission + ELSE ${alias}.amount_signed + ${alias}.commission + END`; +}