Files
finance-app/src/app/api/transactions/reconcile/route.ts
T
siddharthd 17028c79ff
ci / lint-test (push) Successful in 48s
Accept grocery receipts scanned in pantry as candidate spend
Adds /api/receipts/ingest as a sibling to the order lane, sharing its shape but
making one decision differently: nothing is parked. An order can wait for its
statement because it is already visible as an email; a gift-card grocery shop is
visible nowhere at all, so a scan that produces no transaction produces nothing
anyone can see. Every payment becomes a manual row immediately and the existing
pending-reconciliation queue resolves the ones with a card leg coming.

One transaction per tender leg. A $114.57 shop settled $40.75 gift card +
$73.82 Mastercard has a statement line for $73.82 only. A single row marked
credits is excluded from the queue while that line double-counts; marked card it
is searched for at 1% of $114.57 and never matches. Either way the shop books
$188.39. Per-leg rows make each amount the settled amount, so the matcher works
untouched.

Reconciliation now carries expense_metadata across. It already moved overrides,
tags and splits from the manual row to the statement row and left metadata
behind, which did not matter while metadata only came from an email that made
its own transaction. It matters now that it carries a shop's line items:
unmoved, the contents vanish at exactly the moment the statement line appears,
and COLES 0556 MANOR LAKES stays as unreadable as before anything was scanned.
transaction_id is UNIQUE, so a statement row that already has metadata keeps it
and the pantry row is flagged rather than raising a constraint violation.

Also regenerates the Prisma model. card_last4, currency, flags, reconciled_at,
matched_transaction_id, platform and route have been in the database since
migrations 0019/0020 and were absent from schema.prisma — regenerating the
client from it would have dropped columns the order lane writes on every ingest.

23 integration tests against the real schema, built from the three receipts that
drove the design. Existing suites unchanged: 104 unit, 144 integration.
2026-07-30 13:40:32 +10:00

122 lines
4.9 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { prisma, queryRaw } from "@/lib/db";
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const body = await req.json() as {
matches: { manual_id: number; statement_tx_id: number }[];
};
if (!Array.isArray(body.matches) || body.matches.length === 0) {
return NextResponse.json({ error: "No matches provided" }, { status: 400 });
}
// Verify all manual_ids belong to this user
const manualIds = body.matches.map((m) => m.manual_id);
const owned = await queryRaw<{ id: number }>(
`SELECT id FROM transactions WHERE id = ANY($1::int[]) AND statement_id IS NULL AND owner_id = $2`,
[manualIds, user.id]
);
if (owned.length !== manualIds.length) {
return NextResponse.json({ error: "One or more transactions not found" }, { status: 404 });
}
let reconciled = 0;
for (const { manual_id, statement_tx_id } of body.matches) {
await prisma.$transaction(async (tx) => {
// Copy overrides: manual → statement tx
const override = await tx.transaction_overrides.findUnique({
where: { transaction_id: manual_id },
});
if (override) {
await tx.transaction_overrides.upsert({
where: { transaction_id: statement_tx_id },
update: {
category_override: override.category_override,
merchant_normalized: override.merchant_normalized,
notes: override.notes,
my_share_percent: override.my_share_percent,
updated_at: new Date(),
},
create: {
transaction_id: statement_tx_id,
category_override: override.category_override,
merchant_normalized: override.merchant_normalized,
notes: override.notes,
my_share_percent: override.my_share_percent,
},
});
await tx.transaction_overrides.deleteMany({ where: { transaction_id: manual_id } });
}
// Move tags: manual → statement tx
const tags = await tx.transaction_tags.findMany({ where: { transaction_id: manual_id } });
if (tags.length) {
await tx.transaction_tags.createMany({
data: tags.map((t) => ({ transaction_id: statement_tx_id, tag_id: t.tag_id })),
skipDuplicates: true,
});
await tx.transaction_tags.deleteMany({ where: { transaction_id: manual_id } });
}
// Move splits: manual → statement tx
const splits = await tx.transaction_splits.findMany({ where: { transaction_id: manual_id } });
if (splits.length) {
await tx.transaction_splits.createMany({
data: splits.map((s) => ({
transaction_id: statement_tx_id,
participant_id: s.participant_id,
share_percent: s.share_percent,
})),
skipDuplicates: true,
});
await tx.transaction_splits.deleteMany({ where: { transaction_id: manual_id } });
}
// Move provenance: manual → statement tx.
//
// Overrides, tags and splits above were always carried across; expense_metadata was
// the one child left behind, which did not matter while every metadata row came from
// an email that had created its own transaction. It matters now: a scanned grocery
// receipt puts its line items here, and reconciliation hides the manual row from
// every figure — so without this the shop's contents disappear at exactly the moment
// the statement line appears, and `COLES 0556 MANOR LAKES` stays as unreadable as it
// was before the receipt was ever scanned.
//
// transaction_id is UNIQUE, so a statement row that already has metadata (an emailed
// or Paperless copy got there first) keeps it. The pantry row stays attached to the
// reconciled manual transaction and is flagged, rather than raising a constraint
// violation or silently overwriting the other source.
const moved = await tx.$executeRawUnsafe(
`UPDATE expense_metadata SET transaction_id = $1
WHERE transaction_id = $2
AND NOT EXISTS (SELECT 1 FROM expense_metadata other WHERE other.transaction_id = $1)`,
statement_tx_id,
manual_id
);
if (moved === 0) {
await tx.$executeRawUnsafe(
`UPDATE expense_metadata
SET flags = coalesce(flags, '[]'::jsonb) || '["metadata_collision_on_reconcile"]'::jsonb
WHERE transaction_id = $1`,
manual_id
);
}
// Mark manual tx as reconciled (link to statement tx)
await tx.$executeRawUnsafe(
`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`,
statement_tx_id,
manual_id
);
});
reconciled++;
}
return NextResponse.json({ reconciled });
}