ci / lint-test (push) Successful in 1m25s
Every spend analytic gated on `OWNER_SCOPE = $1` and scaled by `mySplitOf` *within* that gate, so ownership was a precondition for an expense being yours. Half a grocery shop Sonu paid for counted as zero — in monthly, daily, merchants, subscriptions, fees and the budget page. 167 rows / $3,210.91 across Jan-Jul 2026, worst in April (+$1,354.83, the Europe trips), while getParticipantBalances booked the matching debt correctly. The app could say you owed her for a shop while insisting you had not spent anything on it. New MY_SPEND_SCOPE(): owner = me OR I hold a split. OWNER_SCOPE stays on the things that measure an *account* rather than a person — the income and investment lines, and the statement-level fee rollup. myShare had to change with it, and widening the gate alone would have been worse than the bug: its `100 - everyone else` fallback is the payer's remainder, so on someone else's unsplit row it returns 100 and moves their whole bill onto you. It now branches on ownership — my row resolves as before; their row takes an explicit split row only, absent meaning 0. That 0 is what makes the wider gate safe. my_share_percent is deliberately not read on someone else's row: one unscoped column, writable by anyone who can see the row, so "my" can only mean the owner's. All 402 rows carrying one today are owner-side. MY_SHARE_PCT mirrors myShare for the transactions list, which has no viewer-scoped ts join; a test asserts the two agree across seven fixture shapes. No historical restatement — every non-owner split is 2026-dated, and the 1,266 pre-2026 SplitMyExpenses splits are all on rows you own. Also fixes a latent failure in the NATIVE_CURRENCY test, which inserted a statement relying on participant id 1 existing (owner_id is NOT NULL DEFAULT 1 with an FK) and only passed when a sibling file had left one behind. It now owns its fixture. 15 new integration tests; 189 integration + 130 unit green.
59 lines
1.9 KiB
TypeScript
59 lines
1.9 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { getCurrentUser } from "@/lib/auth";
|
|
import { queryRaw } from "@/lib/db";
|
|
import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
|
|
import { bankLabel } from "@/lib/queries";
|
|
|
|
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
|
|
|
|
export async function GET(
|
|
req: NextRequest,
|
|
{ params }: { params: Promise<{ merchant: string }> }
|
|
) {
|
|
const user = await getCurrentUser(req);
|
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
|
|
const { merchant } = await params;
|
|
const decoded = decodeURIComponent(merchant);
|
|
|
|
const transactions = await queryRaw<{
|
|
id: number;
|
|
transaction_date: string;
|
|
description: string;
|
|
amount: number;
|
|
amount_aud: number | null;
|
|
my_amount: number;
|
|
transaction_type: string;
|
|
category: string;
|
|
bank_name: string;
|
|
statement_id: number;
|
|
}>(`
|
|
SELECT
|
|
t.id,
|
|
t.transaction_date::text,
|
|
t.description,
|
|
t.amount,
|
|
t.amount_aud,
|
|
CASE
|
|
WHEN t.transaction_type IN ('refund', 'credit') THEN -${MY_AMOUNT}
|
|
ELSE ${MY_AMOUNT}
|
|
END::numeric(10,2) as my_amount,
|
|
t.transaction_type,
|
|
${EFFECTIVE_CATEGORY} as category,
|
|
${bankLabel()} as bank_name,
|
|
t.statement_id
|
|
FROM transactions t
|
|
${STATEMENTS_JOIN}
|
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
|
WHERE ${MY_SPEND_SCOPE()}
|
|
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
|
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2
|
|
AND ${EXCLUDE_RECONCILED_SOURCE}
|
|
ORDER BY t.transaction_date DESC
|
|
LIMIT 500
|
|
`, [user.id, decoded]);
|
|
|
|
return NextResponse.json({ transactions });
|
|
}
|