ci / lint-test (push) Successful in 48s
Statements 107, 142 and 143 bill overlapping periods on one ANZ account, so 31 transactions -- $42,040.68 -- are in the ledger twice. They are marked superseded, not deleted. Every child of transactions is ON DELETE CASCADE (splits, tags, overrides, expense_metadata, order_reviews), so deleting "the duplicate" destroys whatever curation sits on it, and which member of a pair holds that curation is an accident of import order: here 1 pair carries splits and 6 carry overrides, all on the surviving side, but nothing guarantees that. Superseding keeps the row, keeps its children, and makes a mistake one UPDATE to undo rather than a restore from backup. reconciled_with_id could not be reused. Its predicate is scoped to statement_id IS NULL on purpose -- a statement line pointing at something else is the survivor, not the duplicate -- and here both rows are statement lines. The exclusion goes into EXCLUDE_RECONCILED_SOURCE rather than into a new fragment, so every query already asking "count each purchase once" gets it without being edited. The trip cost queries did not use that fragment at all and now do; verified a no-op on current data (0 trip-tagged rows are either reconciled sources or duplicates), but they were one import away from double-counting. Most of the $42k is transfers and investments, which spend already excludes. The damage was elsewhere: duplicated rows in the list, and rules re-splitting a duplicate -- txn 3807 is one of these 31 and was a candidate for splitting earlier today. Balances are unchanged: no duplicate carried a split.
1172 lines
48 KiB
TypeScript
1172 lines
48 KiB
TypeScript
import { queryRaw } from "./db";
|
|
import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED, ACTIVE_OBLIGATION, STATEMENTS_JOIN, OWNER_SCOPE, NET_SPEND_ROWS, SPEND_SIGNED } from "./analytics-sql";
|
|
|
|
export interface RoutePointRow {
|
|
label: string;
|
|
time: string | null;
|
|
address: string;
|
|
}
|
|
|
|
export interface TagRow {
|
|
id: number;
|
|
name: string;
|
|
color: string;
|
|
}
|
|
|
|
export interface TransactionRow {
|
|
id: number;
|
|
statement_id: number | null;
|
|
transaction_date: string;
|
|
description: string;
|
|
amount: number;
|
|
amount_aud: number | null;
|
|
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;
|
|
// loan repayment split — set only when the lender itemises it (migration 0014)
|
|
principal_amount: number | null;
|
|
interest_amount: number | null;
|
|
// How it was paid (migration 0016). NULL = unknown, treated as reconcilable.
|
|
// 'cash' and 'credits' are excluded from reconciliation — see needsCardMatch().
|
|
payment_method: string | null;
|
|
/** Uber pick-up/drop-off, when this row came from an order receipt. */
|
|
order_route: RoutePointRow[] | null;
|
|
order_platform: "doordash" | "ubereats" | "uber" | null;
|
|
// override fields
|
|
category_override: string | null;
|
|
merchant_override: string | null;
|
|
notes: string | null;
|
|
my_share_percent: number | null;
|
|
effective_category: string;
|
|
effective_merchant: string;
|
|
// My share of this transaction, resolved server-side so the UI matches analytics.
|
|
my_share_pct: number;
|
|
my_amount: number;
|
|
// statement context (null for manual transactions)
|
|
bank_name: string;
|
|
// The currency `amount` is denominated in; `amount_aud` is the converted
|
|
// figure where one exists. Usually the statement's currency, but an
|
|
// order-receipt row has no statement and carries its own — see
|
|
// NATIVE_CURRENCY. Not simply 'AUD' for every statement-less row.
|
|
currency: string;
|
|
/** True when `amount` is non-AUD and no converted figure exists. */
|
|
amount_unconverted: boolean;
|
|
owner_id: number;
|
|
owner_name: string;
|
|
// tags
|
|
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 {
|
|
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;
|
|
// Loan statements only (see migration 0014)
|
|
interest_rate: number | null;
|
|
scheduled_repayment: number | null;
|
|
repayment_frequency: string | null;
|
|
redraw_available: number | null;
|
|
loan_term_months: number | null;
|
|
tier_used: string | null;
|
|
owner_id: number;
|
|
owner_name: string;
|
|
created_at: string;
|
|
transaction_count: number;
|
|
// Balance assertion (see BALANCE_DELTA). Null when the statement has no
|
|
// opening/closing balance to check against.
|
|
expected_closing: number | null;
|
|
balance_diff: number | null;
|
|
/**
|
|
* Other statements for this account billing the same days — see
|
|
* STATEMENT_OVERLAPS. Non-empty means some of these transactions are almost
|
|
* certainly imported twice. Empty array, never null.
|
|
*/
|
|
overlaps: { id: number; days: number }[];
|
|
}
|
|
|
|
interface TransactionFilters {
|
|
from?: string;
|
|
to?: string;
|
|
categories?: string[];
|
|
bank_names?: string[];
|
|
tag_ids?: string[];
|
|
transaction_types?: string[];
|
|
search?: string;
|
|
statement_id?: string;
|
|
sort_by?: string;
|
|
sort_dir?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
amount_min?: number;
|
|
amount_max?: number;
|
|
has_split?: string;
|
|
trip_id?: string;
|
|
}
|
|
|
|
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
|
const conditions: string[] = [
|
|
`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`,
|
|
EXCLUDE_RECONCILED_SOURCE,
|
|
];
|
|
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.categories?.length) {
|
|
conditions.push(`COALESCE(o.category_override, t.category) = ANY($${paramIdx++}::text[])`);
|
|
params.push(filters.categories);
|
|
}
|
|
if (filters.bank_names?.length) {
|
|
// "Manual" and "Gift Card" are not banks — they are the two shapes a
|
|
// statement-less row can take, and bankLabel() decides which. The filter
|
|
// has to split on the same condition or the chip selects nothing.
|
|
const hasManual = filters.bank_names.includes("Manual");
|
|
const hasGiftCard = filters.bank_names.includes("Gift Card");
|
|
const bankList = filters.bank_names.filter((b) => b !== "Manual" && b !== "Gift Card");
|
|
const alternatives: string[] = [];
|
|
if (hasManual) {
|
|
alternatives.push(`(t.statement_id IS NULL AND t.payment_method IS DISTINCT FROM 'credits')`);
|
|
}
|
|
if (hasGiftCard) {
|
|
alternatives.push(`(t.statement_id IS NULL AND t.payment_method = 'credits')`);
|
|
}
|
|
if (bankList.length > 0) {
|
|
alternatives.push(`s.bank_name = ANY($${paramIdx++}::text[])`);
|
|
params.push(bankList);
|
|
}
|
|
conditions.push(`(${alternatives.join(" OR ")})`);
|
|
}
|
|
if (filters.tag_ids?.length) {
|
|
const noTags = filters.tag_ids.includes("untagged");
|
|
const realTagIds = filters.tag_ids.filter((id) => id !== "untagged").map(Number);
|
|
if (noTags) {
|
|
conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id)`);
|
|
} else if (realTagIds.length > 0) {
|
|
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = ANY($${paramIdx++}::int[]))`);
|
|
params.push(realTagIds);
|
|
}
|
|
}
|
|
if (filters.transaction_types?.length) {
|
|
conditions.push(`t.transaction_type = ANY($${paramIdx++}::text[])`);
|
|
params.push(filters.transaction_types);
|
|
}
|
|
if (filters.search) {
|
|
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx} OR COALESCE(o.merchant_normalized, t.merchant_normalized) 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.amount_min !== undefined) {
|
|
conditions.push(`t.amount >= $${paramIdx++}`);
|
|
params.push(filters.amount_min);
|
|
}
|
|
if (filters.amount_max !== undefined) {
|
|
conditions.push(`t.amount <= $${paramIdx++}`);
|
|
params.push(filters.amount_max);
|
|
}
|
|
if (filters.has_split === "yes") {
|
|
conditions.push(`EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
|
|
} 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 ")}`;
|
|
|
|
const sortCol = filters.sort_by === "amount" ? "t.amount" : filters.sort_by === "created_at" ? "t.created_at" : "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
|
|
LEFT 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, o.my_share_percent,
|
|
COALESCE(o.category_override, t.category) as effective_category,
|
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
|
${bankLabel()} as bank_name,
|
|
${NATIVE_CURRENCY} as currency,
|
|
${AMOUNT_UNCONVERTED} as amount_unconverted,
|
|
-- My share, resolved the same way analytics does it (see myShare in
|
|
-- analytics-sql.ts): explicit split row, then override, then whatever is
|
|
-- left after everyone else. Computed here so the UI cannot drift from
|
|
-- the totals it is drilling into.
|
|
COALESCE(
|
|
(SELECT x.share_percent FROM transaction_splits x
|
|
WHERE x.transaction_id = t.id AND x.participant_id = $1),
|
|
o.my_share_percent,
|
|
100 - COALESCE((SELECT SUM(x.share_percent) FROM transaction_splits x
|
|
WHERE x.transaction_id = t.id AND x.participant_id <> $1), 0)
|
|
)::numeric(5,2) as my_share_pct,
|
|
(COALESCE(t.amount_aud, t.amount) * COALESCE(
|
|
(SELECT x.share_percent FROM transaction_splits x
|
|
WHERE x.transaction_id = t.id AND x.participant_id = $1),
|
|
o.my_share_percent,
|
|
100 - COALESCE((SELECT SUM(x.share_percent) FROM transaction_splits x
|
|
WHERE x.transaction_id = t.id AND x.participant_id <> $1), 0)
|
|
) / 100)::numeric(12,2) as my_amount,
|
|
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,
|
|
order_ctx.route as order_route,
|
|
order_ctx.platform as order_platform
|
|
FROM transactions t
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
LEFT JOIN statements s ON s.id = t.statement_id
|
|
-- Order provenance, for the sub-line under the description. Five rows all
|
|
-- reading "Order - Uber Trip" are indistinguishable; where they went is the
|
|
-- only thing that tells them apart, and it was already stored.
|
|
-- Both directions, because a card-settled order has no transaction of its
|
|
-- own and points at the statement line instead (I5).
|
|
LEFT JOIN LATERAL (
|
|
SELECT em.route, em.platform
|
|
FROM expense_metadata em
|
|
WHERE em.transaction_id = t.id OR em.matched_transaction_id = t.id
|
|
LIMIT 1
|
|
) order_ctx ON true
|
|
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
|
|
JOIN tags tg ON tg.id = tt.tag_id
|
|
WHERE tt.transaction_id = t.id
|
|
) txn_tags ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT COALESCE(json_agg(json_build_object('participant_id', ts.participant_id, 'name', sp.name, 'share_percent', ts.share_percent, 'settled', ts.settled) ORDER BY sp.name), '[]'::json) as splits
|
|
FROM transaction_splits ts
|
|
JOIN participants sp ON sp.id = ts.participant_id
|
|
WHERE ts.transaction_id = t.id
|
|
) txn_splits 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[]; splits: string | TransactionRow["splits"] }>(dataSql, params);
|
|
const data = raw.map((r) => ({
|
|
...r,
|
|
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
|
splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []),
|
|
})) as TransactionRow[];
|
|
|
|
return { data, total, limit, offset };
|
|
}
|
|
|
|
// A user may act on a transaction they own (directly or via the parent
|
|
// statement) or one they participate in via a split.
|
|
export async function canAccessTransactions(ownerId: number, transactionIds: number[]): Promise<boolean> {
|
|
if (!transactionIds.length) return false;
|
|
const rows = await queryRaw<{ n: number }>(
|
|
`SELECT COUNT(*)::int AS n
|
|
FROM transactions t
|
|
LEFT JOIN statements s ON s.id = t.statement_id
|
|
WHERE t.id = ANY($2::int[])
|
|
AND (COALESCE(t.owner_id, s.owner_id) = $1
|
|
OR EXISTS (SELECT 1 FROM transaction_splits ts WHERE ts.transaction_id = t.id AND ts.participant_id = $1))`,
|
|
[ownerId, transactionIds]
|
|
);
|
|
return rows[0]?.n === transactionIds.length;
|
|
}
|
|
|
|
export async function getTransactionById(id: number) {
|
|
const sql = `
|
|
SELECT t.*,
|
|
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
|
|
COALESCE(o.category_override, t.category) as effective_category,
|
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
|
${bankLabel()} as bank_name,
|
|
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
|
p.name as owner_name
|
|
FROM transactions t
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
LEFT JOIN statements s ON s.id = t.statement_id
|
|
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
|
|
WHERE t.id = $1
|
|
`;
|
|
const rows = await queryRaw<TransactionRow>(sql, [id]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Does opening_balance + the period's transactions equal closing_balance?
|
|
*
|
|
* The single cheapest check on extraction quality: it catches missed rows,
|
|
* duplicates, sign errors and transactions filed against the wrong statement,
|
|
* none of which are visible by eye. Borrowed from double-entry accounting,
|
|
* where it is called a balance assertion.
|
|
*
|
|
* Sign depends on what the balance means. On a liability (credit card, loan)
|
|
* the balance is what you OWE, so spending increases it and payments reduce it.
|
|
* On an asset (transaction, savings, offset) the balance is what you HOLD, so
|
|
* the signs invert.
|
|
*/
|
|
export const BALANCE_DELTA = `SUM(CASE
|
|
WHEN s.statement_type IN ('credit_card', 'loan')
|
|
THEN CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END
|
|
ELSE CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN -t.amount ELSE t.amount END
|
|
END)`;
|
|
|
|
/**
|
|
* Other statements for the same account whose billing period overlaps this one.
|
|
*
|
|
* An account cannot be billed twice for the same day, so an overlap means the
|
|
* same transactions were imported twice. This is not hypothetical: ANZ
|
|
* statements 107 and 143 overlap by 118 days and put ~$42,000 of duplicate rows
|
|
* in the ledger, which silently inflated spend and dragged the CSV split match
|
|
* rate down to 46.5%.
|
|
*
|
|
* Two details are what make it actually catch that case:
|
|
*
|
|
* - The account number is compared with non-digits stripped. The duplicate got
|
|
* in precisely because the existing duplicate key compared raw text, and ANZ
|
|
* wrote the same account as `408556264` on one statement and `4085-56264` on
|
|
* the other.
|
|
* - The range is half-open `[)`. These statements are issued back-to-back with
|
|
* one period's end date equal to the next one's start, so inclusive bounds
|
|
* flag every consecutive pair — 5 hits of which 3 were false. Half-open
|
|
* leaves exactly the 2 real ones.
|
|
*
|
|
* NULL bounds are excluded rather than passed to `daterange`, where NULL means
|
|
* unbounded and would make an undated statement overlap the entire history.
|
|
*/
|
|
const STATEMENT_OVERLAPS = `
|
|
SELECT COALESCE(json_agg(json_build_object(
|
|
'id', o.id,
|
|
'days', (LEAST(s.billing_end_date, o.billing_end_date)
|
|
- GREATEST(s.billing_start_date, o.billing_start_date))
|
|
) ORDER BY o.id), '[]'::json) AS overlaps
|
|
FROM statements o
|
|
WHERE o.id <> s.id
|
|
AND o.owner_id = s.owner_id
|
|
AND regexp_replace(o.account_number, '\\D', '', 'g')
|
|
= regexp_replace(s.account_number, '\\D', '', 'g')
|
|
AND o.billing_start_date IS NOT NULL AND o.billing_end_date IS NOT NULL
|
|
AND s.billing_start_date IS NOT NULL AND s.billing_end_date IS NOT NULL
|
|
AND daterange(s.billing_start_date, s.billing_end_date, '[)')
|
|
&& daterange(o.billing_start_date, o.billing_end_date, '[)')`;
|
|
|
|
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,
|
|
recon.expected_closing,
|
|
recon.balance_diff,
|
|
ov.overlaps
|
|
FROM statements s
|
|
LEFT JOIN participants p ON p.id = s.owner_id
|
|
LEFT JOIN LATERAL (${STATEMENT_OVERLAPS}) ov ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
(s.opening_balance + ${BALANCE_DELTA})::numeric(12,2) as expected_closing,
|
|
(s.opening_balance + ${BALANCE_DELTA} - s.closing_balance)::numeric(12,2) as balance_diff
|
|
FROM transactions t
|
|
WHERE t.statement_id = s.id
|
|
AND s.opening_balance IS NOT NULL
|
|
AND s.closing_balance IS NOT NULL
|
|
) recon ON true
|
|
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 [bankRows, statementless] = await Promise.all([
|
|
queryRaw<{ bank_name: string }>(`SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`),
|
|
queryRaw<{ label: string }>(
|
|
`SELECT DISTINCT ${bankLabel("t", "s")} as label
|
|
FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
|
|
WHERE t.statement_id IS NULL`
|
|
),
|
|
]);
|
|
const banks = bankRows.map((r) => r.bank_name);
|
|
// Order matters for the filter chips: real banks first, then the
|
|
// statement-less kinds, in a stable order rather than whatever the DB returns.
|
|
for (const label of ["Manual", "Gift Card"]) {
|
|
if (statementless.some((r) => r.label === label)) banks.push(label);
|
|
}
|
|
return banks;
|
|
}
|
|
|
|
export interface ParticipantBalance {
|
|
id: number;
|
|
name: string;
|
|
total_owed: number;
|
|
unsettled_count: number;
|
|
/** Splits counted at a non-AUD figure because no converted amount exists. */
|
|
unconverted_count: number;
|
|
}
|
|
|
|
export async function getParticipantBalances(ownerId: number, tagIds?: number[]) {
|
|
const params: unknown[] = [ownerId];
|
|
let tagFilter = "";
|
|
if (tagIds?.length) {
|
|
params.push(tagIds);
|
|
tagFilter = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`;
|
|
}
|
|
|
|
// Payments settle the total relationship between two people, not a specific tag.
|
|
// Only subtract payments when viewing the unfiltered total; with a tag filter
|
|
// active, show the raw split amount for that tag context only.
|
|
//
|
|
// That asymmetry is a symptom, not a design: a tag is a view and has no
|
|
// payments, so a tag-filtered balance had nothing honest to subtract. A trip
|
|
// does have payments (`split_payments.trip_id`, migration 0022), which is why
|
|
// the per-trip figure in getTripAnalytics can be netted and this one cannot.
|
|
// The fix for the tag case is to stop showing a balance there, not to invent
|
|
// one — see docs/shared-expenses-design.md.
|
|
const paymentsJoin = tagIds?.length ? "" : `
|
|
LEFT JOIN (
|
|
SELECT
|
|
CASE WHEN sp.from_participant_id != $1 THEN sp.from_participant_id ELSE sp.to_participant_id END AS pid,
|
|
SUM(CASE WHEN sp.to_participant_id = $1 THEN sp.amount ELSE -sp.amount END) AS net_paid
|
|
FROM split_payments sp
|
|
WHERE sp.from_participant_id = $1 OR sp.to_participant_id = $1
|
|
GROUP BY pid
|
|
) payments ON payments.pid = p.id`;
|
|
const paymentsSelect = tagIds?.length ? "" : "- COALESCE(payments.net_paid, 0)::numeric(12,2)";
|
|
const paymentsGroup = tagIds?.length ? "" : ", payments.net_paid";
|
|
|
|
return queryRaw<ParticipantBalance>(`
|
|
SELECT p.id, p.name,
|
|
COALESCE(SUM(splits.signed_amount), 0)::numeric(12,2)
|
|
${paymentsSelect} AS total_owed,
|
|
COALESCE(SUM(splits.split_count), 0)::int AS unsettled_count,
|
|
-- Splits whose AUD value is unknown. They are still summed above (as
|
|
-- their native figure), so a non-zero count means this balance is
|
|
-- approximate and the UI has to say so.
|
|
COALESCE(SUM(splits.unconverted_count), 0)::int AS unconverted_count
|
|
FROM participants p
|
|
|
|
LEFT JOIN (
|
|
-- They owe me: their splits on transactions I own
|
|
-- Settle in AUD: on a foreign-currency row the amount column is in its own
|
|
-- currency, so splitting on it nets a USD figure against AUD ones.
|
|
SELECT ts.participant_id AS pid,
|
|
(CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100 AS signed_amount,
|
|
1 AS split_count,
|
|
(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted_count
|
|
FROM transaction_splits ts
|
|
JOIN transactions t ON t.id = ts.transaction_id
|
|
LEFT JOIN statements s ON s.id = t.statement_id
|
|
WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1
|
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
|
AND ${ACTIVE_OBLIGATION}
|
|
${tagFilter}
|
|
|
|
UNION ALL
|
|
|
|
-- I owe them: my splits on transactions they own
|
|
SELECT COALESCE(t.owner_id, s.owner_id) AS pid,
|
|
-((CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100) AS signed_amount,
|
|
0 AS split_count,
|
|
(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted_count
|
|
FROM transaction_splits ts
|
|
JOIN transactions t ON t.id = ts.transaction_id
|
|
LEFT JOIN statements s ON s.id = t.statement_id
|
|
WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
|
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
|
AND ${ACTIVE_OBLIGATION}
|
|
${tagFilter}
|
|
) splits ON splits.pid = p.id
|
|
${paymentsJoin}
|
|
|
|
WHERE p.id != $1
|
|
GROUP BY p.id, p.name ${paymentsGroup}
|
|
ORDER BY p.name
|
|
`, params);
|
|
}
|
|
|
|
export interface SharedTransactionRow extends TransactionRow {
|
|
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
|
}
|
|
|
|
export async function ensureTag(name: string, color: string): Promise<number> {
|
|
const rows = await queryRaw<{ id: number }>(
|
|
`INSERT INTO tags (name, color) VALUES ($1, $2)
|
|
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
|
|
RETURNING id`,
|
|
[name, color]
|
|
);
|
|
return rows[0].id;
|
|
}
|
|
|
|
export async function batchInsertCSVTransactions(
|
|
ownerId: number,
|
|
rows: {
|
|
date: string;
|
|
description: string;
|
|
amount: number;
|
|
transaction_type: string;
|
|
merchant_name?: string;
|
|
foreign_currency_amount?: number;
|
|
foreign_currency_code?: string;
|
|
category?: string;
|
|
}[],
|
|
tagId: number
|
|
): Promise<number> {
|
|
if (rows.length === 0) return 0;
|
|
|
|
const baseRows = await queryRaw<{ base: number }>(
|
|
`SELECT COALESCE(MAX(row_index), -1) as base FROM transactions WHERE owner_id = $1 AND statement_id IS NULL`,
|
|
[ownerId]
|
|
);
|
|
const base = Number(baseRows[0].base);
|
|
|
|
const valueClauses: string[] = [];
|
|
const params: unknown[] = [ownerId];
|
|
let p = 2;
|
|
rows.forEach((r, i) => {
|
|
valueClauses.push(`(NULL, $1, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, ${base + 1 + i})`);
|
|
params.push(r.date, r.description, r.amount, r.transaction_type, r.merchant_name ?? null, r.foreign_currency_amount ?? null, r.foreign_currency_code ?? null);
|
|
});
|
|
|
|
const txIds = await queryRaw<{ id: number }>(
|
|
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_name, foreign_currency_amount, foreign_currency_code, row_index)
|
|
VALUES ${valueClauses.join(", ")}
|
|
RETURNING id`,
|
|
params
|
|
);
|
|
|
|
if (txIds.length > 0) {
|
|
const tagValueClauses = txIds.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`);
|
|
const tagParams: unknown[] = txIds.flatMap((r) => [r.id, tagId]);
|
|
await queryRaw(
|
|
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ${tagValueClauses.join(", ")} ON CONFLICT DO NOTHING`,
|
|
tagParams
|
|
);
|
|
}
|
|
|
|
return txIds.length;
|
|
}
|
|
|
|
/**
|
|
* Excludes cash from reconciliation.
|
|
*
|
|
* Cash never appears on a statement, so a cash transaction would sit in the
|
|
* queue forever being offered matches within 3 days and 1% on amount. Accepting
|
|
* one is silently destructive: reconciled manual rows are filtered out of every
|
|
* query, so the cash spend vanishes while the card transaction it matched
|
|
* claims to be that same spend.
|
|
*
|
|
* Only cash is excluded. Bank transfers do appear on a statement now that
|
|
* transaction accounts are imported, and NULL means unknown — both stay
|
|
* candidates, which preserves the behaviour of every pre-existing row.
|
|
*/
|
|
/**
|
|
* Payment methods that can still be matched against a card statement line.
|
|
*
|
|
* Cash never appears on one. Neither does a credits-funded delivery order: the
|
|
* gift card already paid it, so there is no card leg coming, ever. Leaving
|
|
* those in the queue meant 81 orders sat in "pending reconciliation" waiting
|
|
* for a match that could not exist (user, 2026-07-27).
|
|
*/
|
|
export const needsCardMatch = (alias = "t") =>
|
|
`(${alias}.payment_method IS NULL OR ${alias}.payment_method NOT IN ('cash', 'credits'))`;
|
|
|
|
/**
|
|
* Bank label for a transaction. A row with no statement was not imported from
|
|
* one, and the label has to say *why*: "Manual" reads as "hand-entered, still
|
|
* awaiting a card line", which is wrong for a gift-card order — nothing is
|
|
* awaited. `s` must be the statements alias in scope.
|
|
*/
|
|
export const bankLabel = (t = "t", s = "s") =>
|
|
`COALESCE(${s}.bank_name, CASE WHEN ${t}.payment_method = 'credits' THEN 'Gift Card' ELSE 'Manual' END)`;
|
|
|
|
export interface PotentialMatch {
|
|
id: number;
|
|
transaction_date: string;
|
|
description: string;
|
|
amount: number;
|
|
transaction_type: string;
|
|
effective_merchant: string;
|
|
effective_category: string;
|
|
bank_name: string;
|
|
billing_end_date: string | null;
|
|
}
|
|
|
|
export interface ManualTxWithMatches extends TransactionRow {
|
|
matches: PotentialMatch[];
|
|
}
|
|
|
|
export async function getPendingReconciliations(ownerId: number): Promise<ManualTxWithMatches[]> {
|
|
// Fetch all unreconciled manual transactions
|
|
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[]; splits: string | TransactionRow["splits"] }>(
|
|
`SELECT t.*,
|
|
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
|
|
COALESCE(o.category_override, t.category) as effective_category,
|
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
|
'Manual' as bank_name,
|
|
t.owner_id,
|
|
p.name as owner_name,
|
|
txn_tags.tags,
|
|
txn_splits.splits
|
|
FROM transactions t
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
LEFT JOIN participants p ON p.id = t.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
|
|
LEFT JOIN LATERAL (
|
|
SELECT COALESCE(json_agg(json_build_object('participant_id', ts.participant_id, 'name', sp.name, 'share_percent', ts.share_percent, 'settled', ts.settled) ORDER BY sp.name), '[]'::json) as splits
|
|
FROM transaction_splits ts JOIN participants sp ON sp.id = ts.participant_id
|
|
WHERE ts.transaction_id = t.id
|
|
) txn_splits ON true
|
|
WHERE t.statement_id IS NULL AND t.owner_id = $1 AND t.reconciled_with_id IS NULL
|
|
AND ${needsCardMatch("t")}
|
|
ORDER BY t.transaction_date DESC, t.row_index ASC`,
|
|
[ownerId]
|
|
);
|
|
|
|
const manualTxs = raw.map((r) => ({
|
|
...r,
|
|
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
|
splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []),
|
|
})) as TransactionRow[];
|
|
|
|
if (manualTxs.length === 0) return [];
|
|
|
|
// Fetch all potential matches in one query using window function
|
|
const matchRows = await queryRaw<PotentialMatch & { manual_id: number; rn: number }>(
|
|
`SELECT manual_id, id, transaction_date, description, amount, transaction_type,
|
|
effective_merchant, effective_category, bank_name, billing_end_date
|
|
FROM (
|
|
SELECT
|
|
m.id AS manual_id,
|
|
t.id,
|
|
t.transaction_date,
|
|
t.description,
|
|
t.amount,
|
|
t.transaction_type,
|
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, '') AS effective_merchant,
|
|
COALESCE(o.category_override, t.category, '') AS effective_category,
|
|
s.bank_name,
|
|
s.billing_end_date,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY m.id
|
|
ORDER BY ABS(t.amount - m.amount), ABS(t.transaction_date - m.transaction_date)
|
|
) AS rn
|
|
FROM transactions m
|
|
JOIN transactions t ON t.statement_id IS NOT NULL
|
|
AND t.transaction_date BETWEEN m.transaction_date - 3 AND m.transaction_date + 3
|
|
AND t.amount BETWEEN m.amount * 0.99 AND m.amount * 1.01
|
|
JOIN statements s ON s.id = t.statement_id
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
WHERE m.statement_id IS NULL
|
|
AND m.owner_id = $1
|
|
AND m.reconciled_with_id IS NULL
|
|
AND ${needsCardMatch("m")}
|
|
AND COALESCE(t.owner_id, s.owner_id) = $1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM transactions mt WHERE mt.reconciled_with_id = t.id
|
|
)
|
|
) sq
|
|
WHERE rn <= 5
|
|
ORDER BY manual_id, rn`,
|
|
[ownerId]
|
|
);
|
|
|
|
// Group matches by manual_id
|
|
const matchesByManualId = new Map<number, PotentialMatch[]>();
|
|
for (const row of matchRows) {
|
|
const list = matchesByManualId.get(row.manual_id) ?? [];
|
|
list.push({
|
|
id: row.id,
|
|
transaction_date: row.transaction_date,
|
|
description: row.description,
|
|
amount: row.amount,
|
|
transaction_type: row.transaction_type,
|
|
effective_merchant: row.effective_merchant,
|
|
effective_category: row.effective_category,
|
|
bank_name: row.bank_name,
|
|
billing_end_date: row.billing_end_date,
|
|
});
|
|
matchesByManualId.set(row.manual_id, list);
|
|
}
|
|
|
|
return manualTxs.map((tx) => ({
|
|
...tx,
|
|
matches: matchesByManualId.get(tx.id) ?? [],
|
|
}));
|
|
}
|
|
|
|
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, tagIds?: number[], noTags?: boolean, participantId?: number) {
|
|
const params: unknown[] = [ownerId];
|
|
let tagClause = "";
|
|
if (noTags) {
|
|
tagClause = `AND NOT EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id)`;
|
|
} else if (tagIds?.length) {
|
|
params.push(tagIds);
|
|
tagClause = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`;
|
|
}
|
|
|
|
let participantClause = "";
|
|
if (participantId) {
|
|
params.push(participantId);
|
|
participantClause = `AND EXISTS (SELECT 1 FROM transaction_splits ts_p WHERE ts_p.transaction_id = t.id AND ts_p.participant_id = $${params.length})`;
|
|
}
|
|
|
|
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,
|
|
${bankLabel()} as bank_name,
|
|
-- The table renders t.amount, which is not always AUD. Without these the
|
|
-- rows visibly disagreed with the participant balances, which do convert.
|
|
${NATIVE_CURRENCY} as currency,
|
|
${AMOUNT_UNCONVERTED} as amount_unconverted,
|
|
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
|
p_owner.name as owner_name,
|
|
COALESCE(src.created_at, t.created_at) as created_at,
|
|
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
|
|
LEFT JOIN statements s ON s.id = t.statement_id
|
|
LEFT JOIN participants p_owner ON p_owner.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
|
|
WHERE (
|
|
(
|
|
COALESCE(t.owner_id, s.owner_id) = $1
|
|
AND EXISTS (SELECT 1 FROM transaction_splits ts2 WHERE ts2.transaction_id = t.id AND ts2.participant_id != $1)
|
|
) OR (
|
|
COALESCE(t.owner_id, s.owner_id) != $1
|
|
AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1)
|
|
)
|
|
)
|
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
|
${tagClause}
|
|
${participantClause}
|
|
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.currency, s.owner_id, p_owner.name, src.created_at
|
|
ORDER BY t.transaction_date DESC
|
|
`, params);
|
|
|
|
return rows.map((r) => ({
|
|
...r,
|
|
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;
|
|
/** Their share of this trip, net of payments scoped to it. */
|
|
owed: number;
|
|
/** Splits counted at a non-AUD figure because no converted amount exists. */
|
|
unconverted_count: number;
|
|
}[];
|
|
}
|
|
|
|
// `total_spend` is the headline figure on the trips list and the trip header,
|
|
// and it nets refunds for the same reason getTripAnalytics does — see the note
|
|
// there. Trips are aliased `tr` so that `t` can be `transactions`, which is the
|
|
// alias the shared fragments assume.
|
|
const TRIP_TOTAL_SPEND = `COALESCE(SUM(
|
|
CASE WHEN ${NET_SPEND_ROWS}
|
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
|
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
|
|
THEN ${SPEND_SIGNED} ELSE 0 END
|
|
), 0)::float AS total_spend`;
|
|
|
|
export async function getTrips(ownerId: number): Promise<TripRow[]> {
|
|
return queryRaw<TripRow>(`
|
|
SELECT
|
|
tr.*,
|
|
${TRIP_TOTAL_SPEND},
|
|
COUNT(o.transaction_id)::int AS transaction_count
|
|
FROM trips tr
|
|
LEFT JOIN transaction_overrides o ON o.trip_id = tr.id
|
|
LEFT JOIN transactions t ON t.id = o.transaction_id
|
|
WHERE tr.owner_id = $1
|
|
GROUP BY tr.id
|
|
ORDER BY tr.created_at DESC
|
|
`, [ownerId]);
|
|
}
|
|
|
|
export async function getTripById(id: number, ownerId: number): Promise<TripRow | null> {
|
|
const rows = await queryRaw<TripRow>(`
|
|
SELECT
|
|
tr.*,
|
|
${TRIP_TOTAL_SPEND},
|
|
COUNT(o.transaction_id)::int AS transaction_count
|
|
FROM trips tr
|
|
LEFT JOIN transaction_overrides o ON o.trip_id = tr.id
|
|
LEFT JOIN transactions t ON t.id = o.transaction_id
|
|
WHERE tr.id = $1 AND tr.owner_id = $2
|
|
GROUP BY tr.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");
|
|
|
|
// What the trip cost, with refunds subtracted.
|
|
//
|
|
// These four queries filtered on `transaction_type IN ('debit','fee','interest')`,
|
|
// which drops every refund and credit — so money that came back was still
|
|
// counted as trip spend. A partly-refunded booking read at its full price and
|
|
// a fully-refunded one read as pure cost.
|
|
//
|
|
// NET_SPEND_ROWS admits the refunds and SPEND_SIGNED carries their direction,
|
|
// the same pair the general analytics adopted after a refunded Expedia
|
|
// purchase read as $2,888.92 of spend. `transactions` is aliased `t` because
|
|
// the fragments assume that alias.
|
|
//
|
|
// A cancelled booking is a different case and is NOT handled here: both its
|
|
// legs are untagged from the trip by hand, because a trip the booking was
|
|
// cancelled from never incurred that cost at all. This nets the partial
|
|
// refunds — a price adjustment on a booking that did happen.
|
|
//
|
|
// COUNT(*) deliberately still counts refund rows: a refund is a transaction
|
|
// that occurred on the trip, even though it subtracts from the total.
|
|
const [categoryRows, dailyRows, merchantRows, tagRows, splitRows] = await Promise.all([
|
|
queryRaw<{ category: string; amount: number; count: number }>(`
|
|
SELECT
|
|
COALESCE(o.category_override, t.category, 'other') AS category,
|
|
SUM(${SPEND_SIGNED})::float AS amount,
|
|
COUNT(*)::int AS count
|
|
FROM transaction_overrides o
|
|
JOIN transactions t ON t.id = o.transaction_id
|
|
WHERE o.trip_id = $1
|
|
AND ${NET_SPEND_ROWS}
|
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
|
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
|
|
GROUP BY 1
|
|
ORDER BY 2 DESC
|
|
`, [tripId]),
|
|
|
|
queryRaw<{ date: string; amount: number }>(`
|
|
SELECT
|
|
t.transaction_date::text AS date,
|
|
SUM(${SPEND_SIGNED})::float AS amount
|
|
FROM transaction_overrides o
|
|
JOIN transactions t ON t.id = o.transaction_id
|
|
WHERE o.trip_id = $1
|
|
AND ${NET_SPEND_ROWS}
|
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
|
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
|
|
GROUP BY 1
|
|
ORDER BY 1
|
|
`, [tripId]),
|
|
|
|
queryRaw<{ merchant: string; amount: number; count: number }>(`
|
|
SELECT
|
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) AS merchant,
|
|
SUM(${SPEND_SIGNED})::float AS amount,
|
|
COUNT(*)::int AS count
|
|
FROM transaction_overrides o
|
|
JOIN transactions t ON t.id = o.transaction_id
|
|
WHERE o.trip_id = $1
|
|
AND ${NET_SPEND_ROWS}
|
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
|
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
|
|
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(${SPEND_SIGNED})::float AS amount,
|
|
COUNT(DISTINCT t.id)::int AS count
|
|
FROM transaction_overrides o
|
|
JOIN transactions t ON t.id = o.transaction_id
|
|
JOIN transaction_tags tt ON tt.transaction_id = t.id
|
|
JOIN tags tg ON tg.id = tt.tag_id
|
|
WHERE o.trip_id = $1
|
|
AND ${NET_SPEND_ROWS}
|
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
|
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
|
|
GROUP BY tg.id
|
|
ORDER BY 4 DESC
|
|
`, [tripId]),
|
|
|
|
// Owed per participant for THIS trip, net of payments made against it.
|
|
//
|
|
// This was gross splits, with a comment explaining why it could not be
|
|
// anything better: `split_payments` carried no trip attribution, so a
|
|
// payment could not be assigned to a trip and every trip reported 100%
|
|
// unsettled including trips paid in full. `split_payments.trip_id`
|
|
// (migration 0022) closes that, so the figure is now real.
|
|
//
|
|
// Three exclusions, all load-bearing:
|
|
// - ACTIVE_OBLIGATION drops settled splits, so a closed trip reads zero
|
|
// rather than its original gross.
|
|
// - EXCLUDE_RECONCILED_SOURCE drops the manual row a statement line has
|
|
// superseded. The trip queries never applied it, so a reconciled trip
|
|
// expense was counted twice here.
|
|
// - AMOUNT_UNCONVERTED counts rows whose AUD value is unknown, the same
|
|
// way getParticipantBalances does. They are still summed (at their
|
|
// native figure), so a non-zero count means this total is approximate
|
|
// and the UI has to say so. A trip is where foreign rows actually live,
|
|
// so netting a EUR figure against AUD ones silently is most likely to
|
|
// bite exactly here.
|
|
//
|
|
// A fourth, and it is what "owed" actually means: only rows THIS owner paid
|
|
// for. Without ${OWNER_SCOPE} the figure sums every split on every trip
|
|
// transaction regardless of who paid, so it silently mixes debts owed to
|
|
// different people. On Europe 2026 that put $1,605.49 of Molina's share of
|
|
// Sonu-paid rows into a number labelled as owed to the owner — a debt that
|
|
// is real, but between the other two participants, and which they settled
|
|
// directly (split_payments id 5, Molina -> Sonu, exactly $1,605.49).
|
|
// A participant's own share of a row they paid for was in there too, which
|
|
// is nobody's debt at all.
|
|
//
|
|
// `transactions` is aliased `t` so the shared fragments apply directly —
|
|
// they assume that alias, and hand-inlining a copy is what let the
|
|
// reconciled-row exclusion drift out of the analytics routes to begin with.
|
|
queryRaw<{ participant_id: number; name: string; owed: number; unconverted_count: number }>(`
|
|
WITH owed AS (
|
|
SELECT ts.participant_id AS pid,
|
|
SUM(ts.share_percent / 100.0 * COALESCE(t.amount_aud, t.amount)) AS gross,
|
|
SUM(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted
|
|
FROM transaction_overrides o
|
|
JOIN transactions t ON t.id = o.transaction_id
|
|
${STATEMENTS_JOIN}
|
|
JOIN transaction_splits ts ON ts.transaction_id = t.id
|
|
WHERE o.trip_id = $1
|
|
AND ${OWNER_SCOPE} = $2
|
|
AND ts.participant_id <> $2
|
|
AND t.transaction_type IN ('debit','fee','interest')
|
|
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
|
|
AND ${ACTIVE_OBLIGATION}
|
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
|
GROUP BY ts.participant_id
|
|
),
|
|
-- Only payments made TO this owner. Payment 5 on Europe is Molina -> Sonu:
|
|
-- a real settlement, but of a debt between those two, so it must not
|
|
-- reduce what Molina owes here. Symmetrical with the owner scoping above.
|
|
paid AS (
|
|
SELECT sp.from_participant_id AS pid, SUM(sp.amount) AS amt
|
|
FROM split_payments sp
|
|
WHERE sp.trip_id = $1
|
|
AND sp.to_participant_id = $2
|
|
GROUP BY sp.from_participant_id
|
|
)
|
|
SELECT p.id AS participant_id, p.name,
|
|
(COALESCE(owed.gross, 0) - COALESCE(paid.amt, 0))::float AS owed,
|
|
COALESCE(owed.unconverted, 0)::int AS unconverted_count
|
|
FROM participants p
|
|
LEFT JOIN owed ON owed.pid = p.id
|
|
LEFT JOIN paid ON paid.pid = p.id
|
|
WHERE owed.pid IS NOT NULL OR paid.pid IS NOT NULL
|
|
ORDER BY 3 DESC
|
|
`, [tripId, ownerId]),
|
|
]);
|
|
|
|
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);
|
|
}
|