feat: creates backend for the project
This commit is contained in:
196
backend/src/services/analytics.ts
Normal file
196
backend/src/services/analytics.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { pool } from '../db/pool';
|
||||
import type {
|
||||
AnalyticsSummaryResponse,
|
||||
TopCategory,
|
||||
ByCategoryItem,
|
||||
TimeseriesItem,
|
||||
Granularity,
|
||||
} from '@family-budget/shared';
|
||||
|
||||
interface BaseParams {
|
||||
from: string;
|
||||
to: string;
|
||||
accountId?: number;
|
||||
onlyConfirmed?: boolean;
|
||||
}
|
||||
|
||||
function buildBaseConditions(
|
||||
params: BaseParams,
|
||||
startIdx: number,
|
||||
): { conditions: string[]; values: unknown[]; nextIdx: number } {
|
||||
const conditions: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = startIdx;
|
||||
|
||||
conditions.push(`t.operation_at >= $${idx}::date`);
|
||||
values.push(params.from);
|
||||
idx++;
|
||||
|
||||
conditions.push(`t.operation_at < ($${idx}::date + 1)`);
|
||||
values.push(params.to);
|
||||
idx++;
|
||||
|
||||
if (params.accountId != null) {
|
||||
conditions.push(`t.account_id = $${idx}`);
|
||||
values.push(params.accountId);
|
||||
idx++;
|
||||
}
|
||||
if (params.onlyConfirmed) {
|
||||
conditions.push('t.is_category_confirmed = TRUE');
|
||||
}
|
||||
|
||||
return { conditions, values, nextIdx: idx };
|
||||
}
|
||||
|
||||
export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryResponse> {
|
||||
const { conditions, values } = buildBaseConditions(params, 1);
|
||||
const where = 'WHERE ' + conditions.join(' AND ');
|
||||
|
||||
const totalsResult = await pool.query(
|
||||
`SELECT
|
||||
COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS total_expense,
|
||||
COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS total_income
|
||||
FROM transactions t
|
||||
${where}`,
|
||||
values,
|
||||
);
|
||||
|
||||
const totalExpense = Number(totalsResult.rows[0].total_expense);
|
||||
const totalIncome = Number(totalsResult.rows[0].total_income);
|
||||
|
||||
const topResult = await pool.query(
|
||||
`SELECT
|
||||
t.category_id,
|
||||
c.name AS category_name,
|
||||
SUM(ABS(t.amount_signed))::bigint AS amount
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
${where} AND t.direction = 'expense' AND t.category_id IS NOT NULL
|
||||
GROUP BY t.category_id, c.name
|
||||
ORDER BY amount DESC
|
||||
LIMIT 5`,
|
||||
values,
|
||||
);
|
||||
|
||||
const topCategories: TopCategory[] = topResult.rows.map((r) => ({
|
||||
categoryId: Number(r.category_id),
|
||||
categoryName: r.category_name,
|
||||
amount: Number(r.amount),
|
||||
share: totalExpense > 0 ? Number(r.amount) / totalExpense : 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
totalExpense,
|
||||
totalIncome,
|
||||
net: totalIncome - totalExpense,
|
||||
topCategories,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[]> {
|
||||
const { conditions, values } = buildBaseConditions(params, 1);
|
||||
const where = 'WHERE ' + conditions.join(' AND ');
|
||||
|
||||
const totalResult = await pool.query(
|
||||
`SELECT COALESCE(SUM(ABS(t.amount_signed)), 0)::bigint AS total
|
||||
FROM transactions t
|
||||
${where} AND t.direction = 'expense'`,
|
||||
values,
|
||||
);
|
||||
const total = Number(totalResult.rows[0].total);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT
|
||||
t.category_id,
|
||||
c.name AS category_name,
|
||||
SUM(ABS(t.amount_signed))::bigint AS amount,
|
||||
COUNT(*)::int AS tx_count
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
${where} AND t.direction = 'expense'
|
||||
GROUP BY t.category_id, c.name
|
||||
ORDER BY amount DESC`,
|
||||
values,
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
categoryId: r.category_id != null ? Number(r.category_id) : 0,
|
||||
categoryName: r.category_name ?? 'Без категории',
|
||||
amount: Number(r.amount),
|
||||
txCount: r.tx_count,
|
||||
share: total > 0 ? Number(r.amount) / total : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getTimeseries(
|
||||
params: BaseParams & { categoryId?: number; granularity: Granularity },
|
||||
): Promise<TimeseriesItem[]> {
|
||||
let truncExpr: string;
|
||||
let intervalStr: string;
|
||||
let periodEndExpr: string;
|
||||
|
||||
switch (params.granularity) {
|
||||
case 'day':
|
||||
truncExpr = `$1::date`;
|
||||
intervalStr = '1 day';
|
||||
periodEndExpr = 'gs::date';
|
||||
break;
|
||||
case 'week':
|
||||
truncExpr = `date_trunc('week', $1::date)::date`;
|
||||
intervalStr = '1 week';
|
||||
periodEndExpr = "(gs + interval '6 days')::date";
|
||||
break;
|
||||
case 'month':
|
||||
truncExpr = `date_trunc('month', $1::date)::date`;
|
||||
intervalStr = '1 month';
|
||||
periodEndExpr = "(gs + interval '1 month' - interval '1 day')::date";
|
||||
break;
|
||||
}
|
||||
|
||||
const txConditions: string[] = [
|
||||
't.operation_at::date >= p.period_start',
|
||||
't.operation_at::date <= p.period_end',
|
||||
];
|
||||
const values: unknown[] = [params.from, params.to];
|
||||
let idx = 3;
|
||||
|
||||
if (params.accountId != null) {
|
||||
txConditions.push(`t.account_id = $${idx++}`);
|
||||
values.push(params.accountId);
|
||||
}
|
||||
if (params.categoryId != null) {
|
||||
txConditions.push(`t.category_id = $${idx++}`);
|
||||
values.push(params.categoryId);
|
||||
}
|
||||
if (params.onlyConfirmed) {
|
||||
txConditions.push('t.is_category_confirmed = TRUE');
|
||||
}
|
||||
|
||||
const txWhere = txConditions.join(' AND ');
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`WITH periods AS (
|
||||
SELECT
|
||||
gs::date AS period_start,
|
||||
${periodEndExpr} AS period_end
|
||||
FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs
|
||||
)
|
||||
SELECT
|
||||
p.period_start,
|
||||
p.period_end,
|
||||
COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS expense_amount,
|
||||
COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS income_amount
|
||||
FROM periods p
|
||||
LEFT JOIN transactions t ON ${txWhere}
|
||||
GROUP BY p.period_start, p.period_end
|
||||
ORDER BY p.period_start`,
|
||||
values,
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
periodStart: r.period_start.toISOString().slice(0, 10),
|
||||
periodEnd: r.period_end.toISOString().slice(0, 10),
|
||||
expenseAmount: Number(r.expense_amount),
|
||||
incomeAmount: Number(r.income_amount),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user