financing
Financial ledger: accounts, journal entries, multi-source ingest (banks/onchain), staging, reconciliation, and routing rules.
| Endpoint | https://financing.mcp.devfellowship.com/mcp |
| Tools | 79 |
| Backing data | financial schema (accounts, journal entries, staging, routing rules/memory). |
Accounts
Section titled “Accounts”| Tool | Description |
|---|---|
create_account | Create a financial.ledger_accounts row (a chart-of-accounts entry). Pass parent_id to |
list_accounts | List financial.ledger_accounts (the chart of accounts) for a tenant. |
update_account | Update the mutable fields of a ledger account (is_active / name / description) by id or code+tenant_id. UPDATE-only, no delete. RLS-scoped user-JWT. |
Journal entries
Section titled “Journal entries”| Tool | Description |
|---|---|
book_linked_entry | Book N balanced journal-entry legs ATOMICALLY across accounts and/or tenants for a cross-boundary event (transfer_fx / capital_contribution / internal_transfer). Takes a high-level INTENT (type + typed params) and encodes each recipe’s accounting rule once, wiring the correct bridge account (FX gain-loss / equity / suspense). Accounts + tenants may be UUIDs OR chart codes / slugs. All legs created or none (compensating rollback); idempotency_key de-dupes retries. Posts by default. READ + WRITE both on the caller’s user-JWT under RLS (member+). |
classify | Re-run the classifier on a specific reconciliation_staging row. A routing-match step runs |
create_journal_entry | Create a DRAFT financial.journal_entries row plus its double-entry lines. Each line is |
delete_draft_entries | HARD-delete DRAFT financial.journal_entries (and their journal_entry_lines via ON DELETE CASCADE) created in error — e.g. malformed reconciliation restatement drafts. The ONLY sanctioned deletion path for journal entries. Selection is an EXPLICIT allowlist of entry UUIDs (entry_ids) — never a query/filter, so a sweep cannot happen by accident. HARD GUARD: any id not found (or not visible under RLS) OR not status=draft (posted/voided) is REFUSED with a reason and NEVER deleted — a posted/voided ledger record is impossible to delete through this tool. dry_run=true (default) returns would_delete (id, entry_date, description, status, line_count) + refused without mutating; dry_run=false performs a status-guarded hard delete. RLS-scoped user-JWT (only entries in your tenants). |
void_journal_entries | VOID individually named POSTED financial.journal_entries — the missing inverse of post_journal_entry for a single entry. Nothing else reaches them: reverse_batch requires a reconciliation_batch_id (NULL on hand-booked entries), delete_draft_entries only touches drafts, reset_unposted_staging refuses any staging row whose entry is posted. VOID, NEVER DELETE — a status flip to voided plus void_reason + voided_by (voided_at is stamped by the trg_journal_entry_status trigger), so the entry and its lines survive as the audit record. Selection is EXACTLY ONE of entry_ids (an explicit UUID allowlist) or reference_id + reference_type (one whole linked group): there is NO filter mode and no limit, and an unrecognized key is REFUSED rather than dropped (strict schema + handler guard, the defect class fixed in #306/#309). KEY INVARIANT: voiding some but not all still-POSTED members of a linked (reference_type, reference_id) group is REFUSED and the missing members are NAMED — a linked group is ONE booking (the production book_linked_entry groups are cross-tenant pairs) and half-voiding it leaves one leg alive and one dead with nothing in the schema to detect it; allow_partial=true is the only override and REQUIRES partial_reason, stamped into void_reason with the ids left posted. The sibling lookup is RLS-scoped, so completeness is proven only WITHIN your visible tenants — single-member groups are hoisted into warnings, not guessed at in code. An already-voided entry is an idempotent reported NO-OP; a draft is REFUSED with its status. Period locks are checked before writing (enforce_period_lock RAISES otherwise). ALL-OR-NOTHING, reason REQUIRED, dry_run=true by DEFAULT (reverse_batch defaults it to false; this does not) reporting entry ids, amounts, ledger accounts and the balance delta per account to check against financial.v_projected_wallet_balances. RLS-scoped user-JWT, NOT service_role. |
redate_journal_entries | Move the entry_date of individually named financial.journal_entries — and NOTHING else. The field-level repair that void-and-recreate was standing in for: void-and-recreate mints NEW entry ids and orphans every reconciliation_staging.journal_entry_id pointing at the old entry, just to fix one wrong field. The write patch is whitelisted AT RUNTIME to entry_date plus the metadata.redate_history audit stamp, so amounts, ledger accounts, entry_type, status, description and reference links cannot be touched, and journal_entry_lines is never written at all. Selection is an EXPLICIT journal_entry_ids UUID allowlist (min 1) — there is NO filter mode, no date-range selector and no limit, an empty array is REFUSED rather than read as “everything”, and an unrecognized key is REFUSED rather than dropped (the #306/#309 defect class). reason is REQUIRED (min 20 chars) and is APPENDED to metadata.redate_history, an append-only array of {from, to, reason, at, by, tool} — a date change with no recorded why is unauditable, because the moved entry afterwards looks exactly like one that was always dated that way. CLOSED PERIODS: refused when EITHER the current date OR the target date falls on-or-before the tenant’s financial.period_locks.locked_through watermark — moving an entry OUT of a closed period rewrites a published total just as moving one IN does. ⚠️ That guard is enforced HERE, in application code, NOT by the database: enforce_period_lock’s two UPDATE arms both require a status transition (→posted, or posted→voided), so a posted→posted re-date matches neither and the trigger would ALLOW it. There is deliberately no override flag — move the watermark deliberately (an audited unlock) and re-run. A voided entry is REFUSED (frozen audit record); an entry already on the target date is an idempotent reported NO-OP; an id not visible under RLS is REFUSED by name, never dropped in silence. The UPDATE carries .eq('entry_date', <the date we read>), so an entry re-dated by someone else between the read and the write is reported as a race and NEVER counted as a success. ALL-OR-NOTHING: any refusal writes nothing, even with dry_run=false. Before/after dates, line count, debit/credit totals and the ledger accounts touched are reported per entry. dry_run=true (DEFAULT) previews without writing: redated counts entries ACTUALLY WRITTEN and is therefore 0 in a dry run, while previewed entries are counted in would_redate and carry after_is_projected=true. RLS-scoped user-JWT for the read AND the write, never service_role, no tenant_id parameter. |
backfill_journal_line_asset_type | Fill the MISSING asset_type_id on the lines of POSTED financial.journal_entries — and write NOTHING else. Repairs the population migration 20260709160000 GRANDFATHERED and that dfl-schema #888 has now FROZEN: trg_reassert_integrity_posted_lines re-runs assert_journal_entry_integrity on the parent after every line write of a posted entry, and that judge fails while ANY line is NULL — so since #888 those entries cannot be touched at all until they are repaired whole. Measured on production 2026-08-27: 2.072 lines over 1.033 posted entries across 34 ledger accounts. TWO RESOLUTION RULES, in order: (a) account_name_suffix — the ledger account NAME contains /, so the asset is the substring after the LAST / (Clearing/YT-LBTC → YT-LBTC; the last slash, because a symbol may carry a hyphen and a name may be nested), resolved case-insensitively against financial.asset_types.symbol; (b) entry_currency — otherwise the currency the ENTRY carries (journal_entries.metadata.currency, else the tenant’s financial.tenants.settings.currency). 🚨 There is NO literal fallback currency and NO default asset anywhere in the tool: an unresolvable suffix, an undeclared tenant currency or a symbol naming no asset_types row SKIPS the entry with its reason. A wrong asset_type_id still balances per asset, so nothing downstream would ever go red on a guess — “probably right” is worse here than “not done”. ONE STATEMENT PER ENTRY: UPDATE … WHERE journal_entry_id = $1 AND asset_type_id IS NULL, so PostgreSQL fires the AFTER-ROW judge from the after-trigger queue at the END of the statement, when the entry is already whole; a line-by-line repair would be refused on the FIRST line. An entry whose NULL lines resolve to MORE THAN ONE asset is REFUSED (multi_asset_single_write_unsupported), never half-written — PostgREST cannot express a heterogeneous single-column bulk UPDATE, and the per-asset alternative IS the line-by-line repair the trigger rejects. PROJECTED JUDGE: the post-fill state is judged with the same rules as assert_journal_entry_integrity BEFORE writing, because filling asset_type_id is what DECIDES rules (b) and (c) — an entry whose legs span two assets and do not net per asset becomes CROSS-ASSET the moment the currencies appear, and the judge then demands usd_value on every line. Those entries are skipped by name (cross_asset_missing_usd_value); usd_value needs a PRICE AT THE ENTRY DATE, a different problem with a different source, and is NEVER written here. CLOSED PERIODS: an entry dated on-or-before its tenant’s financial.period_locks.locked_through is skipped, because enforce_period_lock_lines would RAISE check_violation — repeated here so the dry run cannot promise a doomed write. ⚠️ Not a corner case: tainan-pf is locked through 2025-12-31 and 985 of the 1.033 affected entries sit inside it, so only 48 entries / 102 lines are writable without a deliberate audited unlock. There is no override flag. The write patch is whitelisted AT RUNTIME to asset_type_id alone and financial.journal_entries is never written at all. Selection is an EXPLICIT journal_entry_ids array OR a non-empty filter (entry_date_from / entry_date_to / ledger_account_ids), never both and never neither; an empty array and an empty \{\} filter are BOTH refused rather than read as a 1.033-entry sweep, and an unrecognized key is refused rather than dropped (the #306/#309 defect class). limit defaults to 25 entries, maximum 200, and an over-long explicit id list is REFUSED rather than truncated — a truncated run reads exactly like a completed one. reason is REQUIRED (min 20 chars); it is logged and echoed, deliberately NOT persisted, because persisting it needs a second write surface the whitelist forbids and trg_activity_journal_entry_lines already records every line diff. NOT all-or-nothing across the batch (refusals are the NORMAL case in a grandfathered population, unlike redate_journal_entries) but ATOMIC PER ENTRY. dry_run=true (DEFAULT) previews without writing: repaired counts entries ACTUALLY WRITTEN and is therefore 0 in a dry run, while previewed entries are counted in would_repair and carry after_is_projected=true. Every planned line is reported with its resolved symbol AND which of the two rules produced it, so a human can audit the mapping without writing a query. An entry whose lines were filled by someone else between the read and the write is reported as a race and NEVER counted as repaired. RLS-scoped user-JWT for the read AND the write, never service_role, no tenant_id parameter. |
create_journal_entry_template | Create a financial.journal_entry_templates row — an alias of {debit_ledger_account_id, |
update_journal_entry_template | Repoint an EXISTING financial.journal_entry_templates row (by tenant_id + code) — the complement of the create-only create_journal_entry_template. Moves a template’s leg(s) (primary use: move the CASH leg from a generic placeholder account to a real wallet ledger account). Only supplied fields change; any new ledger account id is validated to exist for the tenant. No-op when the patch matches current values (changed=false). READ (account validation) user-JWT/RLS; WRITE service-role (RLS-by-design). |
get_journal_entry | Fetch a single financial.journal_entries row plus its journal_entry_lines (the |
list_journal_entries | List financial.journal_entries (headers only) for a tenant, newest first. |
post_journal_entry | Confirm a DRAFT financial.journal_entries row → status=posted (sets posted_at). This is |
reconcile_match | Link the legs of ONE money-movement across on-chain → Binance → bank into a |
Ingest (banks & onchain)
Section titled “Ingest (banks & onchain)”| Tool | Description |
|---|---|
upsert_bank_wallet_alias | Add or update a row in financial.bank_wallet_aliases — the shared copy of the statement-label → wallet-name map, so SQL, views and the app can join it instead of re-deriving it. Adding a bank is a ROW, not a deploy. bank_label is the label a STATEMENT carries, exactly as it lands in reconciliation_staging.source_bank_name (e.g. “Banco do Brasil”); wallet_name is the LEDGER label, exactly as it appears in financial.wallets.name (e.g. “BB PJ/BRL”). Lower priority is tried first, preserving the ORDER of the TypeScript array, which is the tie-break when one label matches wallets in more than one tenant. ⚠️ The TypeScript constant BANK_WALLET_ALIASES is the floor and is never overridden — the two are UNIONED — so deactivating a row here CANNOT remove an alias that ships in the constant; that stays a code change, deliberately, because a table that won outright would let a partial seed silently break postings the constant already resolved. WARNS when wallet_name matches no wallet, because such an alias resolves nothing and would otherwise fail only later, at post time. dry_run=true (DEFAULT) previews without writing. |
annotate_suppression_reason | Write the audit reason on financial.reconciliation_staging rows that are ALREADY suppressed. This exists because suppress_staging SKIPS an already-suppressed row, so it cannot repair a missing reason — and a row hidden with no stated reason is invisible to review AND unexplainable, so nobody can tell a deliberate suppression from an accident. Writes ONLY source_raw.suppressed_reason (the mirror trigger copies it to the suppressed_reason column). NEVER changes suppressed, status, amount, journal_entry_id or any classification field, so it cannot change what a row MEANS — only what it SAYS about why it is hidden. Requires an explicit staging_ids array; there is NO filter-based bulk annotate. A row that is NOT suppressed is SKIPPED, because the mirror trigger clears suppressed_reason whenever suppressed is not true and the write would be silently discarded. A row that ALREADY has a reason is SKIPPED unless overwrite=true. dry_run=true (DEFAULT) previews without writing: annotated counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run, while previewed rows are counted in would_annotate and carry after_is_projected=true. An id RLS did not return is reported in not_found_or_not_visible rather than silently dropped. |
ingest_bb | Ingest a Banco do Brasil PJ (BBPJ) checking-account statement (CSV export) into financial.reconciliation_staging. DIRECTION: the Tipo Lançamento column (Entrada/Saída) is persisted as source_raw.type = debit|credit, corroborated by the Valor C/D suffix — the rung publish_batch_atomic grades ABOVE sign(amount). The two columns contradicting each other, or a token neither recognises, yields NO type plus a warning; it is never guessed. |
ingest_c6 | Ingest a C6 Bank PF checking-account statement into financial.reconciliation_staging (source_bank_name=‘C6 Bank’). INPUT IS TEXT, NOT A PDF: C6 exports a password-protected PDF, so extract it with pdftotext -upw <password> -layout <file.pdf> - and pass the result — the -layout flag is required and the password never reaches the server. Rows carry only DAY/MONTH; the YEAR comes from the month section header, so a statement crossing a year boundary is dated correctly on both sides. Running-balance lines (Saldo do dia / Saldo Anterior / S A L D O) are filtered. Dedup is keyed on transaction CONTENT (date + amount + normalized description + intra-day sequence), not a file hash, so re-sending an overlapping period does not re-insert rows. CHECKSUM GATE: each month header prints C6’s own Entradas/Saídas totals, so the tool sums the rows it parsed and compares them BEFORE writing anything — on a mismatch it inserts NOTHING and returns the per-month comparison, since a half-read statement lands a wrong number that only surfaces later as a reconciliation gap. The comparison table is returned on success too. acknowledge_checksum_mismatch is the escape hatch and needs a WRITTEN reason (min 20 chars), which is stamped into source_raw.checksum_override on every row it admits. Rows land status=pending_review. DIRECTION: source_raw.type = debit|credit is persisted ONLY when the Tipo column’s FIRST WORD is one (Entrada/Saída) — the rung publish_batch_atomic grades ABOVE sign(amount). Outros gastos, Pagamento and Débito de Cartão are CATEGORIES, not directions (the “Débito” there is the CARD type), so those rows carry NO type and stay honestly ungradeable. |
ingest_binance | Ingest a Binance |
ingest_hyperliquid_fills | Land Hyperliquid TRADE FILLS in financial.reconciliation_staging as source_type=onchain, status=pending_review, so on-chain activity reaches the review queue without anyone remembering to ask for it (Tainan, 2026-08-17: “eles tinham que injetar em staging já né? senao eu tenho q lembrar tudo toda vez?”). WHY THE CLASS EXISTS: we read the Hyperliquid BALANCE every week and had never once read the TRADES — BlackL - Hyperliquid/BTC said 0.158500, four Airtable-era journal entries from 2025, while the chain held 0.546586011. Not an unexplained gap, an UNREAD one: the truth is 45 BTC fills. The reading half is dfl-financing bun run src/cli.ts hyperliquid-fills --emit-staging (PR #217), which fetches userFillsByTime, resolves the spot index through spotMeta, reconciles against the weekly balance snapshot to a residual of ZERO on UBTC/UETH/USOL/MAX — and then STOPS, writing nothing, because a DATA write goes through an MCP tool carrying the caller’s user-JWT, never service_role and never a PR. Paste its JSON array into fills. EVENTS ONLY, NEVER BALANCE DELTAS: one fill, one row; amount is that fill’s own effect on the base asset (gross size minus a fee paid in that SAME asset — 39 of the 45 BTC fills pay in UBTC and 6 in USDC, and a USDC fee never moves the BTC position), RECOMPUTED here rather than trusted, because a balance delta has no date and no counterparty and injecting one manufactures a plug (“a gente não quer conta Clearing… Eu quero a realidade.”). CUTOFF 2026-01-20: an earlier fill is already in the ledger through the Airtable migration, so it is WITHHELD with its reason attached, never dropped silently — and the cutoff is RE-APPLIED here rather than trusted from the emitter, because the payload is JSON an agent pastes and can come from an older build or a hand-edit. PERPS ARE NOT SPOT: a bare coin such as "HYPE" is a PERPETUAL (this account carries an Open Short and a liquidated Close Short), and reading it as spot would book a 4,87 HYPE sale that never happened, which nothing downstream would flag because a plausible sale of an asset we hold looks exactly like a real one — so only a fill that PROVES it is spot (a resolved @<index> pair) is admitted and the rest is reported as not_spot. IDEMPOTENT on source_hash = sha256('hyperliquid:fill:<fillIdentityKey>'), swept STATUS-AGNOSTICALLY and enforced by uq_reconciliation_staging_tenant_source_hash, so a re-run over an overlapping window inserts nothing. ⚠️ tid is NOT that key: all eleven Spot Dust Conversion fills report tid: 0 AND hash: 0x0000…0000, so keying on tid folds eleven distinct events into one row and loses ten, including the 0.0000000078 UBTC sale the ninth decimal needs. log_index stays NULL on purpose — a fill has no ordinal inside its order that survives a re-read, and seven of nineteen BTC orders SHARE one hash across several fills (one across fourteen), so the (chain, tx_hash, log_index) natural key would collapse fourteen real trades into one. A payload whose source_hash is not the digest of its own source_ref, whose amount is not the fill’s own arithmetic, or whose direction contradicts its side is REFUSED WHOLE — nothing is written, because partially trusting a payload that lied once is worse. dry_run=true (DEFAULT) reports what would be inserted, what already exists, and every withheld row with its reason, using the SAME source_hash sweep the write path uses. Rows land pending_review on the Reconciliation Preview — this FEEDS the human review gate, it does not bypass it. RLS-scoped user-JWT; no credentials of any kind are needed to read the fills (the endpoints are public and keyless). |
ingest_hyperliquid_transfers | Land Hyperliquid DEPOSITS, WITHDRAWALS and TRANSFERS in financial.reconciliation_staging as source_type=onchain, status=pending_review — the sibling of ingest_hyperliquid_fills, which reads userFillsByTime and therefore sees TRADES ONLY. Deposits, withdrawals and internal movement change a cash account and no trade explains them: a −4.000 USDC withdrawal to Arbitrum on 2026-06-24 was never booked and is part of the +64.901,23 phantom surplus on BlackL - Hyperliquid/USD, the largest single gap in the register. 🚨 THAT WITHDRAWAL IS A send, NOT A withdraw — withdraw is only the legacy USDC-to-Arbitrum bridge path (last used 2025-11), so an implementation that handles deposit/withdraw and stops misses exactly the transaction this tool exists for, and misses it quietly. THE DESTINATION ADDRESS CLASSIFIES THE EVENT: 0x2222…2222 is HYPE’s system address (HyperCore → HyperEVM, own wallet to own wallet); 0x2000…0000 is the USDC system address and continues to another chain over CCTP; 0x2000…00<idx> is any other token’s system address (…00c5 = UBTC 197, …010c = USDT0 268); our own address inbound; anything else is a real counterparty — and the prefix is matched EXACTLY, because this account received 830791.76195 MAX from 0x207700bd207df757825f9193ef9c648c1c65e06a, which also begins 0x20 and is an ordinary counterparty that a startsWith('0x20') test would call a bridge. FEES ARE THEIR OWN ROW, NEVER NETTED: send/spotTransfer carry fee + feeToken + nativeTokenFee, and feeToken is frequently NOT the token that moved (2026-03-23: 3.0 UETH moved, 1.0 USDC charged; 2026-06-24: 4000 USDC moved, 0.00018352 HYPE charged), so each fee is booked against feeToken as a separate row in that currency — a staging row carries ONE currency, so a fee in another asset cannot be netted into the movement. Two fee CONVENTIONS, measured rather than assumed: withdraw’s fee is ON TOP (2025-08-29 moved exactly 2862.6 into perp for 2861.6 + 1.0), internalTransfer’s is INSIDE (the 7500.0 of 2025-06-09 credited the recipient 7499, whose next action moved exactly half) — either way the rows of one event SUM to its true effect on the account. INTERNAL MOVEMENT IS WITHHELD WITH ITS REASON, never dropped and never posted: spot↔perp (accountClassTransfer, and a send between two DEXes of the same wallet), spot↔staking and trading↔vault move nothing across the boundary, and booking this account’s 17 accountClassTransfer events — together roughly 100.000 USD — would invent an economic event for each. CUTOFF 2026-01-20: an earlier event is already in the ledger through the Airtable migration, so it is WITHHELD and COUNTED (pre_cutoff is flagged on every withheld row, whatever stronger reason won), never dropped. THE RECONSTRUCTION IS THE EVIDENCE: every response rebuilds spot / perp / staked balances from userNonFundingLedgerUpdates + userFunding + delegatorHistory + delegatorRewards + userFillsByTime (read-only — fills stay ingest_hyperliquid_fills’s to stage) and diffs them against the live venue balances, because a transfer ingest that is merely plausible is worthless: a missing kind, a fee on the wrong asset and a sign flip all produce reports that look correct. Measured on 0x8577a1a3…8edd (2026-08-18): HYPE, MAX, UBTC, UETH, USOL and USDT0 close to exactly zero and USDC to +0,000148 across spot+perp. HYPE is unreconcilable without the staking feeds — cStakingTransfer reports only 2 of the 5 real spot↔staking moves, and it is the SAME event as the finalized delegatorHistory withdrawal (identical timestamp, identical amount, both hash: 0x0000…0000), so counting both double-books 90 HYPE and counting neither loses it; the two are matched, and an unmatched one is applied AND named. IDEMPOTENT on source_hash = sha256(source_ref) where source_ref carries sha256(address‖endpoint‖time_ms‖canonical_json(delta)) plus the leg name — the key NEVER reads hash, because three of this account’s events report 0x0000…0000 and no ledger event carries a tid, the same defect class that forced the fills tool off tid; (time, hash) is unique across the real 78 events only by luck. nonce IS THE JOIN, NEVER THE IDENTITY: a send to 0x2000…0000 carries a nonce that appears VERBATIM in the destination chain’s CCTP hookData — 1782333848945 is bit-for-bit 0x0000019efb603d71 inside the Arbitrum mint 0xfc71d2cc…4dc6, immediately after this account’s own address — and it is emitted as cctp_nonce_hex; ⚠️ matching on amount and date returns the WRONG transaction, because that mint was 3999.8 after the CCTP fee while an unrelated burn of exactly 4000.000000 USDC exists on HyperEVM the same day, bound for Avalanche. A leg below 5e-9 is REPORTED rather than written: numeric(20,8) rounds it to zero and approve_staging gates on a non-zero amount, so it would be unapprovable for ever, in silence. An unmodelled delta.type is REPORTED too, never dropped — a silently skipped kind is indistinguishable from one that never happened. Wallets resolve from financial.onchain_address_book (by address or label, under the evm family that HyperCore and HyperEVM share) — there is NO all-wallets default, because reading every own wallet would call Hyperliquid for wallets that never traded there and report an empty, healthy-looking result for each. An UNRECOGNIZED argument key is REFUSED and named (real z.object(...).strict(), the PR #306 lesson). dry_run=true (DEFAULT). Rows land pending_review on the Reconciliation Preview — this FEEDS the human gate, it does not bypass it. No credential of any kind is needed: every Hyperliquid endpoint here is public and keyless. RLS-scoped user-JWT, never service_role. |
ingest_nubank | Ingest a Nubank PF or PJ statement (CSV format) into financial.reconciliation_staging. DIRECTION: the description verb (“Transferência enviada” / “recebida”, “Pagamento de boleto efetuado”) is persisted as source_raw.type = debit|credit — the rung publish_batch_atomic grades ABOVE sign(amount). A description that names no direction gets NO type: ungradeable is reported honestly, never guessed. |
ingest_onchain | Ingest an on-chain transaction history CSV (Etherscan / BSCScan / Snowtrace / Arbiscan) |
ingest_onchain_alchemy | AUTOMATIC on-chain ingest — pulls wallet transfers from Alchemy (alchemy_getAssetTransfers) into financial.reconciliation_staging with source_type=onchain, status=pending_review. The keyless-of-human sibling of ingest_onchain, which needs a PASTED Etherscan CSV and therefore never ran: the on-chain queue’s newest row was dated 2026-06-08 while roughly 21.000 USD of pods income went unbooked, which in turn let the matching BRL landings be double-counted as TPL-REV-MISC revenue. Wallets resolve from financial.onchain_address_book (by address, label, or ledger account code) — an address the book does not know is REFUSED, never scanned on trust. IDEMPOTENT on the (chain, tx_hash, log_index) natural key the database enforces with a partial UNIQUE, so re-running a range writes nothing new; dry_run runs the SAME duplicate check as the write, so the preview predicts the write. Accepts ingest_onchain’s chain spellings and canonicalizes eth → ethereum before keying on it (writing “eth” would miss the 99 existing “ethereum” rows and duplicate them). Alchemy serves ethereum / arbitrum / base / polygon; hyperliquid, hyperevm, btc and solana are REFUSED with the reason, never silently skipped. An UNRECOGNIZED argument key is REFUSED and named — every parameter here has a default WIDER than the typo it replaces, so a dropped key looks like a working call (the update_staging lesson, PR #306); enforced twice, by a real z.object(...).strict() and by a handler guard. from_date defaults to 90 days back, never all-time. CONSULTS financial.onchain_token_ignores: a row whose (chain, token_address) is on the denylist is still WRITTEN, then hidden with source_raw.suppressed=true — never skipped, because a skip loses the receipt, cannot be reversed, and leaves the natural key free so the next sweep re-creates the row. The reply states rows_auto_suppressed_denylisted and NAMES the tokens; un_suppress_staging reverses it. dry_run=true (DEFAULT). RLS-scoped user-JWT. Needs ALCHEMY_API_KEY in the server runtime env (Infisical /shared). |
ingest_uuv | Ingest a UUV / Binance |
ingest_woovi | Ingest a Woovi Pix-account CSV export (Revera/DFL-PJ payment account) into financial.reconciliation_staging. DIRECTION: the Tipo de Entrada column (charges export) or which side of the transfer is the Woovi account (movement export, where Valor is UNSIGNED) is persisted as source_raw.type = debit|credit — the rung publish_batch_atomic grades ABOVE sign(amount). A blank or unrecognised Tipo de Entrada yields NO type plus a warning, never a credit default. |
ingest_wise | Ingest a Wise (TransferWise) balance statement (CSV export) into financial.reconciliation_staging, and record the period read into financial.bank_statements. Wise keeps ONE BALANCE PER CURRENCY and exports one file per balance, named statement_<accountId>_<CCY>_<start>_<end>.csv — pass that filename as file_hint and the period is read from it, or state period_start/period_end. THE PERIOD IS REQUIRED: the tool REFUSES rather than guessing, because a fabricated period recorded as coverage reads as evidence, and the file that matters most (a header-only export) has no dates inside it at all. AN EMPTY STATEMENT IS A VALID, USEFUL INGEST — it means “no movement in this window”, which is a fact worth recording; without it, “Wise was quiet” and “nobody ever read Wise” are indistinguishable. DIRECTION: Wise prints a Transaction Type column, but this parser has no samples of its vocabulary yet, so rows carry NO source_raw.type — ungradeable is reported honestly and never derived from sign(amount); the raw token is stored for the day real rows arrive. |
insert_bank_balance_snapshot | Manually persist ONE bank or brokerage balance into financial.bank_balance_snapshots |
set_wallet_fiat_balance | Record the REAL (bank-statement) balance for a financial.wallets wallet (by name or wallet_id) so the Reconciliation Preview can compare projected (ledger) vs real per wallet. Resolves the wallet → canonical bank key / holder / currency (READ on user-JWT/RLS), then upserts one financial.bank_balance_snapshots row (WRITE on the caller’s user-JWT/RLS). Stamps created_by from the caller’s auth.uid(). Idempotent on (account_holder, bank, currency, balance_at). Feature-guarded until the bank_balance_snapshots table is migrated. |
delete_bank_balance_snapshot | Hard-delete ONE financial.bank_balance_snapshots row by snapshot_id (the complement of insert_bank_balance_snapshot / set_wallet_fiat_balance) — use to remove a stray or smoke-test snapshot from the Reconciliation Preview’s Current-balances panel. The DELETE runs through the caller’s user-JWT under RLS (member+, tenant-scoped), so a row outside the caller’s tenant matches nothing (deleted=false). Feature-guarded until the bank_balance_snapshots table is migrated. |
create_asset_type | Register a new asset in financial.asset_types — the GLOBAL lookup every ledger line, wallet and staging row resolves its currency against. Required BEFORE create_wallet or any posting can reference the asset: wallets.asset_type_id is NOT NULL, and publish_batch_atomic refuses a staging row whose currency does not resolve here. There is no tenant_id, so one row is visible to every tenant and INSERT is SUPERADMIN-only by RLS. A duplicate symbol is REFUSED (the existing id is returned) — two rows for one symbol would let two ledger lines claim the same currency and still compare unequal. UPDATE and DELETE are deliberately unavailable: re-labelling an asset silently re-interprets every posted line referencing it, so a correction goes through a dfl-schema migration. |
create_wallet | Create a financial.wallets row pointing at an EXISTING ledger account (ledger_account_id) — binds {account_holder_id, wallet_type_id, asset_type_id, ledger_account_id, name} so the Reconciliation Preview can attribute a real bank/brokerage account to its ledger cash account (e.g. Woovi 1.1.1.03 / Nubank-PJ 1.1.1.02). The ledger account must already exist (does NOT create accounts). Idempotent on ledger_account_id (never duplicates). READ (account validation) user-JWT/RLS; WRITE service-role (RLS-by-design). |
update_wallet | Patch an EXISTING financial.wallets row by wallet_id (the complement of create_wallet): name (rename), ledger_account_id (repoint to an existing account, validated), wallet_type (pass wallet_type_id as a uuid OR wallet_type as a case-insensitive name/slug, e.g. “Checking” → Checking Account), and is_active. Only the fields you pass change. dry_run=true (DEFAULT) returns the before/after diff WITHOUT writing — set dry_run=false to persist. Every read AND the UPDATE run through the caller’s user-JWT under RLS (member+, tenant-scoped) — never service-role. Use case: rename a wallet + fix its wallet_type (‘Brokerage’→‘Checking’) while keeping its ledger account. |
register_onchain_address | Register a public wallet address in financial.onchain_address_book — the book that decides which wallets are read on chain at all. It had readers and no writer until 2026-08-17: ingest_onchain_alchemy REFUSES an address the book does not list, and the balance collector scans only what the book lists, so a wallet nobody inserted by hand was unreachable (measured on prod: of 69 wallets with a non-zero projection, 33 had no on-chain snapshot and 19 of those had no address on file — they render “sem saldo real” permanently). CHAIN VOCABULARY, which is the trap: this table stores a chain FAMILY — only evm or solana. financial.onchain_balance_snapshots.chain stores the NETWORK (ethereum, arbitrum, base, hyperevm, hyperliquid, solana, binance), so the two columns do NOT join, and the column has no check constraint — a book row written as “arbitrum” is accepted by Postgres and then matches nothing, a silent empty join rather than an error. A network slug (ethereum / arbitrum / base / polygon / hyperevm / solana) is accepted and TRANSLATED to its family, with the translation reported in the response; anything else is refused with both accepted vocabularies listed. IDEMPOTENT on (tenant, chain family, lower(address)) — the key the existing unique index idx_onchain_address_book_tenant_chain_address enforces — so re-registering a known address UPDATES its label instead of failing, and re-running a batch of ten writes nothing new. EVM addresses are validated as 0x plus exactly 40 hex characters and stored LOWERCASE (every consumer compares with lower(), and the legacy rows are inconsistently checksummed); Solana addresses are validated as 32–44 base58 characters and stored exactly as given, because base58 is case-significant. is_own_wallet is REQUIRED with no default — true is our custody, false is a counterparty (the Binance deposit address is false) — because a default silently mislabels a counterparty as ours and a default scan then imports their whole history. NOT a replacement for update_wallet: binding a financial.wallets row to a chain + address stays that tool’s job, and a wallet usually needs both writes. There is NO tenant_id parameter — the tenant is resolved from the caller’s own RLS-visible tenants, so a call cannot address another tenant. Reads and write both on the caller’s user-JWT under RLS. |
list_onchain_addresses | List financial.onchain_address_book — every address that is read on chain at all. Use before register_onchain_address: registering is idempotent, so re-adding a known address under a different label RENAMES the entry ingest_onchain_alchemy selects wallets by. The chain column here is a chain FAMILY (evm / solana), NOT the network onchain_balance_snapshots.chain holds — the response repeats that so the two are not joined. Filters: chain (a family, or a network slug which is translated to its family) and own_wallets_only (excludes counterparties such as the Binance deposit address). RLS-scoped user-JWT, no tenant_id parameter. |
read_solana_transactions | READ-ONLY. Enumerate every asset movement of one Solana address over a time or slot window, straight from the chain: signature, slot, block time, entry date, direction, asset, SPL mint, decimals, exact amount and a best-effort counterparty. This is the SOLANA counterpart of ingest_onchain_alchemy, which cannot serve Solana — alchemy_getAssetTransfers is EVM-only and refuses the chain by name. It writes nothing: ingesting pre-cutoff on-chain history on top of the Airtable-migrated history double-counts, so a human reads and compares before any row is staged. Direction is the SIGN OF THE NET DELTA of the address in each transaction, not an instruction-level parse, so a swap, an LP move and a plain transfer all reduce to how much of what left or arrived. Amounts are exact decimal strings computed in integer units; the float uiAmount field is never read. It also reconstructs a PAST balance without an archive node: with reconstruct_opening_balance (default true) it reads the balances as of now and subtracts the window net, giving the balance at from_date. That reconstruction is WITHHELD, with the reason named, whenever the window is truncated, holds an unreadable transaction, or does not run to the present — an incomplete net looks exactly like a complete one. Uses the SAME ALCHEMY_API_KEY the EVM readers use (or SOLANA_RPC_URL); it refuses by name when neither is set and never falls back to the rate-limited public endpoint, which would silently under-report. |
redate_snapshot_observations | Repair financial.onchain_balance_snapshots rows whose recorded observation date (snapshot_at) disagrees with when the balance was actually read (fetched_at). WHY THE CLASS EXISTS: snapshot_at has a now() DEFAULT and a DEFAULT applies on INSERT ONLY, so whenever an adapter’s upsert anchor does not move between two runs the later run takes the ON CONFLICT DO UPDATE branch on the OLDER row — the balances are replaced and the date label stays put, and the row then claims to be an observation of a day on which nobody looked. Measured on prod 2026-08-17, chain='binance': 39 of 52 rows mislabelled across three groups (06-09→07-06, 07-20→07-27, 08-10→08-17), with 2026-07-13 the only honest point; the Binance adapter keyed on the account’s updateTime, an anchor carrying no date and spanning all history. dfl-financing #215 fixed that anchor going forward and could fix neither the written rows nor the class, so this tool keys on the SYMPTOM and names no chain, adapter or date — it finds the same defect in any other adapter without a code change. IT ONLY EVER WRITES snapshot_at := fetched_at; it never computes, rounds or infers a date, and a row with a NULL fetched_at is REPORTED as unrepairable rather than guessed at. INSPECT THEN APPLY: dry_run=true (DEFAULT) reports every candidate grouped by (current date → true date, adapter, chain) with a count, plus the unrepairable list and the collision verdict, writing nothing. dry_run=false REQUIRES expect — the row count and/or the exact from→to groups you believe you are changing — and REFUSES, writing nothing, when reality disagrees; the check runs in BOTH directions, so a real group you did not list refuses just as an absent one does, because a repair that silently re-dates more rows than you pictured is worse than no tool (afterwards a wrongly re-dated row is indistinguishable from a correct one). TOLERANCE: min_drift_hours, default 24 — one whole day. A row qualifies only when BOTH its UTC calendar date differs AND at least that many hours separate the two stamps, so a row read five seconds after midnight is NOT treated as mislabelled. COLLISION SAFETY: snapshot_at is part of two partial UNIQUE indexes (onchain_balance_snapshots_daily_dedup_idx for wallet_id IS NOT NULL, and ..._daily_dedup_unattributed_idx keyed on lower(address)/lower(chain) for wallet_id IS NULL, dfl-schema 20260817220000); both keys are reconstructed in the PREVIEW, per row, so a move onto an occupied date is reported instead of raised as a 23505 mid-apply, and ANY collision refuses the whole apply. A target held by ANOTHER row that is itself moving away is an ORDERING constraint rather than a collision, and the writes are ordered so the holder vacates first; a cycle of such constraints is refused. IDEMPOTENT: after a successful apply the same call reports already_consistent instead of refusing on the now-stale expectation. The chain / source_adapter / snapshot_ids filters NARROW the candidates only — the collision check always reads the whole visible table, because the row a candidate would land on top of can sit outside any filter. An UNRECOGNIZED argument key is REFUSED and named. Read AND write on the caller’s user-JWT under RLS; there is no tenant parameter. |
record_onchain_balance_snapshots | Record 1 to 25 exact on-chain balance observations. dry_run=true is the default. The tool validates the caller-visible wallet, address, chain, asset UUID, symbol, decimals, bucket, time, and raw source evidence. It writes only financial.onchain_balance_snapshots through the caller’s user JWT under RLS. It never uses service_role. One call uses one atomic array insert. An identical repeat is a no-op. A conflicting natural or daily key is refused. V1 accepts one aggregate for each wallet, asset, adapter, UTC day, and bucket. It does not accept a caller-defined holding suffix. Exact decimal strings do not pass through JavaScript numbers. source_evidence can contain response data, but it cannot contain URL strings or normalized credential keys such as x-api-key or private_key. Use stable provider and endpoint identifiers instead. The row is source-observation evidence only. It does not certify full transaction history, an opening balance, or wall coverage. The tool writes no wallet, ledger, staging, period-lock, or coverage-watermark row. |
delete_wallet | HARD-delete financial.wallets rows by an EXPLICIT allowlist (wallet_ids) — never a filter, so a sweep cannot happen by accident. Two fail-closed guards: FK dependents, discovered DYNAMICALLY via the financial.count_fk_dependents RPC (queries pg_constraint at call time, never a hardcoded table pair) — a wallet with ANY dependent row in ANY referencing table is refused, naming the table(s) and row count(s); a future FK is covered automatically. Non-zero balance (financial.v_wallets.balance) — refused unless allow_nonzero_balance=true. reason is REQUIRED (a hard delete leaves no row to stamp it onto — logged + echoed in the response instead). dry_run=true (DEFAULT) returns would_delete + refused without writing. Feature-guarded: refuses every wallet until the count_fk_dependents RPC migration is merged (fail closed). Every read AND the DELETE run through the caller’s user-JWT under RLS — never service-role. |
ignore_onchain_tokens | Add tokens to financial.onchain_token_ignores, the per-tenant denylist that keeps spam airdrops and dust out of the RED “sem atribuição” panel on /reconciliation/preview. Measured on prod 2026-08-18: of 42 unattributed rows, 20 were on base and 18 of those were one spam airdrop — eight of them carrying the identical amount 85.494595. A red panel that is 43% noise stops being read, which is the same outcome as hiding the rows. KEYED ON (chain, token_address), NEVER on the ticker: a ticker is a string an attacker picks, and on that same snapshot DRV existed on base AND on ethereum at two unrelated contracts, one of them ours — a list written on 'DRV' cannot express which was meant. Pass native: true instead of token_address for a network’s own asset, which has no contract and cannot be minted twice. evm is REFUSED as a chain: it is the FAMILY that onchain_address_book stores, and the column would accept it and then match nothing for ever. TWO EFFECTS, NOT ONE, and neither DELETES anything. Snapshot: onchain_balance_snapshots is untouched, no number moves, and v_onchain_snapshot_unattributed still RETURNS the row with is_ignored = true plus the reason; the panel filters it and states how many it hid, from the same array. Ingest (since PR #349): the shared staging writer reads the same table, so a NEW on-chain row for a denylisted token is WRITTEN and then stamped source_raw.suppressed = true — never skipped, because a skip loses the receipt and frees the (chain, tx_hash, log_index) slot that keeps the next sweep idempotent. BATCHED (spam arrives dozens at a time and the MCP is rate-limited), all-or-nothing validation before any write, dry_run default TRUE. matched_rows counts the SNAPSHOT side only, so matched_rows = 0 is still reported loudly but proves less than it used to: it means the entry hides no BALANCE row today — the signal that catches a wrong chain or contract — not that the entry is inert, because the ingest effect applies to every future on-chain row regardless. RLS-scoped user-JWT, no tenant_id parameter. |
un_ignore_onchain_tokens | Remove tokens from financial.onchain_token_ignores so they appear again in the RED “sem atribuição” panel. Takes the same targets as ignore_onchain_tokens, but is NOT a full inverse. It deletes a denylist row and nothing else. Snapshot: the balance was never modified, so the row simply stops being marked is_ignored. Ingest: the delete only stops FUTURE on-chain rows from arriving suppressed — staging rows already stamped source_raw.suppressed = true STAY suppressed, and this tool neither counts nor clears them; list them with list_staging include_suppressed=true and clear them with un_suppress_staging. Adding an entry is one call; undoing it fully is two. A target that is not on the denylist is an idempotent no-op, not an error. dry_run default TRUE, and reports how many SNAPSHOT rows would become visible again plus their USD total. RLS-scoped user-JWT. |
list_onchain_token_ignores | Show every denylist entry for your tenant, each with how many currently unattributed rows it hides, their USD total, and how many of those carry no price (kept separate — “we hold this and cannot value it” is not “this is worth nothing”). Also reports the totals: unattributed rows hidden vs still visible, read from the view’s own is_ignored flag rather than re-derived. Every count here measures the SNAPSHOT side only; each entry also suppresses new on-chain staging rows at ingest, and that side is not counted on this page. Entries whose matched_rows is 0 are still called out — the balance may now be attributed to a wallet, or the chain / contract was wrong when the entry was written — but a 0 means the entry hides no BALANCE row today, not that it is inert. A denylist you cannot inspect is a trap, which is why this ships with the writer and not after it. Optional chain filter. RLS-scoped user-JWT. |
list_asset_symbol_aliases | Show every row of financial.asset_symbol_aliases, resolved to the asset it means: the alias spelling, the canonical symbol and name, the asset_type_id, the recorded reason, and the timestamps. This is the ONE list that BOTH the preview and the SQL write path read. Before dfl-schema #850 the aliases lived only in TypeScript, so the dry run resolved UETH and financial.publish_batch_atomic — which resolves in plain SQL — refused the same batch; a preview that passes and a write that refuses is worse than either answer alone. GLOBAL data: no tenant_id and no tenant parameter, so one row is how EVERY tenant resolves that spelling. Resolution is ALWAYS exact asset_types.symbol FIRST and this table SECOND, never the reverse and never a heuristic. AUTHORSHIP IS NOT RECORDED — #850 shipped no created_by column, so “who added it” lives in the reason text and nowhere else. REMOVAL: use delete_asset_symbol_alias — superadmin-only, with a mandatory reason, recorded in the append-only financial.asset_symbol_alias_removals (removals DO record their author, unlike creations). It is not free: every staging row that resolved only through the alias stops resolving, and the fail-closed publish path then refuses the WHOLE batch. There is still no UPDATE path — correcting a target is remove-then-create. 🚨 AN ALIAS IS A SPELLING, NEVER A DERIVATIVE — see create_asset_symbol_alias. Optional alias_symbol / canonical_symbol filters, both case-insensitive. RLS-scoped user-JWT. |
create_asset_symbol_alias | Add one or more rows to financial.asset_symbol_aliases — the ONE list that BOTH the preview and the SQL write path (financial.publish_batch_atomic) read when a source reports a different SPELLING of an asset the ledger already carries. Use it when the fail-closed publish path refuses a batch because a currency “did not resolve to a financial.asset_types row”, AND you have confirmed the spelling is the same asset. 🚨 AN ALIAS IS A SPELLING, NEVER A DERIVATIVE: one unit of the alias IS one unit of the canonical asset, at a fixed 1:1, for ever (UETH is Unit-bridged ETH; USD₮0 is USDT0 with Tether’s ₮ glyph U+20AE). It must NEVER be used for a derivative whose exchange rate DRIFTS against the underlying — stHYPE, vHYPE, kHYPE, LHYPE, earnETH, THBILL, and every wrapped-yield / liquid-staking token (stETH, wstETH, rETH, cbETH, weETH, ezETH, sDAI, sUSDe, jitoSOL, mSOL). Those are worth MORE of the underlying every day the yield accrues: aliasing one MISSTATES THE BALANCE by the accrued yield, the journal entry still BALANCES so nothing raises, and the misstatement GROWS without limit. A drifting token needs its own asset_types row (create_asset_type), never an alias — those names sit on a LITERAL denylist and are refused. REFUSES, before writing anything: a canonical asset that does not exist; an alias whose spelling IS already a real asset_types symbol (dead data — the exact match always wins first); an alias of an alias or of itself (the resolver reads this table ONCE and stops, so a chain resolves to nothing); RE-POINTING an existing alias at a different asset (it would silently change where every future row with that spelling posts); a canonical_symbol and an asset_type_id that name two DIFFERENT assets. IDEMPOTENT: re-sending a pair already on the list is already_exists, never an error. ALL-OR-NOTHING: one bad entry means NOTHING is written. dry_run default TRUE — created counts rows ACTUALLY WRITTEN and is 0 in a dry run, while the entries that would be written are counted in would_create. reason is REQUIRED and stored on every row: an alias nobody can audit is how a WRONG alias survives. REVERSIBLE, BUT NEVER CHEAP — delete_asset_symbol_alias removes a wrong alias on a user-JWT (superadmin, mandatory reason, append-only tombstone). That is not permission to guess: removing an alias makes every staging row that resolved only through it unresolvable, so a batch that published yesterday refuses today, and it refuses WHOLE. There is still NO update path, so a wrong TARGET costs a remove plus a create. GLOBAL data, so the INSERT policy is iam.is_superadmin() and a non-superadmin is refused by name. Reads and the write both on the caller’s user-JWT under RLS. |
delete_asset_symbol_alias | Remove one or more rows from financial.asset_symbol_aliases — use it when an alias was WRONG (the spelling turned out to be a different asset, or a drifting derivative that should have been its own asset_types row). THE ROW IS HARD-DELETED, and an append-only TOMBSTONE is written to financial.asset_symbol_alias_removals in the SAME transaction: the alias spelling, the asset it meant, the ORIGINAL reason, your reason for removing it, and who you are. Nothing is lost — the evidence moves, it does not disappear. Hard delete rather than an is_active flag on purpose: no reader anywhere has to remember to filter, and the spelling is FREED, so the documented recovery (register it as its own asset with create_asset_type) actually works. ⚠️ WHAT REMOVAL MEANS: an alias is the ONLY thing that lets a source spelling resolve. Take it away and every financial.reconciliation_staging row whose currency resolved ONLY through it becomes unresolvable — a batch that published fine yesterday will REFUSE today, and it refuses WHOLE, because financial.publish_batch_atomic is fail-closed and one unresolvable row takes the good rows beside it down too. Already-posted journal entries are NOT touched or re-valued; the damage is to what you publish NEXT. The dry run COUNTS the affected staging rows for you, per entry and in total, under your own RLS scope — check them with list_staging before you persist. REFUSES: a spelling that is the CANONICAL asset of one or more aliases (ETH when you meant UETH) — it names the candidates rather than guessing, because a no-op reported as success on a call aimed at the wrong row is worse than an error; two casings of the SAME spelling in one call; a blank entry; a reason shorter than 10 characters. IDEMPOTENT: a spelling on no list reports not_found, never an error. ALL-OR-NOTHING: one bad entry means NOTHING is removed. dry_run default TRUE — removed counts rows ACTUALLY DELETED and is 0 in a dry run, while the entries that would be deleted are counted in would_remove. reason is REQUIRED (10–2000 chars) and stored on the tombstone next to the reason the alias was created with, so the two read as one story; the database enforces the floor too, because a DELETE statement has nowhere to put a reason — which is exactly why removal goes through financial.remove_asset_symbol_aliases() and not through a grant. NOT AN UNDO FOR A RE-POINT: there is still no UPDATE path on this table, by design. Correcting a target is remove-then-create — two audited events, each with its own reason. SUPERADMIN-only (iam.is_superadmin()), GLOBAL data, caller’s user-JWT under RLS throughout. Ships with dfl-schema #866. |
Balance check on every bank-statement ingest
Section titled “Balance check on every bank-statement ingest”ingest_bb, ingest_c6, ingest_nubank and ingest_woovi reconcile the file
against the account’s real balance, and report the result in balance_check.
ingest_uuv does NOT — despite its neighbours it writes source_type=binance,
not a bank statement.
Why it exists. On 2026-08-11 a Woovi statement was exported with the payments view instead of the movement view: outflows only, zero inflows, across a 40-day window. The ingest accepted it with no error and no warning. Every row in it was real — only the total was wrong, and only against a fact outside the file. A partial statement is indistinguishable from a complete one by inspection.
The arithmetic.
opening posted-ledger balance of the bank's cash account, entry_date < first row+ net EVERY row in the file, including rows the writer skipped as duplicates+ bridge posted-ledger movement between the last row and the balance observation= implied closing balance
delta = implied − realdelta > 0 means the file is missing outflows. delta < 0 means it is
missing inflows. The response says which, in words.
Verdicts. reconciled (equal within tolerance), MISMATCH (with the delta
and its shape), unverified (no usable closing balance). Every verdict is also
emitted as its own plain-text block in the response, because the failure this
guards against was a quiet one.
Duplicates count. Dedup decides what gets written; this check asks what moved. The opening balance stops strictly before the window, so a row dated inside it contributes nothing there — dropping deduped rows from the net would invent a gap in a complete file.
Tolerance is 0.01 — currency rounding noise, the same number
check_batch_divergence uses. It is deliberately NOT wide enough to absorb the
115,53 residual that the complete Woovi export still shows, because a
tolerance that swallows a residual also swallows a missing payment.
Arguments (all optional, all reporting-only): closing_balance,
closing_balance_as_of, balance_tolerance. Without a closing_balance the
tool falls back to the newest financial.bank_balance_snapshots row for the
bank; a snapshot dated before the window’s end is refused as stale, and one more
than 7 days after it is refused as unattributable.
It never blocks. A mismatch does not refuse the ingest. The rows are real
and they are staged; a failure inside the check degrades to unverified.
Staging
Section titled “Staging”| Tool | Description |
|---|---|
classify_bulk | Re-run the classifier over MANY financial.reconciliation_staging rows at once. Deterministic by default (skip_llm=true): resolves via routing_rules → routing_memory → heuristic → sign fallback — no LLM. Selection REQUIRES an explicit selector: staging_ids (array-of-UUID allowlist, min 1, takes precedence) OR filter (status/ai_category/source_type, at least one field set), capped by limit (default 50, max 500). A call with NEITHER is REFUSED — “re-classify everything up to the limit” is reachable only by stating filter: {"status": "pending_review"} outright, never by omission. An UNRECOGNIZED key is REFUSED and named, never silently dropped. This matters MORE here than on update_staging: the sibling tool writes the fields you named, but classify_bulk RE-RUNS the classifier, so a swept row has its ai_category / ai_template_code / routing / holder / cost_center recomputed — silently REVERTING hand-made human corrections. The same defect was measured live on 2026-08-12 on update_staging, which calls the same selector function: staging_id / ids / id each named ONE row and each returned processed:50 against the production queue. Enforced twice: the input schema is a real z.object(...).strict() so the MCP SDK refuses the key before the handler runs, plus a handler guard whose error names the key and lists the accepted selectors. dry_run=true (default) previews before/after diffs without writing. Never changes row status. |
execute_group | Settle a whole reconciliation group (a transfer’s classified send-leg + its matched counterpart legs) as ONE action: post EXACTLY ONE balanced transfer JE from the classified (TPL-XFER-*) primary leg’s template, then mark ALL still-in-staging legs status=executed pointing at the SHARED journal entry — so the counterpart leg goes terminal + leaves staging instead of orphaning. Idempotent (already-executed group → no-op). Reuses the executed status (no schema change). RLS-scoped user-JWT (the human approves). Each leg is denominated in ITS OWN account’s currency — the currency of an account is the asset of the wallet bound to it, not the currency of the row. A CONVERSION between two assets posts the row’s magnitude on its own leg and usd_value_at_block on the USD leg, with the same usd_value on both, so the entry balances in dollars. A CRYPTO asset leg against a revenue/expense account books that P&L leg in USD from usd_value_at_block instead of writing a token quantity into an income account (a BRL expense stays BRL — the asset’s crypto category is what fires the rule). A row that needs that second amount and does not carry it REFUSES: no rate is invented and there is no fallback to a single amount. The per-leg result is reported as legs (mode same-asset |
list_staging | List financial.reconciliation_staging rows for a tenant. Useful when the LLM needs to |
post_from_staging | Promote an APPROVED financial.reconciliation_staging row into the canonical double-entry Each leg is denominated in ITS OWN account’s currency — the currency of an account is the asset of the wallet bound to it, not the currency of the row. A CONVERSION between two assets posts the row’s magnitude on its own leg and usd_value_at_block on the USD leg, with the same usd_value on both, so the entry balances in dollars. A CRYPTO asset leg against a revenue/expense account books that P&L leg in USD from usd_value_at_block instead of writing a token quantity into an income account (a BRL expense stays BRL — the asset’s crypto category is what fires the rule). A row that needs that second amount and does not carry it REFUSES: no rate is invented and there is no fallback to a single amount. The per-leg result is reported as legs (mode same-asset |
approve_staging | Move staging rows from status=pending_review to status=approved — the MIDDLE step of the pending_review → approved → executed lifecycle, which until now NO tool performed (only the preview UI did). That gap made publish_batch unreachable: publish_batch skips every row that is not approved, and it is the ONLY publish path that is previewable (dry_run), grouped into one reconciliation_batches row, and reversible with reverse_batch. APPROVAL MEANS READY TO POST, so a row that publish_batch would then silently skip is REFUSED here rather than moved one step down the pipeline: the row must have a resolved posting account (ai_template_code OR ledger_account_id), a resolvable tenant (target_tenant_id, or a target_tenant_slug that actually resolves), a currency, and a non-zero amount. ⚠️ A NEGATIVE amount is POSTABLE and is NOT refused — a bank outflow is stored negative and posts as its absolute value (the template, not the sign, decides debit vs credit); only a ZERO or NULL amount is refused. Only pending_review rows are eligible: an executed row is REFUSED with NO override parameter, rejected and error rows are REFUSED and named, and an already-approved row is an idempotent no-op SKIP that does not abort the batch. A row soft-hidden by suppress_staging is refused (un-suppress it first). Explicit staging_ids allowlist, no filter mode. ALL-OR-NOTHING: any refusal ⇒ nothing written, even with dry_run=false. reason required and recorded into reviewer_notes (a pre-existing note is preserved, never clobbered). dry_run=true (default) previews the before/after diff + refused list without writing. NEVER touches amount / journal_entry_id / source_raw. RLS-scoped user-JWT. |
reject_staging | Move specific staging rows to the TERMINAL status=rejected state with a reviewer_notes audit stamp — the canonical terminal for a row that must never be posted (e.g. a confirmed phishing / address-poisoning token). update_staging & validate_staging_row never change status, suppress_staging only soft-hides, post_from_staging is the ACCEPT terminal. NEVER touches amount / journal_entry_id / source_raw. Requires an explicit staging_ids array + reason (no bulk sweep). Skips executed + already-rejected rows. dry_run=true (default) previews before/after without writing. RLS-scoped user-JWT. |
link_staging_legs | Record that staging rows are the COUNTERPART LEG of a money movement ALREADY booked by a posted journal entry — the second statement’s view of a transfer between two accounts we own. One transfer appears on TWO statements but needs ONE journal entry; whichever statement is ingested second yields a real row that must never be posted, because posting it would double the movement. Writes the convention the ledger already uses for linked money-movements: both legs share one reconciliation_group_id, the surviving leg stays status=executed carrying the journal_entry_id, the linked leg goes status=rejected with an audit note, and the pair nets to zero. Fills the gap left by reconcile_match, which loads ONLY pending_review rows and so can never pair a pending row with an already-executed leg. journal_entry_id is REQUIRED and must be posted + non-voided for the caller’s tenant — the tool NEVER searches by amount; amount and date are corroborating CHECKS and a row failing either is skipped. Executed rows are skipped; already-rejected rows are grouped WITHOUT a status change (the repair path for a transfer whose legs were both rejected and never linked, which is how a movement becomes invisible on both statements). The group is written status=resolved so a later reconcile_match run cannot delete the link. Idempotent — a repeat call reuses the group for that entry. A linked row cannot be posted by approve_staging, post_from_staging, publish_batch or execute_group; only the deliberate unreject_staging returns it to review. dry_run=true (default) previews without writing. RLS-scoped user-JWT. |
unreject_staging | The exact inverse of reject_staging — move specific staging rows from the TERMINAL status=rejected state BACK to status=pending_review (the active review queue), with a reviewer_notes audit stamp (reason APPENDED to preserve the prior rejection note). Unblocks reconciliation: reject was a one-way terminal, so legit rows rejected in error got stuck in rejected forever. NEVER touches amount / journal_entry_id / source_raw. Requires an explicit staging_ids array + reason (no bulk sweep). Only status=rejected rows are eligible; any other status is skipped (already-pending_review is an idempotent no-op). dry_run=true (default) previews before/after without writing. RLS-scoped user-JWT. |
reset_unposted_staging | Return staging rows that claim status=executed but NEVER reached the ledger back to status=pending_review (the active review queue), clearing the broken journal_entry_id + executed_at. status=executed is an unbacked claim — no FK and no CHECK ties it to the entry actually posting — so a row can sit in executed while its journal_entry_id points at a DRAFT that never posted (often an empty shell with zero lines) or is NULL outright. No other tool can fix that: update_staging refuses status changes, unreject_staging only reverses rejected, un_suppress_staging only reverses suppression, reverse_batch only covers batch-published rows. HARD GUARD with NO override parameter: a row pointing at a POSTED entry is ALWAYS REFUSED — a reset row re-enters approval and would double-book. The guard PROVES non-posting rather than assuming it, so an unreadable entry (deleted, or invisible under RLS) is refused too — run this BEFORE delete_draft_entries, never after. ⚠️ WHAT THE GUARD CANNOT PROVE: a row with journal_entry_id IS NULL has no pointer to follow, and a NULL pointer does NOT mean the transaction is unbooked — the entry may live on the COUNTERPART leg’s staging row, because one bank transfer appears on TWO bank statements but needs ONE journal entry (measured 2026-08-11: 8 TPL-WOOVI-FUND Nubank PJ outflows were NULL and all CORRECT — resetting them would have double-posted 60.252,50). That evidence lives on the other bank and this tool never sees it, so it is deliberately NOT detected in code; instead every dry_run row carries its template code, description, amount, date, bank and tenant, each NULL-pointer row carries a caution, and the summary hoists a top-level warnings entry naming the ids. Read it before confirming. A voided entry is refused by default (pass allow_voided=true). Explicit staging_ids allowlist, no filter mode. ALL-OR-NOTHING: any refusal ⇒ nothing written, even with dry_run=false. reason required, stamped into reviewer_notes with the cleared pointer. dry_run=true (default) previews would_reset + refused without writing. RLS-scoped user-JWT. |
suppress_staging | Audit-preserving SOFT-suppress of specific staging rows — stamps source_raw.suppressed=true so the rows are hidden from list_staging / the Preview WITHOUT deleting them and WITHOUT changing status. Requires an explicit staging_ids array (no bulk sweep). Skips executed + already-suppressed rows. dry_run=true (default) previews before/after without writing. |
un_suppress_staging | The exact inverse of suppress_staging — restore a soft-suppressed row by clearing source_raw.suppressed (sets false + optional unsuppressed_reason/unsuppressed_at) so the rows are VISIBLE again in list_staging / the Preview (back to the active pending_review queue) WITHOUT deleting them and WITHOUT changing status. Requires an explicit staging_ids array (no bulk sweep). Skips executed + not-suppressed rows. dry_run=true (default) previews before/after without writing. RLS-scoped user-JWT. |
update_staging | Patch classification/attribution fields (account_holder_id, ledger_account_code, ledger_template_code, ai_category, cost_center_id, usd_value_at_block) on staging rows. Selection REQUIRES an explicit selector: staging_ids (array-of-UUID allowlist, min 1, takes precedence) OR filter (status/ai_category/source_type, at least one field set), capped by limit (default 50, max 500). A call with NEITHER is REFUSED — “patch everything up to the limit” is reachable only by stating filter: {"status": "pending_review"} outright, never by omission. An UNRECOGNIZED key is REFUSED and named, never silently dropped: measured live on 2026-08-12, staging_id / ids / id each named ONE row and each returned processed:50 against the production reconciliation queue, because the dropped key left no selector and the sweep took over — only the dry_run default prevented 50 corrupted rows. Enforced twice: the input schema is a real z.object(...).strict() so the MCP SDK refuses the key before the handler runs, plus a handler guard whose error names the key and lists the accepted selectors. Never changes status. Skips executed rows by default. dry_run=true (default) previews before/after diffs without writing. |
validate_staging_row | Record a HUMAN classification decision on a reconciliation_staging row and LEARN from it. |
backfill_nubank_identifier_refs | Idempotent maintenance backfill — re-keys LEGACY Nubank staging rows onto the statement’s native Nubank Identificador (source_raw.identifier), rewriting source_ref to bank_statement:nubank:<identifier> and source_hash to sha256(that ref) so a full re-ingest of the same months stays deduped. Fixes the re-ingest-creates-duplicates bug (legacy file-hash key vs new Identificador key → different source_hash → dedup miss). NEVER changes status / amount / journal_entry_id. Idempotent (identifier_used_as_ref guard) + collision-safe (a hash owned by another row is reported & skipped). RLS-scoped user-JWT. dry_run=true (default) reports how many rows would be re-keyed without writing. |
backfill_row_signatures | Idempotent maintenance backfill — compute and persist the stable row_signature on every financial.reconciliation_staging row where it IS NULL, using the ONE canonical definition (rowSignature() in classifier/routing.ts, the same pure function applyRouting and validate_staging_row call), so no second signature space is created. WHY ROWS ARE NULL: before 2026-08-21 the column was only written when a routing rule/memory MATCHED (updateClassification, behind if (c.routing)) or when a human validated the row; ingest never wrote it — so rows matching nothing stayed NULL forever (340 of 1073 on prod). WHAT THAT BROKE: the phase-1.5 re-import duplicate sweep, which skips NULL signatures, leaving a third of the corpus invisible to it. It did NOT break routing_memory auto-application — applyRouting recomputes the signature from the row in memory and never reads this column. Writes EXACTLY ONE column (row_signature); never status, ai_template_code, ai_category, was_manually_edited, target_tenant_slug or journal_entry_id. Rows whose new signature already exists as a routing_memory key are REPORTED in memory_matches and otherwise untouched. Idempotent by construction (only NULL rows are selected, so a second run reports zero). Filterable by tenant_id / source_type / statuses. RLS-scoped user-JWT. dry_run=true (default) reports what would be stamped without writing. |
Routing rules & memory
Section titled “Routing rules & memory”| Tool | Description |
|---|---|
create_routing_memory | Record a learned (validated) classification decision in financial.routing_memory — the |
create_routing_rule | Create a financial.routing_rules entry — a declarative human classification rule |
list_routing_memory | List financial.routing_memory rows — the learned/validated classification decisions |
list_routing_rules | List financial.routing_rules for a tenant — the declarative human classification rules |
routing_memory_reachability | READ-ONLY measurement: how many financial.routing_memory rows the classifier can actually reach, split by signature scheme (A = the MCP classifier, exact amount; B = the dfl-financing SPA, banded amount), how many staging rows match under each, how many rows the human-settled gate withholds from scheme B, and every scheme A/B conflict. The two schemes are near-disjoint: measured on prod 2026-08-21, 304 of 308 memories were reachable ONLY by scheme B, because routing_memory stores just row_signature plus the validated outcome and keeps no source fields — so a scheme-B key can never be recomputed into scheme A. Reports the pre-dual-key baseline (matching_a_only) alongside the current figure so the delta is readable, and counts gated_settled separately because those rows are deliberately NOT re-classified. Writes nothing, to any table. RLS-scoped user-JWT. |
settled_memory_disagreements | READ-ONLY report (no write path of any kind — not a dry-run flag on a writing tool): every already-settled reconciliation_staging row carrying a routing_memory decision that DISAGREES with what the row was actually classified as. Dual-key matching made 304 previously-unreachable memories reachable, which made ~428 already-settled prod rows suddenly match one; applyRouting withholds those on purpose, because silently re-posting over a human decision is worse than the bug it would fix. This lists only the withheld memories that are actually WRONG — agreements are counted and never listed, because a report of all 428 rows is unreadable and therefore useless. Each item is decidable on one line: row identity + date + amount, what the row was classified as (target_tenant_slug / ai_template_code / cost_center_id / routing_source), what the memory says, which signature scheme reached it (a = exact amount, the MCP scheme; b = banded, the dfl-financing SPA scheme), why it was withheld (human_settled_gate vs shadowed_by_scheme_a) and which fields differ. Generic: the comparison window is parameters — settled scope, statuses, entry-date range, schemes, withheld_only, limit. A NULL memory field is NO OPINION, never a disagreement. Both signatures and the settled gate come from @devfellowship/financing-core, the same functions applyRouting calls, so the report and the engine cannot drift apart. ALWAYS read verdict before disagreements.count: reconciliation_staging is RLS tenant-scoped and an identity outside those tenants reads 0 rows with HTTP 200 and error:null, not a 403 — so the tool reports CANNOT_READ_CORPUS instead of rendering an empty, reassuring report. RLS-scoped user-JWT; never service_role. |
update_routing_rule | Patch an existing financial.routing_rules entry by id — change priority, toggle active, |
Reconciliation batches
Section titled “Reconciliation batches”| Tool | Description |
|---|---|
publish_batch | Publish an EXPLICIT list of APPROVED reconciliation_staging rows as ONE reversible batch: creates a financial.reconciliation_batches row, posts one journal entry per staging_id (stamped with the batch id), marks each staging row executed, and stores a per-wallet projected-balance snapshot on the batch. Rows not approved / not resolvable / already executed are skipped with a reason (partial publish is fine — it is reversible). dry_run=true previews without writing. Feature-guarded until the dfl-schema batches migration is applied. The same per-leg denomination rule applies here — post_from_staging, execute_group and publish_batch all build their entry with the one shared builder, so the three surfaces cannot disagree. |
reverse_batch | Undo a published batch by VOID + RESET (NOT reversing-entries): voids every posted journal entry stamped with the batch id (existing void mechanism → voided_at + void_reason) and resets each linked staging row to approved/journal_entry_id=NULL so it is re-publishable, then marks the batch reverted. Idempotent (already-reverted → no-op). Never hard-deletes. dry_run=true reports the counts that would change without writing. Feature-guarded until the dfl-schema batches migration is applied. |
get_batch | Read one reconciliation batch: label, status (open|published|reverted), created/reverted provenance, its journal entries (id/date/status/posted_at/voided_at), and the stored projected_snapshot / real_snapshot. Read-only, RLS-scoped. Feature-guarded until the dfl-schema batches migration is applied. |
list_batches | List financial.reconciliation_batches rows (newest first), optionally filtered by tenant and status (open|published|reverted). Read-only, RLS-scoped. Feature-guarded until the dfl-schema batches migration is applied. |
check_batch_divergence | Compare a batch’s stored projected-balances snapshot (captured at publish time) against freshly-fetched REAL balances (bank_balance_snapshots, latest per bank+currency). Returns per-wallet projected/real/diff, a total absolute divergence, and a batch-level diverged boolean (true if any wallet with a known real balance exceeds the threshold) — the evidence to justify a reverse_batch. Read-only, RLS-scoped. Feature-guarded until the dfl-schema batches migration is applied. |
snapshot_account_drift | Persist the durable per-account drift snapshot: score every active wallet’s computed balance (from POSTED journal entries, financial.v_wallets) against its newest real balance (onchain_balance_snapshots / bank_balance_snapshots), and write one reconciliation_runs row per tenant plus its reconciliation_diffs rows. Unit-safe: a token account is scored against a token QUANTITY and a USD/BRL account against a currency VALUE — a wallet whose only real balance is in the other unit is EXCLUDED, never compared. Full coverage report: every wallet appears either scored or excluded with a reason (no-real-balance / stale-real-balance / unit-mismatch), so “all green” can never mean “nobody looked”. Closes on max(absolute floor, close_pct). dry_run defaults true. RLS-scoped user-JWT. |
Audit (read-only)
Section titled “Audit (read-only)”These four tools REPORT and never act — no writes, no status changes, no rejections. They exist because the same questions used to be answered with hand-written ad-hoc SQL against prod, and two reported errors came from bugs in that untested SQL. The first three share one classifier, which imports the bank-name alias table and the ledger-code ancestry predicates from the posting path (db/cash_leg.ts) rather than re-encoding them. The fourth, list_wallet_reality_feed, goes one step further and asks a merged VIEW instead of any client-side join at all.
| Tool | Description |
|---|---|
account_close_readiness | Which account can we close next, and what does it cost? Per wallet: the posted BOOK balance, the REAL balance and its date (bank_balance_snapshots for fiat, onchain_balance_snapshots for on-chain), the current drift, the staging queue by status with PER-CURRENCY sums, how many queued rows have no template, how many would post to the wrong place, the projected balance after the queue posts, and the residual gap. Sorted cheapest-to-close first, with the ordering criterion printed in an ordering block — the score is a COUNT OF OPEN ITEMS, never a money amount, so it is comparable across a BRL bank and a token wallet. Unit-safe: on-chain wallets are compared in TOKEN UNITS, never USD, and every figure carries its unit. Full coverage: a queue row that cannot be attributed to a wallet is reported in an explicit unattributed bucket with a reason, computed BEFORE any wallet filter, so narrowing the report can never make a problem vanish. Reads financial.v_projected_wallet_balances as a cross-check — that view drops the rows its naive bank-name comparison cannot attribute, so a view_projected_agrees:false marks a wallet whose rows the view lost. Read-only, RLS-scoped. |
staging_routing_audit | Flag every queued staging row whose classification would post to the wrong place, by category, each with a count, per-currency sums and row samples. Categories: no_template; template_missing_for_tenant; generic_cash_leg (INFORMATIONAL — the template cash leg is a strict ANCESTOR of the row’s own bank account, which the poster substitutes at post time); wrong_bank (ERROR — the template cash leg is a DIFFERENT SPECIFIC account, a sibling, which is NOT substituted, so the entry posts silently wrong); ambiguous_cash_leg (the post refuses); inactive_account_leg; template_has_no_cash_leg; template_legs_unreadable. The row’s own bank account is resolved through the SAME alias table the poster uses, because source_bank_name does not equal the wallet name (Nubank PF → Nubank/BRL, Banco do Brasil → BB PJ) and a naive comparison manufactures false mismatches. A row whose bank cannot be resolved goes to an explicit unattributed bucket with a reason, never dropped. Read-only, RLS-scoped — it never writes and never rejects. |
list_wallet_reality_feed | Read financial.v_wallet_reality_feed — one row per wallet, answering “does this wallet have anything to check itself against?”. USE THIS INSTEAD OF HAND-WRITING THE JOIN: doing it by hand was wrong twice in one hour on 2026-08-26 — one answer called 35 of 43 cash wallets empty while five banks held 721 staging rows, and the other asked a human for a bank statement for an account that already held 132 rows. The join is four-way and every leg fails silently: reconciliation_staging.ledger_account_id is NULL on 1,020 of 1,175 rows (86.8%); source_bank_name carries the STATEMENT label (“Banco do Brasil”), not the wallet name (“BB PJ/BRL”); the financial.bank_wallet_aliases map closes that gap only once it is seeded; and bank_statements, bank_balance_snapshots and onchain_balance_snapshots are three further legitimate surfaces. The four flags DO NOT mean the same thing — has_statement is the ONLY one that proves a statement WINDOW WAS READ, so it is what separates “there is no feed” from “nobody ever read it”. 🚨 The response lifts alias_rows — a GLOBAL count of active bank_wallet_aliases rows — to the TOP LEVEL and WARNS LOUDLY when it is 0: an empty map makes every bank wallet read “no feed” for a reason that has nothing to do with the wallet, which is the 2026-08-26 error arriving through a new door. It is read on its own unfiltered query, so an empty page (the happy path under only_missing=true) still reports the count. Filters: only_missing (DEFAULT true), include_clearing (default false — a clearing account is a bookkeeping waypoint, not money anyone holds), include_inactive (default false), min_abs_balance, limit (default 100, max 500). Read-only, RLS-scoped on the caller’s user-JWT; there is no tenant_id parameter. |
find_staging_duplicates | Find reconciliation_staging rows ingested more than once, and report each group with EVERY copy’s ingestion date so an operator can tell a re-import from a genuine same-day repeat. The ingest dedup keys only on source_hash, and a re-import of the same statement gets a different one, so it never fires; this groups on the business identity of the movement instead — (tenant, entry_date, amount, currency, normalized description). Each group carries signal=likely_reimport (copies arrived in more than one ingestion run) or signal=same_ingestion_run (every copy arrived together — two identical R$225 payments on one day are REAL, so do NOT reject on this alone), plus distinct_source_hashes and distinct_row_signatures, which explain why the dedup missed the group. Read-only: it never rejects, mutates or suppresses anything and has no write path. Use reject_staging, with a human decision, to act on what it reports. |