feat(trips): trip tracking with analytics, tag conversion, and transaction assignment

Adds trips table usage across API and UI: trip CRUD, per-trip analytics
(category/daily/merchant/tag/participant breakdowns), tag-to-trip
conversion, trip assignment via transaction overrides, and trip filter
in the transactions view. Recovered from working tree after local git
corruption; feature was already live via host-context Docker builds.
This commit is contained in:
2026-07-19 20:00:51 +10:00
parent b706973fb5
commit 48ec151c15
21 changed files with 1506 additions and 55 deletions
+234
View File
@@ -37,6 +37,10 @@ export interface TransactionRow {
tags: TagRow[];
// splits
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
// trip
trip_id: number | null;
trip_name: string | null;
trip_color: string | null;
}
export interface StatementRow {
@@ -83,6 +87,7 @@ interface TransactionFilters {
amount_min?: number;
amount_max?: number;
has_split?: string;
trip_id?: string;
}
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
@@ -154,6 +159,12 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
} else if (filters.has_split === "no") {
conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
}
if (filters.trip_id === "unassigned") {
conditions.push(`o.trip_id IS NULL`);
} else if (filters.trip_id) {
conditions.push(`o.trip_id = $${paramIdx++}`);
params.push(Number(filters.trip_id));
}
const where = `WHERE ${conditions.join(" AND ")}`;
@@ -181,6 +192,9 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
COALESCE(t.owner_id, s.owner_id) as owner_id,
p.name as owner_name,
COALESCE(src.created_at, t.created_at) as created_at,
o.trip_id,
tr.name as trip_name,
tr.color as trip_color,
txn_tags.tags,
txn_splits.splits
FROM transactions t
@@ -188,6 +202,7 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
LEFT JOIN trips tr ON tr.id = o.trip_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
@@ -604,3 +619,222 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
splits: typeof r.split_data === "string" ? JSON.parse(r.split_data) : r.split_data,
}));
}
// ─── Trips ───────────────────────────────────────────────────────────────────
export interface TripRow {
id: number;
owner_id: number;
name: string;
description: string | null;
start_date: string | null;
end_date: string | null;
color: string;
archived: boolean;
created_at: string;
total_spend: number;
transaction_count: number;
}
export interface TripAnalytics {
trip: TripRow;
total_spend: number;
transaction_count: number;
num_days: number;
daily_average: number;
category_breakdown: { category: string; amount: number; count: number }[];
daily_spend: { date: string; amount: number }[];
top_merchants: { merchant: string; amount: number; count: number }[];
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
participant_splits: { participant_id: number; name: string; owed: number; settled: number; unsettled: number }[];
}
export async function getTrips(ownerId: number): Promise<TripRow[]> {
return queryRaw<TripRow>(`
SELECT
t.*,
COALESCE(SUM(
CASE WHEN tx.transaction_type IN ('debit','fee','interest') THEN tx.amount ELSE 0 END
), 0)::float AS total_spend,
COUNT(o.transaction_id)::int AS transaction_count
FROM trips t
LEFT JOIN transaction_overrides o ON o.trip_id = t.id
LEFT JOIN transactions tx ON tx.id = o.transaction_id
WHERE t.owner_id = $1
GROUP BY t.id
ORDER BY t.created_at DESC
`, [ownerId]);
}
export async function getTripById(id: number, ownerId: number): Promise<TripRow | null> {
const rows = await queryRaw<TripRow>(`
SELECT
t.*,
COALESCE(SUM(
CASE WHEN tx.transaction_type IN ('debit','fee','interest') THEN tx.amount ELSE 0 END
), 0)::float AS total_spend,
COUNT(o.transaction_id)::int AS transaction_count
FROM trips t
LEFT JOIN transaction_overrides o ON o.trip_id = t.id
LEFT JOIN transactions tx ON tx.id = o.transaction_id
WHERE t.id = $1 AND t.owner_id = $2
GROUP BY t.id
`, [id, ownerId]);
return rows[0] ?? null;
}
export async function getTripAnalytics(tripId: number, ownerId: number): Promise<TripAnalytics> {
const trip = await getTripById(tripId, ownerId);
if (!trip) throw new Error("Trip not found");
const [categoryRows, dailyRows, merchantRows, tagRows, splitRows] = await Promise.all([
queryRaw<{ category: string; amount: number; count: number }>(`
SELECT
COALESCE(o.category_override, tx.category, 'other') AS category,
SUM(tx.amount)::float AS amount,
COUNT(*)::int AS count
FROM transaction_overrides o
JOIN transactions tx ON tx.id = o.transaction_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
GROUP BY 1
ORDER BY 2 DESC
`, [tripId]),
queryRaw<{ date: string; amount: number }>(`
SELECT
tx.transaction_date::text AS date,
SUM(tx.amount)::float AS amount
FROM transaction_overrides o
JOIN transactions tx ON tx.id = o.transaction_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
GROUP BY 1
ORDER BY 1
`, [tripId]),
queryRaw<{ merchant: string; amount: number; count: number }>(`
SELECT
COALESCE(o.merchant_normalized, tx.merchant_normalized, tx.merchant_name, tx.description) AS merchant,
SUM(tx.amount)::float AS amount,
COUNT(*)::int AS count
FROM transaction_overrides o
JOIN transactions tx ON tx.id = o.transaction_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
GROUP BY 1
ORDER BY 2 DESC
LIMIT 10
`, [tripId]),
queryRaw<{ tag_id: number; name: string; color: string; amount: number; count: number }>(`
SELECT
tg.id AS tag_id, tg.name, tg.color,
SUM(tx.amount)::float AS amount,
COUNT(DISTINCT tx.id)::int AS count
FROM transaction_overrides o
JOIN transactions tx ON tx.id = o.transaction_id
JOIN transaction_tags tt ON tt.transaction_id = tx.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
GROUP BY tg.id
ORDER BY 4 DESC
`, [tripId]),
queryRaw<{ participant_id: number; name: string; owed: number; settled: number; unsettled: number }>(`
SELECT
p.id AS participant_id,
p.name,
SUM(ts.share_percent / 100.0 * tx.amount)::float AS owed,
SUM(CASE WHEN ts.settled THEN ts.share_percent / 100.0 * tx.amount ELSE 0 END)::float AS settled,
SUM(CASE WHEN NOT ts.settled THEN ts.share_percent / 100.0 * tx.amount ELSE 0 END)::float AS unsettled
FROM transaction_overrides o
JOIN transactions tx ON tx.id = o.transaction_id
JOIN transaction_splits ts ON ts.transaction_id = tx.id
JOIN participants p ON p.id = ts.participant_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
GROUP BY p.id
ORDER BY 3 DESC
`, [tripId]),
]);
const num_days = (trip.start_date && trip.end_date)
? Math.max(1, Math.round((new Date(trip.end_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1)
: Math.max(dailyRows.length, 1);
return {
trip,
total_spend: trip.total_spend,
transaction_count: trip.transaction_count,
num_days,
daily_average: trip.total_spend / num_days,
category_breakdown: categoryRows,
daily_spend: dailyRows,
top_merchants: merchantRows,
tag_breakdown: tagRows,
participant_splits: splitRows,
};
}
export async function createTrip(
ownerId: number,
data: { name: string; description?: string | null; start_date?: string | null; end_date?: string | null; color?: string }
): Promise<TripRow> {
const rows = await queryRaw<TripRow>(`
INSERT INTO trips (owner_id, name, description, start_date, end_date, color)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *, 0::float AS total_spend, 0::int AS transaction_count
`, [ownerId, data.name, data.description ?? null, data.start_date ?? null, data.end_date ?? null, data.color ?? '#6366f1']);
return rows[0];
}
export async function updateTrip(
id: number,
ownerId: number,
data: Partial<{ name: string; description: string | null; start_date: string | null; end_date: string | null; color: string; archived: boolean }>
): Promise<TripRow | null> {
const setClauses: string[] = [];
const params: unknown[] = [];
let idx = 1;
if (data.name !== undefined) { setClauses.push(`name = $${idx++}`); params.push(data.name); }
if ('description' in data) { setClauses.push(`description = $${idx++}`); params.push(data.description ?? null); }
if ('start_date' in data) { setClauses.push(`start_date = $${idx++}`); params.push(data.start_date ?? null); }
if ('end_date' in data) { setClauses.push(`end_date = $${idx++}`); params.push(data.end_date ?? null); }
if (data.color !== undefined) { setClauses.push(`color = $${idx++}`); params.push(data.color); }
if (data.archived !== undefined) { setClauses.push(`archived = $${idx++}`); params.push(data.archived); }
if (!setClauses.length) return getTripById(id, ownerId);
params.push(id, ownerId);
const rows = await queryRaw<TripRow>(`
UPDATE trips SET ${setClauses.join(', ')}
WHERE id = $${idx++} AND owner_id = $${idx}
RETURNING *, 0::float AS total_spend, 0::int AS transaction_count
`, params);
return rows[0] ?? null;
}
export async function deleteTrip(id: number, ownerId: number): Promise<void> {
await queryRaw(`DELETE FROM trips WHERE id = $1 AND owner_id = $2`, [id, ownerId]);
}
export async function assignTransactionsToTrip(
tripId: number | null,
transactionIds: number[]
): Promise<void> {
if (!transactionIds.length) return;
await queryRaw(`
INSERT INTO transaction_overrides (transaction_id, trip_id)
SELECT unnest($1::int[]), $2
ON CONFLICT (transaction_id)
DO UPDATE SET trip_id = EXCLUDED.trip_id
`, [transactionIds, tripId]);
}
export async function getTagTransactionIds(tagId: number): Promise<number[]> {
const rows = await queryRaw<{ transaction_id: number }>(
`SELECT transaction_id FROM transaction_tags WHERE tag_id = $1`,
[tagId]
);
return rows.map((r) => r.transaction_id);
}