feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate (#2294) (#2346)
* feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate The dashboard match-batch route refused BATCH_TX_POSSIBLE_DUPLICATE when posted, unlinked vouchers already summed to the bank row (PR #2300), but the MCP door (gnubok_match_batch_allocate staging + commitMatchBatchAllocate) called the RPC with no guard, so an agent could book a Bankgirot aggregate a second time. The detector existed once; the guard lived in one door. One shared decision helper, lib/invoices/already-explained-guard.ts, now sits on top of the existing detectors (no fork) and is called by the dashboard route, the MCP staging tools and the commit executors: - gnubok_match_batch_allocate refuses to stage, coded BATCH_TX_POSSIBLE_DUPLICATE, naming the vouchers, the reconcile_match / link_transaction_to_journal_entry call that resolves the row, and the exact force + expected_journal_entry_ids binding. - commitMatchBatchAllocate runs the same guard before the RPC and re-validates a staged force binding against the set detected at commit, so a stale approval cannot book a duplicate; 409 auto-rejects with the vouchers in result_data. - force + expected_journal_entry_ids on the tool mirror MatchBatchSchema; an honoured override stages with a compliance_warning and, after the booking succeeds, writes BankTransactionDuplicateDismissed to behandlingshistorik (dashboard route included; it only logged before). - gnubok_match_transaction_to_invoice and commitMatchTransactionInvoice get the dashboard's 1:1 soft-duplicate guard (MATCH_INVOICE_POSSIBLE_DUPLICATE / MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH) with force + expected_journal_entry_id; at commit it runs before the storno. - Registry: both duplicate codes gain retryable: false and a remediation. Catalog payload held under the 60K ceiling by trimming the two tools' own descriptions (59 988 measured, ledger entry in payload-size.bench.test.ts). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * docs(decisions): record the 2026-09-06 ten-issue batch's first-principles choices Carries the DECISIONS.md lines for PRs #2337 #2339 #2340 #2341 #2342 #2343 #2344 #2345 #2346 #2347 in one place so the ten branches do not conflict on this file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(mcp): refuse an unverifiable forced override, surface a failed duplicate check, validate the binding (#2294 review) Review round on PR #2346 (CodeRabbit + compliance): - guardAlreadyExplained returned 'clear' when the detector threw even with force=true, so a forced 1:N override could book without re-validating expected_journal_entry_ids and left no behandlingshistorik record. It now returns a distinct 'unverifiable' outcome under force (mirrors guardDuplicatePaymentVoucher); the dashboard route, the MCP staging tool and the commit executor all refuse it with the new registry code BATCH_TX_EXPLAINED_CHECK_FAILED (409, retryable, remediation). Regression tests on every caller. - A detector failure without force still fails open at stage time, but no longer silently: the tools track onDetectError and stage a complianceNote, so preview_data.compliance_warning is set on both match_batch_allocate (GenericPreview renders it) and match_transaction_invoice (MatchTransactionInvoicePreview now renders data.compliance_warning through AttnLine). - expected_journal_entry_ids / expected_journal_entry_id are validated at the MCP boundary (array of 1 to 10 non-empty strings / non-empty string) and refused with VALIDATION_ERROR instead of being silently filtered. No schema description text added: catalog payload unchanged. - RoPA: .compliance/ropa.yaml gains bookkeeping.duplicate_dismissal_history for the BankTransactionDuplicateDismissed record (Art. 6(1)(c), BFNAR 2013:2 p. 9.16, retention per BFL 7 kap, stored in processing_history). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
39d409d257
commit
cce0de5704
@@ -1610,6 +1610,16 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-05] Utlägg phase 2, repayment from the bank line: create_expense_payout_batch takes an optional bank transaction and, under the same row locks, requires an unbooked SEK outflow equal to the claims' total and stamps it (journal_entry_id, is_business, reconciliation_method) in the transaction that posts the verifikat and flips the claims to paid. The pairing bank line ↔ person is computed at read time from the open claims (exact öre match on one person's total, ambiguous totals skipped), surfaced as a suggested match on Hem and as the inbox row's primary button, instead of a new hint column on transactions: the candidate pool is the handful of open claims, so a column written at ingest would add schema and a stale-pointer class for no saving. Old 6-parameter RPC signature dropped in the same migration so a 6-argument call cannot become ambiguous. "Betala ut" on /expenses stays for companies paying from an account without a feed. "Via lön" and the supplier-form control are split into their own issues rather than stretched into this PR.
|
||||
[2026-09-06] Utlägg gaps closed on the phase 2 PR: (1) a manual "Matcha mot utlägg" picker in the inbox row menu for outflows the exact-amount pairing missed, offered only while the company has open claims; the sum of picked receipts must equal the row to the öre and the same RPC books it, so a partial receipt can never be marked paid (partial payout inside the RPC was considered and left out: it changes money logic for a rare case, rounding a transfer up is not solved). (2) A foreign receipt (currency other than SEK) defaults VAT to 0 in the Underlag dialog with a note: foreign VAT is not deductible on 2641, and the wizard's seller-country step is gone from this path. (3) Enskild firma: a claim on 2018 is egen insättning, not a debt; it is excluded from Att göra, the attention resource, suggestions and the picker, and a payout for it debits 2013 (eget uttag) instead of 2018.
|
||||
[2026-09-05] SIE precheck refuses a closed or locked containing year up front (conflict verdict with the remedy: Öppna igen / Lås upp) instead of letting the voucher RPC fail with the trigger text; the årsredovisning warns when the comparison year has no entries instead of deriving BR comparatives from the IB voucher: derivation would hide that the RR comparatives are still unknown, and manual/IB comparatives after a migration are a product decision (follow-up issue).
|
||||
[2026-09-06] Fiscal-year edit dialog (#2287, PR #2337) lets the name follow typed dates only while the existing name has fiscalYearName()'s shape (isDerivedFiscalYearName), not only when it equals the name for the current dates: the customer row's "Räkenskapsår 2027" never matched its dates and would otherwise have stayed wrong after the dates were fixed; a hand-written name is never overwritten. FiscalPeriodEditor (Settings > Företag, first year only, i18n-keyed name that writes "Fiscal year N" in the EN locale) is now a strict subset of Ändra and was left in place as a founder call because removing it is user-visible.
|
||||
[2026-09-06] Article company-scope for invoice lines (#2059 part 2, PR #2339) enforced once in buildInvoiceWriteData (covers cookie, v1, webshop, sales-order and MCP-update writers) rather than as a DB constraint: invoice_items has no company_id and articles no UNIQUE (id, company_id), so a composite FK needs a denormalized column + backfill + sync trigger and a constraint trigger cannot be validated against prod rows the session could not query; DB enforcement proposed as option B for after Part 1 rebases commitCreateInvoice onto the builder.
|
||||
[2026-09-06] Migrated invoice rows (#2313, PR #2340) are written only through complete_invoice_rows (FOR UPDATE on the invoice, insert iff no rows, header in the same transaction), shared by the completion pass and the wizard; the unique index on invoice_items (invoice_id, sort_order) is deferred: it needs a renumbering of legacy rows under posted invoices first and is the weaker invariant anyway (two writers with different sort_order sets both pass it while still doubling the invoice).
|
||||
[2026-09-06] Completion cron (#2309, PR #2341) sizes registers with a PostgREST embed-null HEAD count (invoice_items=is.null) and orders smallest first, no stored state: the pass's own candidate load is a full register read, not a count; the new grammar is proven by a tool-pg test because a count that silently returned 0 would switch the cron off; a persisted fairness counter for the reverse-starvation case was rejected as state for a regime prod is not in (57 consents in 60 days), the deferred list in every run summary is the signal instead. The phantom-column schema guard now recognises the embed-null anti-join (is on a name the same chain embedded) instead of an allowlist entry.
|
||||
[2026-09-06] Guided/manual SIE mapping step (#2212, PR #2342) renders "created on import" as the target for any in-range source account the chart lacks (incl. nameless ones), instead of adding source accounts to the target list or blocking: the import engine already created them correctly; the defect was the step hiding it, and the shared-component fix covers both wizards.
|
||||
[2026-09-06] Assistant surfaces (#2204, PR #2343) gate on one client flag (CompanyContext.assistantAvailable from getAiStatus) and hide, the FAB falls back to general.help, no global disable switch: the capability flag already says "cannot run", a second switch is a state nobody asked for, and a disabled control the user cannot enable is noise on a self-host.
|
||||
[2026-09-06] Migrated-paid invoices (#2213, PR #2344) are detected from data (settled status, zero invoice_payments rows, no posted invoice_paid/invoice_cash_payment voucher keyed on the invoice), not a provenance column: every Accounted settlement path writes the sub-ledger row fail-closed, so the state itself is the marker; external_invoice_number is the self-billing field, not a migration marker.
|
||||
[2026-09-06] Duplicate-payment needle (#2299, PR #2345) = first non-legal-form token of >= 2 chars (not >= 3 as the issue proposed), one .or() probe on merchant_name+description, one detector shared by supplier and customer mark-paid: two-letter first tokens are initialisms banks keep verbatim (SJ, 3M, DB), and the alnum-only needle is what makes the .or() DSL interpolation safe.
|
||||
[2026-09-06] MCP already-explained guard (#2294, PR #2346) refuses to stage instead of staging an op with a blocker in its preview: a staged-but-blocked op could still be approved, would auto-reject at commit and would pollute /pending; a coded refusal carrying the voucher set, the link call and the exact force binding gives the agent the same steer with no new op state, and commit re-runs the same shared guard as the last gate.
|
||||
[2026-09-06] Invoice-to-verifikat links (#2298, PR #2347) are resolved by readers from the invoice side (invoices.journal_entry_id / invoice_payments.journal_entry_id) instead of being written onto the posted entry at link time: writing source_type/source_id to a posted verifikat is outside the rättelse paths (Hard Rule 1) and would erase the SIE-import provenance, so the missing-underlag RPCs, their TS mirrors and the periodisk sammanställning all follow the link; the PS report also stopped filtering on source_type, which had silently dropped kontantmetod (invoice_cash_payment) sales.
|
||||
[2026-09-06] Recurring invoice month phase (yearly in February, quarterly Feb/May/Aug/Nov) is exposed as a first/next invoice date (start_date on create, next_run_date on update; web dialog + MCP), not the reporter's "months offset" dropdown: an offset is a derived value relative to now that changes meaning when the interval changes, while the date maps one-to-one onto the next_run_date column that already anchors the phase, so no migration and no per-interval range rules. A date off the day_of_month grid is refused (400) instead of normalized, because the cron advances from the due date and an off-grid first run would drift back to day_of_month on the second run.
|
||||
[2026-09-06] Voucher series names live on the existing Verifikationsserier list in settings, not a separate group: the list already enumerates the letters in use, and a name belongs next to the letter it names. Rows are the union of used, configured and named letters so a freshly assigned series can be named before its first verifikat.
|
||||
[2026-09-06] Declined the request to import only SIE accounts with IB, UB or saldo <> 0: an inactive account is a harmless row in the chart, and dropping accounts breaks re-imports of later years that reference them. The chart imports whole.
|
||||
|
||||
Reference in New Issue
Block a user