8bd7d77a8a
- Add owner <select> dropdown per row using useUpdateStatement + useParticipants
- Detect CC by statement_type.includes('card') instead of credit_limit/payment_due_date
(Wise multi-currency account had payment_due_date set but is not a CC)
- Amount: remove 'due'/'balance' label; color green for positive bank balances, red for CC/overdraft
- Add statement_type to StatementRow type
299 lines
9.6 KiB
TypeScript
299 lines
9.6 KiB
TypeScript
import { queryRaw } from "./db";
|
|
|
|
export interface TagRow {
|
|
id: number;
|
|
name: string;
|
|
color: string;
|
|
}
|
|
|
|
export interface TransactionRow {
|
|
id: number;
|
|
statement_id: number;
|
|
transaction_date: string;
|
|
description: string;
|
|
amount: number;
|
|
transaction_type: string;
|
|
merchant_name: string | null;
|
|
merchant_normalized: string | null;
|
|
location: string | null;
|
|
foreign_currency_amount: number | null;
|
|
foreign_currency_code: string | null;
|
|
category: string;
|
|
row_index: number;
|
|
created_at: string;
|
|
// override fields
|
|
category_override: string | null;
|
|
merchant_override: string | null;
|
|
notes: string | null;
|
|
effective_category: string;
|
|
effective_merchant: string;
|
|
// statement context
|
|
bank_name: string;
|
|
owner_id: number;
|
|
owner_name: string;
|
|
// tags
|
|
tags: TagRow[];
|
|
}
|
|
|
|
export interface StatementRow {
|
|
id: number;
|
|
bank_name: string;
|
|
card_name: string | null;
|
|
account_number: string;
|
|
account_type: string | null;
|
|
account_holder_name: string | null;
|
|
billing_start_date: string | null;
|
|
billing_end_date: string | null;
|
|
total_amount_due: number;
|
|
minimum_amount_due: number | null;
|
|
payment_due_date: string;
|
|
opening_balance: number | null;
|
|
closing_balance: number | null;
|
|
total_credits: number | null;
|
|
total_debits: number | null;
|
|
interest_charged: number | null;
|
|
fees_charged: number | null;
|
|
credit_limit: number | null;
|
|
currency: string;
|
|
statement_type: string | null;
|
|
tier_used: string | null;
|
|
owner_id: number;
|
|
owner_name: string;
|
|
created_at: string;
|
|
transaction_count: number;
|
|
}
|
|
|
|
interface TransactionFilters {
|
|
from?: string;
|
|
to?: string;
|
|
category?: string;
|
|
bank_name?: string;
|
|
search?: string;
|
|
statement_id?: string;
|
|
tag_id?: string;
|
|
sort_by?: string;
|
|
sort_dir?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
|
|
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
|
const conditions: string[] = [`s.owner_id = $1`];
|
|
const params: unknown[] = [ownerId];
|
|
let paramIdx = 2;
|
|
|
|
if (filters.from) {
|
|
conditions.push(`t.transaction_date >= $${paramIdx++}`);
|
|
params.push(filters.from);
|
|
}
|
|
if (filters.to) {
|
|
conditions.push(`t.transaction_date <= $${paramIdx++}`);
|
|
params.push(filters.to);
|
|
}
|
|
if (filters.category) {
|
|
conditions.push(`COALESCE(o.category_override, t.category) = $${paramIdx++}`);
|
|
params.push(filters.category);
|
|
}
|
|
if (filters.bank_name) {
|
|
conditions.push(`s.bank_name = $${paramIdx++}`);
|
|
params.push(filters.bank_name);
|
|
}
|
|
if (filters.search) {
|
|
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx})`);
|
|
params.push(`%${filters.search}%`);
|
|
paramIdx++;
|
|
}
|
|
if (filters.statement_id) {
|
|
conditions.push(`t.statement_id = $${paramIdx++}`);
|
|
params.push(Number(filters.statement_id));
|
|
}
|
|
if (filters.tag_id) {
|
|
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = $${paramIdx++})`);
|
|
params.push(Number(filters.tag_id));
|
|
}
|
|
|
|
const where = `WHERE ${conditions.join(" AND ")}`;
|
|
|
|
const sortCol = filters.sort_by === "amount" ? "t.amount" : "t.transaction_date";
|
|
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
|
|
const limit = filters.limit || 50;
|
|
const offset = filters.offset || 0;
|
|
|
|
const countSql = `
|
|
SELECT COUNT(*)::int as total
|
|
FROM transactions t
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
JOIN statements s ON s.id = t.statement_id
|
|
${where}
|
|
`;
|
|
const countResult = await queryRaw<{ total: number }>(countSql, params);
|
|
const total = countResult[0]?.total || 0;
|
|
|
|
const dataSql = `
|
|
SELECT t.*,
|
|
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
|
COALESCE(o.category_override, t.category) as effective_category,
|
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
|
s.bank_name, s.owner_id,
|
|
p.name as owner_name,
|
|
txn_tags.tags
|
|
FROM transactions t
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
JOIN statements s ON s.id = t.statement_id
|
|
LEFT JOIN participants p ON p.id = s.owner_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
|
|
FROM transaction_tags tt
|
|
JOIN tags tg ON tg.id = tt.tag_id
|
|
WHERE tt.transaction_id = t.id
|
|
) txn_tags ON true
|
|
${where}
|
|
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
|
|
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
|
|
`;
|
|
params.push(limit, offset);
|
|
|
|
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[] }>(dataSql, params);
|
|
const data = raw.map((r) => ({
|
|
...r,
|
|
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
|
})) as TransactionRow[];
|
|
|
|
return { data, total, limit, offset };
|
|
}
|
|
|
|
export async function getTransactionById(id: number) {
|
|
const sql = `
|
|
SELECT t.*,
|
|
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
|
COALESCE(o.category_override, t.category) as effective_category,
|
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
|
s.bank_name, s.owner_id,
|
|
p.name as owner_name
|
|
FROM transactions t
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
JOIN statements s ON s.id = t.statement_id
|
|
LEFT JOIN participants p ON p.id = s.owner_id
|
|
WHERE t.id = $1
|
|
`;
|
|
const rows = await queryRaw<TransactionRow>(sql, [id]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
export async function getStatements(ownerId: number) {
|
|
const sql = `
|
|
SELECT s.*,
|
|
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count,
|
|
p.name as owner_name
|
|
FROM statements s
|
|
LEFT JOIN participants p ON p.id = s.owner_id
|
|
WHERE s.owner_id = $1
|
|
ORDER BY s.billing_end_date DESC NULLS LAST, s.created_at DESC
|
|
`;
|
|
return queryRaw<StatementRow>(sql, [ownerId]);
|
|
}
|
|
|
|
export async function getStatementById(id: number) {
|
|
const sql = `
|
|
SELECT s.*,
|
|
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count,
|
|
p.name as owner_name
|
|
FROM statements s
|
|
LEFT JOIN participants p ON p.id = s.owner_id
|
|
WHERE s.id = $1
|
|
`;
|
|
const rows = await queryRaw<StatementRow>(sql, [id]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
export async function getMerchantSuggestions(search: string) {
|
|
const sql = `
|
|
SELECT DISTINCT COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as merchant
|
|
FROM transactions t
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
WHERE COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) ILIKE $1
|
|
ORDER BY merchant
|
|
LIMIT 20
|
|
`;
|
|
return queryRaw<{ merchant: string }>(sql, [`%${search}%`]);
|
|
}
|
|
|
|
export async function getBankNames() {
|
|
const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`;
|
|
return queryRaw<{ bank_name: string }>(sql);
|
|
}
|
|
|
|
export interface ParticipantBalance {
|
|
id: number;
|
|
name: string;
|
|
total_owed: number;
|
|
unsettled_count: number;
|
|
}
|
|
|
|
export async function getParticipantBalances(ownerId: number) {
|
|
return queryRaw<ParticipantBalance>(`
|
|
SELECT p.id, p.name,
|
|
COALESCE(SUM(CASE WHEN ts.settled = false THEN t.amount * ts.share_percent / 100 ELSE 0 END), 0)::numeric(12,2) as total_owed,
|
|
COUNT(CASE WHEN ts.settled = false THEN 1 END)::int as unsettled_count
|
|
FROM participants p
|
|
LEFT JOIN transaction_splits ts ON ts.participant_id = p.id
|
|
LEFT JOIN transactions t ON t.id = ts.transaction_id
|
|
LEFT JOIN statements s ON s.id = t.statement_id
|
|
WHERE (s.owner_id = $1 OR s.id IS NULL)
|
|
GROUP BY p.id, p.name
|
|
ORDER BY p.name
|
|
`, [ownerId]);
|
|
}
|
|
|
|
export interface SharedTransactionRow extends TransactionRow {
|
|
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
|
}
|
|
|
|
export async function getTags() {
|
|
return queryRaw<TagRow & { transaction_count: number }>(`
|
|
SELECT tg.id, tg.name, tg.color,
|
|
COUNT(tt.transaction_id)::int as transaction_count
|
|
FROM tags tg
|
|
LEFT JOIN transaction_tags tt ON tt.tag_id = tg.id
|
|
GROUP BY tg.id
|
|
ORDER BY tg.name
|
|
`);
|
|
}
|
|
|
|
export async function getSharedTransactions(ownerId: number) {
|
|
const rows = await queryRaw<TransactionRow & { split_data: string }>(`
|
|
SELECT t.*,
|
|
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
|
COALESCE(o.category_override, t.category) as effective_category,
|
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
|
s.bank_name, s.owner_id,
|
|
p_owner.name as owner_name,
|
|
json_agg(json_build_object(
|
|
'split_id', ts.id,
|
|
'participant_id', ts.participant_id,
|
|
'name', p.name,
|
|
'share_percent', ts.share_percent,
|
|
'settled', ts.settled
|
|
) ORDER BY p.name) as split_data
|
|
FROM transactions t
|
|
JOIN transaction_splits ts ON ts.transaction_id = t.id
|
|
JOIN participants p ON p.id = ts.participant_id
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
JOIN statements s ON s.id = t.statement_id
|
|
LEFT JOIN participants p_owner ON p_owner.id = s.owner_id
|
|
WHERE s.owner_id = $1
|
|
AND EXISTS (
|
|
SELECT 1 FROM transaction_splits ts2
|
|
JOIN participants p2 ON p2.id = ts2.participant_id
|
|
WHERE ts2.transaction_id = t.id AND p2.name != 'Me'
|
|
)
|
|
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name
|
|
ORDER BY t.transaction_date DESC
|
|
`, [ownerId]);
|
|
|
|
return rows.map((r) => ({
|
|
...r,
|
|
splits: typeof r.split_data === "string" ? JSON.parse(r.split_data) : r.split_data,
|
|
}));
|
|
}
|