fix(inbox): name the FK in the underlag lock-state embed so locked items classify (#1548) (#2022)

fiscal_periods carries two FKs back to journal_entries (closing_entry_id, opening_balance_entry_id), so the bare fiscal_periods(...) embed in resolveUnderlagAnchoring is ambiguous on prod and every lock-state read failed on the first underlag-reconcile cron run: 34 locked-period items were treated as retryable and hit the period-lock trigger daily. The embed now names journal_entries_fiscal_period_id_fkey (same hint the MCP server uses) and the exact select string is pinned in the unit test, since mocks cannot see PostgREST ambiguity. Follow-up to #2012 (issue #1548).
This commit is contained in:
Jakob Wennberg
2026-08-31 10:16:21 +01:00
committed by GitHub
parent dd7a2eab5f
commit 6a40b3c0ef
3 changed files with 11 additions and 1 deletions
+1
View File
@@ -1343,6 +1343,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review).
[2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring.
[2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card.
[2026-08-29] Inbox underlag lock-state read names the FK (fiscal_periods!journal_entries_fiscal_period_id_fkey) instead of the bare embed: fiscal_periods has two FKs back to journal_entries (closing_entry_id, opening_balance_entry_id), so PostgREST rejected the embed as ambiguous and the first underlag-reconcile cron run (2026-08-29) classified 34 locked-period items as retryable and hit the period-lock trigger on every one; the select string is pinned in the unit test since mocks cannot see PostgREST ambiguity.
[2026-08-29] Issue #1661 resolved as option (a): a private marking (is_business=false) STAYS a real booking (eget uttag/insättning on 2013/2018, 2893 for an AB) and therefore stays blocked in a locked or closed period; the actionable path for a row that is no affärshändelse (PSD2 ghost row, duplicate from a reconnect, never-executed transfer) is ignore, which writes no verifikat and is allowed in any period. No ledger or schema semantics changed. A "no-booking private flag" (option b) was rejected: an owner withdrawal IS an affärshändelse under BFL 5 kap 1 § and must be booked, so skipping the verifikat for unbooked private rows would let real withdrawals vanish from a period with nothing in the ledger; the 2026-07-26 lock-guard entry already judged ignore the legal escape hatch and the DB CHECK transactions_is_ignored_no_journal_entry guarantees an ignored row is unbooked. Shipped as: TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED (400, details.suggested_action 'ignore') on all four categorize surfaces instead of a bare PERIOD_LOCKED, a v1 ignore verb (POST/DELETE /transactions/{id}/ignore), a staged MCP tool gnubok_ignore_transaction (op type ignore_transaction, risk low), and an "Ignorera i stället" toast action on the Transaktioner page. The regex-matched phrases in period-service.ts lock/close messages are untouched.
[2026-08-29] gnubok_ignore_transaction ships in the DEFAULT MCP catalog (payload ceiling 64.4K to 65K with a ledger entry, measured 64 863), not with catalogVisibility 'search' as the #1661 plan preferred: gnubok_call_tool bridges READ tools only, so a search-only WRITE is uncallable on Claude.ai (the 2026-08-26 connect-tools and 2026-08-27 update_customer entries), and the TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED remediation, three workflow loadouts and the reconcile-month skill instruct agents to CALL it; a search-only tool would reproduce exactly the dead end those entries fixed. Extending gnubok_categorize_transaction with an ignore flag was rejected: it would muddle a booking tool with triage (the error came from that very call), still needs the new op type, and the dashboard and v1 doors have a distinct verb. The read-demotion rule in the bench is skipped for the same reason as the set_run_salary entry: picking the read needs prod usage data.
[2026-08-29] Ignore has one core, lib/transactions/ignore.ts (setTransactionIgnored), called by the dashboard route, the v1 verb and the pending-operation executor; "already booked" is isTransactionBooked() across all three anchors (journal_entry_id, invoice/supplier payment allocations, transaction_voucher_links), replacing the dashboard route's bare journal_entry_id check, which let a bulk-booked or multi-allocated row (journal_entry_id NULL) be ignored while a verifikat still carried it. Restore (ignored=false) skips the booked check on purpose: the DB CHECK already guarantees an ignored row is unbooked, so the lookup would be three wasted queries.
@@ -428,6 +428,12 @@ describe('resolveUnderlagAnchoring', () => {
// Already anchored: the lock does not matter and it is not looked up.
expect(map.get('i-d')?.status).toBe('anchored')
expect(findCalls('journal_entries', 'in')).toEqual([['id', [JE1, JE2, 'je-open']]])
// The bare `fiscal_periods(...)` embed is ambiguous on prod (fiscal_periods
// has closing_entry_id and opening_balance_entry_id back to journal_entries)
// and made every lock-state read fail on the first cron run (2026-08-29).
expect(findCalls('journal_entries', 'select')).toEqual([
['id, fiscal_period:fiscal_periods!journal_entries_fiscal_period_id_fkey(is_closed, locked_at)'],
])
})
it('keeps an unlinked item unlinked (retryable) when the lock-state read fails', async () => {
+4 -1
View File
@@ -232,7 +232,10 @@ async function resolveLockedJournalEntryIds(
if (entryIds.length === 0) return locked
const { data, error } = await supabase
.from('journal_entries')
.select('id, fiscal_period:fiscal_periods(is_closed, locked_at)')
// fiscal_periods also points back at journal_entries (closing_entry_id,
// opening_balance_entry_id), so PostgREST refuses the bare embed as
// ambiguous; name the FK explicitly.
.select('id, fiscal_period:fiscal_periods!journal_entries_fiscal_period_id_fkey(is_closed, locked_at)')
.in('id', entryIds)
.eq('company_id', companyId)
if (error) {