283 lines
9.5 KiB
TypeScript
283 lines
9.5 KiB
TypeScript
import { pool } from '../db/pool';
|
|
import { effectiveAmountCase } from './analyticsSemantics';
|
|
import type {
|
|
AnalyticsSummaryResponse,
|
|
TopCategory,
|
|
ByCategoryItem,
|
|
TimeseriesItem,
|
|
Granularity,
|
|
} from '@family-budget/shared';
|
|
|
|
type Queryable = Pick<typeof pool, 'query'>;
|
|
|
|
interface BaseParams {
|
|
from: string;
|
|
to: string;
|
|
accountId?: number;
|
|
categoryId?: number;
|
|
onlyConfirmed?: boolean;
|
|
}
|
|
|
|
const effectiveAmount = effectiveAmountCase('t');
|
|
|
|
function analyticsTransactions(where: string): string {
|
|
return `
|
|
WITH analytics_transactions AS (
|
|
SELECT
|
|
t.id,
|
|
t.operation_at,
|
|
COALESCE(c.type, t.direction) AS analytic_type,
|
|
COALESCE(t.category_id, 0) AS category_id,
|
|
COALESCE(c.name, 'Без категории') AS category_name,
|
|
${effectiveAmount} AS effective_amount,
|
|
CASE WHEN a.account_type = 'savings'
|
|
AND ${effectiveAmount} > 0
|
|
AND (t.description ILIKE '%процент%' OR t.description ILIKE '%выплата %' OR t.description LIKE '%\%%' ESCAPE '\\')
|
|
THEN ${effectiveAmount} ELSE 0 END AS interest_income,
|
|
CASE WHEN t.amount_signed = 0
|
|
AND t.commission > 0
|
|
AND t.description ILIKE '%зачисление%'
|
|
THEN t.commission ELSE 0 END AS cashback_income
|
|
FROM transactions t
|
|
LEFT JOIN categories c ON c.id = t.category_id
|
|
LEFT JOIN accounts a ON a.id = t.account_id
|
|
${where}
|
|
)`;
|
|
}
|
|
|
|
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.categoryId != null) {
|
|
conditions.push(params.categoryId === 0 ? 't.category_id IS NULL' : `t.category_id = $${idx}`);
|
|
if (params.categoryId !== 0) {
|
|
values.push(params.categoryId);
|
|
idx++;
|
|
}
|
|
}
|
|
if (params.onlyConfirmed) {
|
|
conditions.push('t.is_category_confirmed = TRUE');
|
|
}
|
|
|
|
return { conditions, values, nextIdx: idx };
|
|
}
|
|
|
|
export async function getSummary(
|
|
params: BaseParams,
|
|
db: Queryable = pool,
|
|
): Promise<AnalyticsSummaryResponse> {
|
|
const { conditions, values } = buildBaseConditions(params, 1);
|
|
const where = 'WHERE ' + conditions.join(' AND ');
|
|
|
|
const totalsResult = await db.query(
|
|
`${analyticsTransactions(where)},
|
|
category_net AS (
|
|
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount,
|
|
SUM(interest_income)::bigint AS interest_income,
|
|
SUM(cashback_income)::bigint AS cashback_income
|
|
FROM analytics_transactions
|
|
GROUP BY category_id, category_name, analytic_type
|
|
)
|
|
SELECT
|
|
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS total_expense,
|
|
COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS total_income,
|
|
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_inflow,
|
|
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_outflow,
|
|
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_inflow,
|
|
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow,
|
|
COALESCE(SUM(interest_income), 0)::bigint AS interest_income,
|
|
COALESCE(SUM(cashback_income), 0)::bigint AS cashback_income
|
|
FROM category_net`,
|
|
values,
|
|
);
|
|
|
|
const totalExpense = Number(totalsResult.rows[0].total_expense);
|
|
const totalIncome = Number(totalsResult.rows[0].total_income);
|
|
const cashInflow = Number(totalsResult.rows[0].cash_inflow);
|
|
const cashOutflow = Number(totalsResult.rows[0].cash_outflow);
|
|
const transferInflow = Number(totalsResult.rows[0].transfer_inflow);
|
|
const transferOutflow = Number(totalsResult.rows[0].transfer_outflow);
|
|
const interestIncome = Number(totalsResult.rows[0].interest_income);
|
|
const cashbackIncome = Number(totalsResult.rows[0].cashback_income);
|
|
|
|
const topResult = await db.query(
|
|
`${analyticsTransactions(where)}
|
|
SELECT category_id::bigint, category_name, GREATEST(-SUM(effective_amount), 0)::bigint AS amount
|
|
FROM analytics_transactions
|
|
WHERE analytic_type = 'expense'
|
|
GROUP BY category_id, category_name
|
|
HAVING SUM(effective_amount) < 0
|
|
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,
|
|
cashInflow,
|
|
cashOutflow,
|
|
transferInflow,
|
|
transferOutflow,
|
|
cashNet: cashInflow - cashOutflow,
|
|
interestIncome,
|
|
cashbackIncome,
|
|
topCategories,
|
|
};
|
|
}
|
|
|
|
export async function getByCategory(
|
|
params: BaseParams,
|
|
db: Queryable = pool,
|
|
): Promise<ByCategoryItem[]> {
|
|
const { conditions, values } = buildBaseConditions(params, 1);
|
|
const where = 'WHERE ' + conditions.join(' AND ');
|
|
|
|
const { rows } = await db.query(
|
|
`${analyticsTransactions(where)}
|
|
SELECT
|
|
category_id::bigint,
|
|
category_name,
|
|
GREATEST(-SUM(effective_amount), 0)::bigint AS amount,
|
|
COUNT(*)::int AS tx_count
|
|
FROM analytics_transactions
|
|
WHERE analytic_type = 'expense'
|
|
GROUP BY category_id, category_name
|
|
HAVING SUM(effective_amount) < 0
|
|
ORDER BY amount DESC`,
|
|
values,
|
|
);
|
|
|
|
const total = rows.reduce((sum, row) => sum + Number(row.amount), 0);
|
|
|
|
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 },
|
|
db: Queryable = pool,
|
|
): 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 >= $1::date',
|
|
't.operation_at::date <= $2::date',
|
|
'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) {
|
|
if (params.categoryId === 0) txConditions.push('t.category_id IS NULL');
|
|
else {
|
|
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 db.query(
|
|
`WITH periods AS (
|
|
SELECT
|
|
gs::date AS period_start,
|
|
${periodEndExpr} AS period_end
|
|
FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs
|
|
),
|
|
period_transactions AS (
|
|
SELECT
|
|
p.period_start,
|
|
p.period_end,
|
|
COALESCE(c.type, t.direction) AS analytic_type,
|
|
COALESCE(t.category_id, 0) AS category_id,
|
|
${effectiveAmount} AS effective_amount
|
|
FROM periods p
|
|
LEFT JOIN transactions t ON ${txWhere}
|
|
LEFT JOIN categories c ON c.id = t.category_id
|
|
),
|
|
period_category_net AS (
|
|
SELECT period_start, period_end, analytic_type, category_id, SUM(effective_amount)::bigint AS amount
|
|
FROM period_transactions
|
|
GROUP BY period_start, period_end, analytic_type, category_id
|
|
)
|
|
SELECT
|
|
period_start,
|
|
period_end,
|
|
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS expense_amount,
|
|
COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS income_amount,
|
|
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow
|
|
FROM period_category_net
|
|
GROUP BY period_start, period_end
|
|
ORDER BY 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),
|
|
transferOutflow: Number(r.transfer_outflow),
|
|
}));
|
|
}
|