diff --git a/CLAUDE.md b/CLAUDE.md index 5eafb26..f3c90f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -275,6 +275,37 @@ by the `rn` window and ordered by amount closeness, so a looser band changes live queue at the time of the change: candidate counts went **0 → 0** on all 12 rows, i.e. no new noise. **The auto-matcher stays exact** — see `findAutoMatches()`. +### A charge that never posted (`POST /api/transactions//void`, migration 0040) + +The third way a row stops counting, for a case with **no survivor**: a card +notification is sent for an *authorisation*, and when the merchant releases the +hold no statement line ever arrives. Txn 7951 — a $1,040.41 Airbnb hold on Amex, +booking cancelled the next day — sat in the reconcile queue with nothing to +match and counted as spend everywhere. `reconcile` needs a statement twin, +`supersede` needs a survivor (and `transactions_no_self_supersede` blocks +pointing a row at itself), so it gets its own columns: `voided_at` + +`void_reason` (reason mandatory, by CHECK). Body `{reason}`; `DELETE` undoes it. +UI: **Never posted** on every row of `/reconcile`. + +- **In `EXCLUDE_RECONCILED_SOURCE`**, so every "count each purchase once" query + drops it without being edited; the queue, `findAutoMatches`, both adoption + helpers, annual charges and the manual reconcile POST filter it explicitly. +- **Statementless only** — `transactions_void_statementless` enforces it in the + database. A statement line is the bank's record of a posted charge; a wrong + one is a supersede. +- **Refused** on a reconciled row (it posted), a superseded row, and a row with a + *settled* split (someone already paid their share — voiding would quietly turn + that into a credit). `merge` refuses a voided row on either side. +- **The wallet lane deliberately still SEES voided rows.** `alreadyIngested()` and + the issuer/Wallet pairing in `wallet-ingestion.ts` do not filter `voided_at`: + the voided row is the sink that stops a late twin notification, or the next + `prompt_version` re-post, from booking the hold again. Same for the intake + router's idempotency lookup. Do not "fix" those. +- **Outside this repo, nothing filters it**: Hermes' `agent_ro` views + (`cashflow_monthly` etc.) and ingestion-engine's `instalment_matcher` already + ignore `superseded_by_id`/`reconciled_with_id` too, so a voided row is no worse + there than any other hidden row. + ### Upcoming annual charges (`GET /api/annual-charges`, migration 0038) Card annual fees, loan package fees and annual subscriptions, derived from diff --git a/prisma/migrations/0040_transaction_void/migration.sql b/prisma/migrations/0040_transaction_void/migration.sql new file mode 100644 index 0000000..d254e88 --- /dev/null +++ b/prisma/migrations/0040_transaction_void/migration.sql @@ -0,0 +1,43 @@ +-- A statementless row for a charge that never posted. +-- +-- WHY: the wallet lane books every card notification as a purchase, and a card +-- notification is sent for an AUTHORISATION, not a settlement. When the +-- merchant releases the hold the bank never posts the charge, so no statement +-- line ever arrives: the row sits in the reconcile queue forever and counts as +-- spend in every figure. Txn 7951: Airbnb HMXAHAW49Y, $1,040.41 held on Amex +-- 1000 on 2026-09-24, booking cancelled 25 Sep inside the free window, hold +-- released ("We removed the $1,040.41 (AUD) payment hold"). Statement 219 +-- (11 Sep - 10 Oct) has no such line and reconciles to its header without it. +-- +-- Neither existing exclusion fits. reconciled_with_id needs a statement twin +-- and superseded_by_id needs a surviving row (and may not point at itself, +-- transactions_no_self_supersede). There is no survivor: nothing happened. +-- +-- Not a DELETE: every child of transactions cascades, and the row is also the +-- sink the wallet lane checks before re-posting an event. Deleting it would let +-- the next prompt_version bump re-create it. + +ALTER TABLE transactions + ADD COLUMN IF NOT EXISTS voided_at timestamptz, + ADD COLUMN IF NOT EXISTS void_reason text; + +COMMENT ON COLUMN transactions.voided_at IS + 'Set when this statementless row records a charge that never posted (e.g. a released authorisation hold). Excluded from every figure and from the reconcile queue, kept for its children and audit trail. NULL = live.'; + +-- A reason is mandatory: a void with no explanation is indistinguishable from +-- a mistake six months later. +ALTER TABLE transactions + ADD CONSTRAINT transactions_void_has_reason + CHECK ((voided_at IS NULL AND void_reason IS NULL) + OR (voided_at IS NOT NULL AND btrim(coalesce(void_reason, '')) <> '')); + +-- Statement rows cannot be voided. A statement line is the bank's own record +-- of a posted charge, and the statement's rows must keep summing to its +-- header; a wrong statement row is a supersede, not a void. +ALTER TABLE transactions + ADD CONSTRAINT transactions_void_statementless + CHECK (voided_at IS NULL OR statement_id IS NULL); + +CREATE INDEX IF NOT EXISTS idx_transactions_voided + ON transactions (voided_at) + WHERE voided_at IS NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a9aaed5..75534a5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -190,7 +190,9 @@ model transactions { owner_id Int? reconciled_with_id Int? superseded_by_id Int? - principal_amount Decimal? @db.Decimal(12, 2) + voided_at DateTime? @db.Timestamptz(6) // a charge that never posted (migration 0040) + void_reason String? + principal_amount Decimal? @db.Decimal(12, 2) interest_amount Decimal? @db.Decimal(12, 2) source String? // feed this row came from, e.g. "frollo"; NULL = statement/manual (migration 0028) source_ref String? // the provider's own transaction id — idempotency key for re-imports diff --git a/src/__tests__/integration/void.test.ts b/src/__tests__/integration/void.test.ts new file mode 100644 index 0000000..3f52854 --- /dev/null +++ b/src/__tests__/integration/void.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; +import { Pool } from "pg"; +import { createPool, resetDB, seedParticipants, insertTransaction } from "./helpers"; +import { EXCLUDE_RECONCILED_SOURCE } from "@/lib/analytics-sql"; + +/** + * A charge that never posted (migration 0040). + * + * Txn 7951: a $1,040.41 Airbnb authorisation hold on Amex, booking cancelled + * the next day, hold released. The wallet lane booked the notification as a + * purchase and no statement line ever arrived, so it sat in the reconcile queue + * forever and counted as spend everywhere. A void has no survivor, which is why + * neither reconcile nor supersede could express it. + * + * As in merge-supersede.test.ts, the refusals matter as much as the happy path: + * a void quietly stops money counting. + */ + +let pool: Pool; +let ownerId: number; +let otherId: number; +let voidRoute: typeof import("../../app/api/transactions/[id]/void/route"); +let merge: typeof import("../../app/api/transactions/[id]/merge/route").POST; + +const req = (body: unknown) => ({ json: async () => body, headers: { get: () => null } }) as never; +const params = (id: number) => ({ params: Promise.resolve({ id: String(id) }) }); +const doVoid = (id: number, reason: unknown = "hold released, booking cancelled") => + voidRoute.POST(req({ reason }), params(id)); + +let acctSeq = 0; +async function statementLine(amount: number, date: string, description: string) { + const st = await pool.query( + `INSERT INTO statements (filename, account_number, bank_name, billing_start_date, billing_end_date, owner_id) + VALUES ('s.pdf', $3, 'Test Bank', $1::date - 20, $1::date + 10, $2) RETURNING id`, + [date, ownerId, `5555-0000-0000-${String(1000 + acctSeq++)}`] + ); + const t = await pool.query( + `INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id) + VALUES ($1, $2, $3, $4, 'debit', $5) RETURNING id`, + [st.rows[0].id, date, description, amount, ownerId] + ); + return t.rows[0].id as number; +} + +/** What every "count each purchase once" query sees. */ +async function countedIds(): Promise { + const r = await pool.query( + `SELECT t.id FROM transactions t WHERE ${EXCLUDE_RECONCILED_SOURCE} ORDER BY t.id` + ); + return r.rows.map((x) => x.id); +} + +beforeAll(async () => { + pool = createPool(); + vi.doMock("@/lib/auth", () => ({ + getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }), + })); + voidRoute = await import("../../app/api/transactions/[id]/void/route"); + ({ POST: merge } = await import("../../app/api/transactions/[id]/merge/route")); +}); + +beforeEach(async () => { + await resetDB(pool); + await pool.query("DELETE FROM expense_metadata"); + ({ ownerId, otherId } = await seedParticipants(pool)); +}); + +describe("voiding a charge that never posted", () => { + it("drops the row out of every figure and the reconcile queue, but keeps it", async () => { + const { getPendingReconciliations } = await import("@/lib/queries"); + const hold = await insertTransaction(pool, ownerId, { description: "AIRBNB", amount: 1040.41 }); + const real = await insertTransaction(pool, ownerId, { description: "AIRBNB", amount: 529.05 }); + + expect((await getPendingReconciliations(ownerId)).map((t) => t.id)).toContain(hold); + + const res = await doVoid(hold); + expect(res.status ?? 200).toBe(200); + + expect(await countedIds()).toEqual([real]); + expect((await getPendingReconciliations(ownerId)).map((t) => t.id)).not.toContain(hold); + const r = await pool.query(`SELECT voided_at, void_reason FROM transactions WHERE id = $1`, [hold]); + expect(r.rows[0].voided_at).not.toBeNull(); + expect(r.rows[0].void_reason).toBe("hold released, booking cancelled"); + }); + + it("can be undone", async () => { + const hold = await insertTransaction(pool, ownerId, { amount: 1040.41 }); + await doVoid(hold); + const res = await voidRoute.DELETE(req({}), params(hold)); + expect(res.status ?? 200).toBe(200); + expect(await countedIds()).toEqual([hold]); + }); + + it("requires a reason", async () => { + const hold = await insertTransaction(pool, ownerId, { amount: 10 }); + expect((await doVoid(hold, " ")).status).toBe(400); + expect((await doVoid(hold, null)).status).toBe(400); + expect(await countedIds()).toEqual([hold]); + }); + + it("refuses a statement line -- that charge posted", async () => { + const stmt = await statementLine(50, "2026-09-10", "SOMETHING"); + expect((await doVoid(stmt)).status).toBe(409); + expect(await countedIds()).toEqual([stmt]); + }); + + it("refuses a reconciled row -- it found its statement line, so it posted", async () => { + const wallet = await insertTransaction(pool, ownerId, { amount: 50, transaction_date: "2026-09-10" }); + const stmt = await statementLine(50, "2026-09-10", "SOMETHING"); + await pool.query(`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, [stmt, wallet]); + expect((await doVoid(wallet)).status).toBe(409); + }); + + it("refuses a row with a settled split -- someone already paid their share", async () => { + const hold = await insertTransaction(pool, ownerId, { amount: 80 }); + await pool.query( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled) + VALUES ($1, $2, 50, true)`, + [hold, otherId] + ); + expect((await doVoid(hold)).status).toBe(409); + expect(await countedIds()).toEqual([hold]); + }); + + it("is enforced by the database, not only the route", async () => { + const stmt = await statementLine(50, "2026-09-10", "SOMETHING"); + await expect( + pool.query(`UPDATE transactions SET voided_at = now(), void_reason = 'x' WHERE id = $1`, [stmt]) + ).rejects.toThrow(/transactions_void_statementless/); + + const manual = await insertTransaction(pool, ownerId, { amount: 5 }); + await expect( + pool.query(`UPDATE transactions SET voided_at = now() WHERE id = $1`, [manual]) + ).rejects.toThrow(/transactions_void_has_reason/); + }); +}); + +describe("the matchers never use a voided row", () => { + it("auto-reconcile does not pair a voided row with a statement line of the same amount", async () => { + const { findAutoMatches } = await import("@/lib/auto-reconcile"); + const hold = await insertTransaction(pool, ownerId, { amount: 120, transaction_date: "2026-09-10" }); + await statementLine(120, "2026-09-11", "SOMETHING"); + expect((await findAutoMatches(ownerId)).map((m) => m.manual_id)).toContain(hold); + + await doVoid(hold); + expect(await findAutoMatches(ownerId)).toEqual([]); + }); + + it("a receipt for a rebooking at the same amount adopts the live row, not the released hold", async () => { + const { findAdoptableTransaction } = await import("@/lib/auto-reconcile"); + const hold = await insertTransaction(pool, ownerId, { amount: 300, transaction_date: "2026-09-10" }); + const rebooked = await insertTransaction(pool, ownerId, { amount: 300, transaction_date: "2026-09-11" }); + // Two candidates: the adopter refuses rather than guess. + expect(await findAdoptableTransaction(ownerId, 300, "2026-09-11")).toBeNull(); + + await doVoid(hold); + expect(await findAdoptableTransaction(ownerId, 300, "2026-09-11")).toBe(rebooked); + }); + + it("merge refuses a voided row on either side", async () => { + const hold = await insertTransaction(pool, ownerId, { amount: 70, transaction_date: "2026-09-10" }); + const stmt = await statementLine(70, "2026-09-10", "SOMETHING"); + await doVoid(hold); + const res = await merge(req({ other_id: stmt, mode: "reconcile" }), params(hold)); + expect(res.status).toBe(409); + }); +}); diff --git a/src/app/api/transactions/[id]/merge/route.ts b/src/app/api/transactions/[id]/merge/route.ts index 72c3e0b..50e03a0 100644 --- a/src/app/api/transactions/[id]/merge/route.ts +++ b/src/app/api/transactions/[id]/merge/route.ts @@ -66,8 +66,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: statement_id: number | null; reconciled_with_id: number | null; superseded_by_id: number | null; + voided_at: Date | null; }>( - `SELECT t.id, t.statement_id, t.reconciled_with_id, t.superseded_by_id + `SELECT t.id, t.statement_id, t.reconciled_with_id, t.superseded_by_id, t.voided_at FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id WHERE t.id = ANY($1::int[]) AND COALESCE(t.owner_id, s.owner_id) = $2`, @@ -95,6 +96,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: { status: 409 } ); } + // A voided row is a charge that never posted. As a source it is already + // out of every figure; as a survivor it would take the other row out too. + if (row.voided_at !== null) { + return NextResponse.json( + { error: `${label} transaction ${row.id} is voided (a charge that never posted)` }, + { status: 409 } + ); + } } try { diff --git a/src/app/api/transactions/[id]/void/route.ts b/src/app/api/transactions/[id]/void/route.ts new file mode 100644 index 0000000..b802593 --- /dev/null +++ b/src/app/api/transactions/[id]/void/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { queryRaw } from "@/lib/db"; +import { unvoidTransaction, voidTransaction } from "@/lib/reconcile"; + +/** + * POST /api/transactions//void { reason } -- a charge that never posted + * DELETE /api/transactions//void -- undo it + * + * For a statementless row whose charge never reached a statement: an + * authorisation hold the merchant released (txn 7951, a cancelled Airbnb + * booking). The row stays, with its children and audit trail, and drops out of + * every figure and the reconcile queue. Rules and refusals: `voidTransaction` + * in lib/reconcile.ts. + */ + +async function ownedRow(req: NextRequest, idParam: string) { + const user = await getCurrentUser(req); + if (!user) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 403 }) }; + const id = Number(idParam); + if (!Number.isInteger(id)) { + return { error: NextResponse.json({ error: "invalid transaction id" }, { status: 400 }) }; + } + // Same owner scope as every other route; a statement row carries owner_id on + // the statement. (Statement rows are refused later, but must 400, not 404.) + const rows = await queryRaw<{ id: number }>( + `SELECT t.id FROM transactions t + LEFT JOIN statements s ON s.id = t.statement_id + WHERE t.id = $1 AND COALESCE(t.owner_id, s.owner_id) = $2`, + [id, user.id] + ); + if (rows.length === 0) { + return { error: NextResponse.json({ error: "transaction not found" }, { status: 404 }) }; + } + return { id }; +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const owned = await ownedRow(req, (await params).id); + if (owned.error) return owned.error; + + let body: { reason?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "invalid JSON" }, { status: 400 }); + } + const reason = typeof body.reason === "string" ? body.reason.trim() : ""; + if (!reason) return NextResponse.json({ error: "reason is required" }, { status: 400 }); + + try { + await voidTransaction(owned.id, reason); + } catch (e) { + return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 409 }); + } + return NextResponse.json({ ok: true, id: owned.id, voided: true }); +} + +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const owned = await ownedRow(req, (await params).id); + if (owned.error) return owned.error; + await unvoidTransaction(owned.id); + return NextResponse.json({ ok: true, id: owned.id, voided: false }); +} diff --git a/src/app/api/transactions/reconcile/route.ts b/src/app/api/transactions/reconcile/route.ts index 840907d..032e619 100644 --- a/src/app/api/transactions/reconcile/route.ts +++ b/src/app/api/transactions/reconcile/route.ts @@ -19,7 +19,7 @@ export async function POST(req: NextRequest) { // 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`, + `SELECT id FROM transactions WHERE id = ANY($1::int[]) AND statement_id IS NULL AND voided_at IS NULL AND owner_id = $2`, [manualIds, user.id] ); if (owned.length !== manualIds.length) { diff --git a/src/app/reconcile/page.tsx b/src/app/reconcile/page.tsx index eb24a0e..73ab504 100644 --- a/src/app/reconcile/page.tsx +++ b/src/app/reconcile/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { usePendingReconciliations, useReconcile } from "@/lib/hooks"; +import { usePendingReconciliations, useReconcile, useVoidTransaction } from "@/lib/hooks"; import type { ManualTxWithMatches, PotentialMatch } from "@/lib/queries"; function formatDate(d: string | Date) { @@ -156,6 +156,7 @@ export default function ReconcilePage() { ))} + ))} @@ -243,6 +244,7 @@ function ReconcileRow({ Clear )} + {!isSkipped && ( ); } + +/** + * "Never posted": a released authorisation hold, e.g. a booking cancelled + * before the charge settled. Drops the row out of every figure and this queue + * (migration 0040); the row and its history are kept. + */ +function VoidButton({ id, amount, onError }: { id: number; amount: number; onError?: (msg: string) => void }) { + const voidTx = useVoidTransaction(); + async function handle() { + const reason = window.prompt( + `Mark this $${Number(amount).toFixed(2)} charge as never posted? It will stop counting everywhere.\n\nReason (e.g. "hold released, booking cancelled"):` + ); + if (!reason?.trim()) return; + try { + await voidTx.mutateAsync({ id, reason }); + } catch (e) { + const msg = e instanceof Error ? e.message : "Void failed"; + if (onError) onError(msg); else window.alert(msg); + } + } + return ( + + ); +} diff --git a/src/lib/analytics-sql.ts b/src/lib/analytics-sql.ts index 909221a..7923a20 100644 --- a/src/lib/analytics-sql.ts +++ b/src/lib/analytics-sql.ts @@ -71,9 +71,17 @@ export const STATEMENTS_JOIN = `LEFT JOIN statements s ON s.id = t.statement_id` * without being edited. Anything summing transactions without this fragment * still double-counts — that is the same gap that let reconciled rows into the * analytics routes in the first place. + * + * **3. The charge that never posted** (`voided_at`, migration 0040). + * + * A card notification is sent for an authorisation, and a released hold never + * reaches a statement (txn 7951: a $1,040.41 Airbnb hold on a booking cancelled + * the next day). There is no survivor to point at, so it gets its own column — + * and it lives in this fragment for the same reason case 2 does. */ export const EXCLUDE_RECONCILED_SOURCE = `NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL) - AND t.superseded_by_id IS NULL`; + AND t.superseded_by_id IS NULL + AND t.voided_at IS NULL`; /** * The currency `t.amount` is actually denominated in. diff --git a/src/lib/annual-charges.ts b/src/lib/annual-charges.ts index 0abc719..fcf1e2f 100644 --- a/src/lib/annual-charges.ts +++ b/src/lib/annual-charges.ts @@ -221,6 +221,7 @@ export async function getUpcomingAnnualCharges(ownerId: number): Promise 0 AND COALESCE(t.owner_id, s.owner_id) = $1 AND t.transaction_type = 'debit' AND t.description !~* $2 diff --git a/src/lib/auto-reconcile.ts b/src/lib/auto-reconcile.ts index 8d4fb54..20778f5 100644 --- a/src/lib/auto-reconcile.ts +++ b/src/lib/auto-reconcile.ts @@ -102,6 +102,7 @@ export async function findAutoMatches( AND m.owner_id = $1 AND m.reconciled_with_id IS NULL AND m.superseded_by_id IS NULL + AND m.voided_at IS NULL AND (m.payment_method IS NULL OR m.payment_method NOT IN ('cash', 'credits')) AND t.superseded_by_id IS NULL AND COALESCE(t.owner_id, s.owner_id) = $1 @@ -188,6 +189,9 @@ export async function findWalletAdoptableTransaction( AND t.transaction_date BETWEEN $3::date - $4::int AND $3::date + $4::int AND t.reconciled_with_id IS NULL AND t.superseded_by_id IS NULL + -- A voided row is a charge that never posted; a real charge for the same + -- amount (a rebooking after a released hold) must not fold into it. + AND t.voided_at IS NULL -- A wallet row is the OTHER HALF of this tap, not a purchase to fold -- into; findPairedRow() merges those, with different rules. AND COALESCE(t.source, '') <> 'wallet' @@ -213,6 +217,9 @@ export async function findAdoptableTransaction( AND t.transaction_date BETWEEN $3::date - $4::int AND $3::date + $4::int AND t.reconciled_with_id IS NULL AND t.superseded_by_id IS NULL + -- A voided row is a charge that never posted; a real charge for the same + -- amount (a rebooking after a released hold) must not fold into it. + AND t.voided_at IS NULL -- Never adopt a row that another source already fully described; that -- is a different purchase that happens to share an amount. AND NOT EXISTS ( diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 1e7286c..65d0d6d 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -1162,6 +1162,27 @@ export function useReconcile() { }); } +/** Mark a statementless row as a charge that never posted (migration 0040). */ +export function useVoidTransaction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, reason }: { id: number; reason: string }) => { + const res = await fetch(`/api/transactions/${id}/void`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason }), + }); + if (!res.ok) throw new Error((await res.json()).error || "Void failed"); + return res.json(); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["transactions"] }); + qc.invalidateQueries({ queryKey: ["reconcile-pending"] }); + qc.invalidateQueries({ queryKey: ["analytics"] }); + }, + }); +} + export function useMerchantTransactions(merchant: string | null) { return useQuery<{ transactions: MerchantTxnRow[] }>({ queryKey: ["analytics", "merchant-txns", merchant], diff --git a/src/lib/queries.ts b/src/lib/queries.ts index a8480d3..3dec223 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -857,6 +857,9 @@ export async function getPendingReconciliations(ownerId: number): Promise