c0ecf2fa3bebd46bdfd0169efd73b89653d1dfed
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b68c082ef5 |
feat(bank-sync): close the F2 report: gap backfill, consent and paused chip states, agent-triggered sync (#2165)
* fix(bank-sync): cron backfills the gap since the last successful sync The daily incremental sync always asked the bank for the last 7 days. Any pause longer than that (a lapsed subscription paid again, a consent renewed after expiry, an outage) silently lost the days in between: the connection came back, looked healthy, and the missing transactions never arrived. The lookback now widens to cover the gap since last_synced_at plus one day of overlap, capped at the 90-day PSD2 limit, and a gap of a month or more asks for strategy=longest like the manual sync route does. Dedup via external_id makes the overlap harmless. First syncs keep their 90-day path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * feat(bank-sync): chip warns seven days before a bank consent expires The transactions-page chip only reacted once a connection was already dead (expired/error) or had gone stale. A consent that is about to end looked healthy until the morning it stopped syncing. New "expiring" state when a live connection's consent_expires is within seven days, the same threshold as the consent-expiry email in the sync cron. Precedence: attention, expiring, stale, healthy. getChipState moves to lib/transactions/bank-sync-chip-state.ts so the precedence is unit-tested; the component keeps the rendering only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * feat(bank-sync): chip says paused when the subscription lapsed The daily cron filters connections by the bank_sync capability, so a company whose trial or subscription ended keeps status=active rows with a frozen last_synced_at. The chip read that as "stale, check the connection", which sends the user to re-authorise a connection that is perfectly alive. 56 of 191 active connections on prod were in this state on 2026-09-01. New "paused" state, ranked above everything else, when the company lacks bank_sync: hosted points at billing, self-host at the connector key, the same split BankSyncNowButton already makes. getChipState takes an options object so the clock stays out of render (react-hooks/purity). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * feat(api): agent-triggerable bank sync in v1 and MCP Closes the first wish in the F2 report: an integration could read bank data but never refresh it. New POST /api/v1/companies/{id}/bank-connections/ {connectionId}/sync and MCP gnubok_sync_bank, both on a shared runner (extensions/general/enable-banking/lib/trigger-sync.ts). Cost is bounded structurally, not by policy: the window is never caller-controlled (the cron's gap-aware 7 to 90 day lookback), a connection synced within 15 minutes answers BANK_SYNC_COOLDOWN with next_allowed_at (429 + Retry-After on v1; synced=false in-band on MCP so the agent reads on instead of retrying), and a failing connection is throttled per process by attempt time. A dead session is flipped to expired with a remediation that hands the user the connect link: no API call revives a consent. Gated on bank_sync like gnubok_connect_bank; scope transactions:write. Registry, scope map, load-routes, spec snapshot and the generated accounted-api skill updated; five BANK_SYNC_* / BANK_SESSION_EXPIRED codes added to the structured-error registry. The web Synka-nu route is left as is (see DECISIONS.md). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * test(bank-sync): use the options object in the remaining chip-state calls Four multi-line calls still passed the clock positionally after getChipState moved to an options object; tsc flagged them (vitest did not, the extra argument was ignored at runtime). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * fix(api): address skeptic findings on the agent-triggered bank sync Three refutations from the pre-publish skeptic pass: 1. Core imported the extension. The v1 sync route pulled the runner straight from @/extensions, which the core-build gate rejects and which left a live bank endpoint on zero-extension builds. The route now resolves it through the registry's services channel against a contract in lib/bank-sync/trigger-sync-contract.ts (same pattern as the Skatteverket read service) and answers EXTENSION_DISABLED when the extension is absent. 2. The idempotency cache stored the handler-level 429. A same-key retry after Retry-After, which is the documented retry, replayed the stale cooldown as a 400 for the cache's 24-hour TTL. withApiV1 no longer caches 429 responses; regression test added. The endpoint's pitfall no longer claims Idempotency-Key is mandatory (it was never enforced). 3. Two cron tests read the clock twice and failed whenever a millisecond passed between the reads. They now pin the clock with fake timers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * fix(bank-sync): durable cooldown lease and review wording Resolves the PR #2165 review findings in one pass. Superagent P1: the attempt throttle was a process-local Map, so two agent calls on different serverless instances (or a retry after a cold start on a failing connection) could each bill an Enable Banking call, contradicting the one-sync-per-15-minutes promise. New bank_connections.sync_lease_until (migration 20260902150000), claimed with one conditional UPDATE before the bank is called; Postgres row locking makes exactly one claimer win, the rest answer BANK_SYNC_COOLDOWN. The lease stays for the full window on success and failure. Tests cover the claim order, a failed attempt seen from a second instance, a lost race, and an expired lease. CodeRabbit: the =1 plural branch now reads "in 1 day" / "om 1 dag" (daysUntilConsentExpiry rounds a partial day up, so "tomorrow" could be today); the cooldown pitfall on the v1 endpoint, the MCP description and the in-band cooldown instruction now say a cooldown can follow a failed attempt and tell the agent to compare last_synced_at before deciding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125TMQQBjBBZG9YxP7wQWub * fix(bank-sync): lease claim as a literal filter for the schema guard CI's no-phantom-columns guard counts runtime-built query expressions and its ceiling is exact; the templated `.or('sync_lease_until.is.null,...')` claim added one. The column now defaults to epoch (NOT NULL), so "never claimed" is just "expired long ago" and the atomic claim is a single literal `.lte('sync_lease_until', now)` the guard can check. Migration is unshipped (same PR), so it is edited in place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125TMQQBjBBZG9YxP7wQWub * fix(bank-sync): runner verifies company membership before the lease Superagent (round 3): the MCP path reached the shared runner without a membership check of its own. Both callers do enforce it upstream (withApiV1's company resolution and resolveMcpCompanyContext in the MCP dispatcher), but the runner writes transactions and bills a bank call, so it now checks company_members itself, before the cooldown and the lease claim, and answers NOT_FOUND for a non-member. The viewer check that was buried inside the sync block moves up with it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125TMQQBjBBZG9YxP7wQWub --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
cd40127f0e |
feat(bank): expose bank-reported balance (booked + available) in UI, reconciliation, MCP and v1 API (#2118)
* feat(bank): expose bank-reported balance (booked + available) in UI, reconciliation, MCP and v1 API
The PSD2 sync has fetched the bank's reported balance for years but the
data was stranded (F7): the Bank-page source picker read a cash_accounts
column no sync ever updated (frozen at connect time), reconciliation
hard-coded external_balance to null for bank accounts, and neither MCP
nor the v1 API exposed any balance at all, so the only path to a current
bank balance was logging into the bank.
- getAccountBalance now returns booked + available from the same
quota-limited BALANCES response (previously all but one type discarded)
- every sync (manual + cron) mirrors balance, available_balance and
balance_updated_at into cash_accounts, fixing the stale picker
- new cash_accounts.available_balance column (additive migration)
- reconciliation bank kind: external_balance = bank-reported balance,
plus bank_reported_* fields and fetch timestamp in the bank block;
difference math stays movement-based and untouched
- reconciliation view shows "Saldo enligt banken ... hamtat {date}"
- MCP gnubok_list_cash_accounts returns the three balance fields; the
cash_today prompt now reports the bank's figure instead of teaching
agents to answer with the bookkept 19xx balance
- new GET /api/v1/companies/{companyId}/cash-accounts endpoint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ewu46quXgh9LSr9UwYusxm
* fix(bank): keep external_balance null for bank sign-offs; never fabricate a zero balance; guard the mirror against stale writers
Post-review fixes from the skeptic pass + CodeRabbit on PR #2118:
- external_balance stays null for the bank reconciliation kind: sign-off
persists it into account_reconciliations and bokslutsbilagor computes
closing - external from that row, so a today-balance stored on a
balansdag sign-off printed a phantom warning-red differens in the
year-end appendix. The bank-reported figure lives only in the
timestamped bank_reported_* pair in the bank block, and only when its
fetch timestamp exists (a balance of unknown age is suppressed).
- AccountOverview no longer falls back to today's date when the balance
timestamp is missing; the line is omitted instead.
- getAccountBalance returns null on an empty BALANCES response instead
of fabricating amount 0 with a fresh timestamp; sync keeps the
previous stored value.
- updateBalancesFromSync only writes over an older-or-missing
balance_updated_at, so an older sync run finishing later cannot move
the mirrored balance backwards.
- The inline initial backfill (picker save) now mirrors fetched
balances into cash_accounts too (accounts_data is deliberately not
re-written there).
- cash_today MCP prompt mentions the gnubok_call_tool bridge for hosts
that only see the default catalog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ewu46quXgh9LSr9UwYusxm
* fix(bank): express the stale-writer guard as two literal predicates for the schema guard
The .or() with a template literal pushed the no-phantom-columns
unresolvable-expression count over its ceiling. Same semantics, two
updates: one for rows with an older timestamp, one for rows with none.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ewu46quXgh9LSr9UwYusxm
* fix(bank): rank interimBooked (ITBD) as a booked balance type before the generic fallback
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ewu46quXgh9LSr9UwYusxm
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
f1d76deaba |
fix(providers): stop dead-ending on a resource 403, and stop dropping every migrated kreditfaktura (#2113)
* fix(providers): stop dead-ending on a resource 403, and stop dropping every migrated kreditfaktura
Two independent defects in the provider migration, both customer-visible.
A per-resource 403 was classified as a dead grant. classifyProviderError mapped
any 401 or 403 to PROVIDER_AUTH_EXPIRED, which is fatal, so a Fortnox account
without leverantorsregister permission aborted the whole migration at the
suppliers step with "Anslutningen har gatt ut. Ateranslut" even though the same
token had just succeeded on the previous step. Reconnecting can never fix that,
and steps 4 and later never ran. The provider's own reason ("Saknar behorighet
for leverantorsregister.") never reached the user. A 403 is now non-fatal once
the same token has already succeeded in the run, the migration continues, and
the provider's reason is surfaced. A 401, or a 403 on the first call, keeps the
auth-expired path.
fetchCompanyInfoDirect swallowed every error and returned null, which made the
existing PROVIDER_API_MODULE_INACTIVE remediation unreachable: a Visma customer
whose api_standard module is off got a silent 200 with an empty company card
instead of the precise Swedish explanation that was already written.
Kreditfakturor were dropped entirely. entity-mapper wrote document_type
'credit_note', but invoices_document_type_check allows only invoice, proforma
and delivery_note, and credit notes are modelled by credited_invoice_id. Every
migrated kreditfaktura was rejected and counted as skipped. One customer
imported 255 sales invoices and 0 credit notes on 2026-08-31; AR and revenue
are overstated by the credited amounts, and kreditfakturor are
rakenskapsinformation. They now import as invoice rows with reversed amounts
and status 'credited', following the in-app credit convention. They import
unlinked: no provider DTO carries a reference to the invoice being credited, so
there is nothing to match on and guessing would corrupt the AR ledger. The
wizard says so instead of burying them in skipped.
Also makes the OAuth callback non-replayable from browser history (no-store
plus history replacement), which is what the "state rejected" events were: a
replay of a callback that had already succeeded seconds earlier. No
already-connected page, so consumed-vs-unknown state stays unobservable to an
unauthenticated caller. Expected PSD2 session expiry drops from error to warn.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc
* fix(arcim): entity line needs the failed flag
The unlinked-credit-note row omitted `failed`, which the entityLines element
type requires. Caught by the zero-extensions build, not by vitest: the unit
suite does not typecheck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc
* fix(arcim): write the missing-reference disclosure onto the credit note itself
Review finding (swedish-compliance-review-bot): ML 17 kap 22-23 § wants a
kreditfaktura to reference the invoice it credits, and BFL 5 kap 6-7 § wants a
verifikation to reference its underlag. No provider DTO carries that reference,
so the pairing cannot be resolved at import and guessing it would corrupt the
AR ledger. Reporting the count in the migration wizard is not enough: a result
screen is not rakenskapsinformation, and the gap has to be legible on the
record itself years later.
The disclosure now goes into invoices.notes and supplier_invoices.notes,
preserving whatever note the provider sent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
5e9cd6f761 |
fix(enable-banking): unstarve the daily bank sync cron (#1969)
* fix(enable-banking): unstarve the daily bank sync cron The sync cron self-limited to 50s (no maxDuration export, so the route ran under the 60s platform default) and processed ~17 connections per day against 123 entitled active connections: any given connection only got an automatic sync every 4-7 days, and users bridged the gap by clicking 'Synka' manually, which pushed them to the back of the queue. - export maxDuration = 300 (Vercel Pro ceiling the code always assumed) - sync loop budget 50s -> 230s; health probe gets the 280s leftover - connections sync in concurrent waves of 4 with per-connection error isolation preserved - MAX_CONNECTIONS_PER_RUN 50 -> 300 (safety cap only; one run now covers the whole entitled queue) Cadence stays once daily at 05:00 UTC by design; users who want more can sync manually. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(enable-banking): serialize same-company connections within sync waves Skeptic refutation: the post-sync unattended reconciliation sweep is company-scoped, so two connections of one company syncing concurrently run two identical whole-company sweeps whose unlinked-GL-line snapshots race; both can claim the same journal entry for different bank transactions, leaving the GL short while every surface shows reconciled. Waves now fan out over company groups instead of raw connections: one company's connections sync sequentially inside a single wave slot, unrelated companies still run 4-wide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(enable-banking): re-check the sync time budget inside company groups Review finding (PR Reviewer Guide): the budget was only checked between waves, so one company with many connections could run past 230s inside a single wave and eat the health-probe and teardown margin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0510d4c13f |
fix(sync): stop expired trials from starving automatic bank and skattekonto sync (#1376)
The bank sync and skattekonto sync crons fetched the 50 oldest connection/token rows and only then checked entitlements per item, so expired-trial rows permanently occupied every batch slot and entitled companies were never synced automatically. Fetch all candidate rows, resolve capability grants in bulk via the new getCompanyIdsWithCapability() (company and firm grants cascade, expired grants excluded, explicit per-company disable wins), and apply the 50-item run cap after filtering. Entitlement query failures now fail the run instead of silently skipping every company. Fixes #563 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
17a7a62ceb |
fix(reports): stop the resultatavslut zeroing declarations, and make the mistake uninventable (#1293)
* fix(settings): explain why account deletion is blocked The delete-account button was disabled while the user still owned companies, but the reason only lived behind the "?" on the blocker row, so the greyed-out button read as broken. Surface it as one visible attn sentence directly under the button, and point aria-describedby at it whenever the button is disabled, not only on a load error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(enable-banking): share one PSD2 consent across a user's companies Connecting the same bank for a second company required a second BankID, and at SEB that new authorization silently revoked the first one. A user with four companies at one bank therefore signed four times a quarter and ended up with three dead feeds, each still rendering as "Aktiv" with a stale last_synced_at until someone pressed Synka. Prod says this is not one customer: every SEB customer holding connections in more than one company has had an earlier company stop syncing at the moment the next was authorized, most of them while the consent was still formally valid for weeks. The same measurement over other banks is far quieter, so the one-active-session-per-PSU limit is real and ASPSP-side. Enable Banking already supports the shape we want. POST /auth carries no account restriction, so a session covers every account the user ticked at the bank, and GET /accounts/{uid}/transactions takes no session id, so a second company can sync its own accounts from an existing session. bank_connections has no unique constraint on session_id, so this needs no migration. Adds lib/session-sharing.ts plus GET /reusable-sessions and POST /attach. When a live session in another of the user's companies still exposes accounts no company syncs, the settings panel offers to reuse it: the new row shares session_id and consent_expires, carries only the unclaimed accounts, and lands in pending_selection so the existing IBAN-aware account picker does the ledger mapping. Only the consent is shared; accounts, cash_accounts and transactions stay strictly per-company. Sharing a session changes three lifecycle paths, all handled here: - Disconnect and reconnect now refcount before revoking. A blind revoke would take down a sibling company's feed, which is the exact failure this removes. The count runs on a service-role client because RLS hides a sibling in a company the user has since left, and it fails closed: an uncertain count is treated as shared, since a lingering consent lapses on its own in 90 days while a wrongly revoked one kills a working feed. - A renewed consent fans out to every company sharing the old session, and re-points their account uids by IBAN. Several ASPSPs reissue uids on re-authorization, so carrying the session id alone would have left siblings calling retired uids and re-broken them every quarter. This is also why the superseded session_id is no longer nulled at /connect: the callback needs it. - The nightly probe runs once per distinct session and applies the verdict to every row holding it, and expiry mails are keyed per (user, session), so one dead consent is one probe and one mail rather than four of each. Only enabled cash_accounts rows count as claiming an IBAN. The callback mirrors every account in a consent, deselected ones included, so counting any row as a claim would leave nothing offerable once the first company connects. An account handed to a company also stops being offered while that company's picker is still open, closing the window where two companies could book the same physical account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ink2): read the resultaträkning from the pre-closing books INK2R summed journal entries raw, so it included the resultatavslut that zeroes every P&L account into 2099 at year-end. Nettoomsättning, kostnader, periodiseringsfond and skatt all came out as 0, which cascaded into INK2S 7650/7651 and the taxable result. INK2 is always filed after bokslut, so this was every real declaration, and nothing warned: with the P&L at zero the balance sheet still tied out. INK2R now reads two views of the same period. The balance sheet comes from the closed books so 7302 keeps arets resultat via 2099; the income statement comes from the pre-closing books via excludeFinalClosingEntry, which drops only fiscal_periods.closing_entry_id so skatt and bokslutsdispositioner stay on the form (7525, 7528). The equity adjustment is now conditional on a posted closing entry having moved the result into 2099. Second, independent bug: accounts were mapped by BAS number with no regard for the sign of the balance, so konto 1630 with a credit was reported as a negative fordran instead of a skatteskuld and konto 2641 with a debit was netted off the liabilities. The three sign-reclassification rules the K2 iXBRL mapper already had are extracted to lib/reports/sign-reclassification .ts and applied to INK2R too, so both statutory reports present the same balance sheet. Only the rule table is shared: k2-mapper keeps its sumOre arithmetic because the iXBRL path is ore-exact while INK2R truncates per SFL 22:1. NE-bilaga had the same empty-resultatrakning bug and gets the same fix. Adds the closed-period coverage that was missing: the old tests only exercised the mapping table against an open period, the one state in which the engine happened to work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(reports): make the year-end closing decision explicit at every call site generateTrialBalance took two optional booleans, so a caller that never thought about the resultatavslut silently got 'include'. That is the wrong default for anything summing class 3-8: the closing verifikat posts the mirror image of every P&L account into 2099 inside the same period, so the report reads ZERO across the board while the balance sheet still ties out and nothing warns. The booleans are replaced by a required closingEntry: 'include' | 'exclude-final' | 'exclude-all-year-end' with no default, so the build fails until each call site decides. All 40 were audited individually; every one keeps its current behaviour except the two that were provably broken: - Resultatrapport read zero on every line for a closed year, in JSON, PDF and XLSX, and its prior-year comparison column read zero for anyone whose previous year was closed. - Resultat per projekt (dimension-pnl) had the same defect and must stay in lockstep with Resultatrapport to keep reconciling. Both now pass 'exclude-all-year-end', which keeps them agreeing with the formal Resultaträkning rather than pre-empting Stage 2 of #1051 (DECISIONS.md:632). Deliberately unchanged and recorded in DECISIONS.md: the KPI expense composition, which is blank for a closed year but cannot be fixed without a migration and a displayed-figure change, and getBookedBolagsskatt, whose contract is an open period and whose call chain already caused a too-high-tax customer bug once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(vat): keep the resultatavslut out of the momsdeklaration The closing verifikat posts the mirror image of every P&L account into 2099 inside the same fiscal period. Revenue accounts drive rutor 05, 39 and 40, so any VAT period containing the fiscal-year end reported NEGATED turnover once the year was closed. get_vat_declaration_totals already excluded vat_settlement and opening_balance entries, but not this one. Reproduced read-only against production: for December of a closed year the December declaration reported ruta 39 = -794 734 kr. After the fix that period reports 0 and the January period carrying the real sale is unchanged at 794 734 kr. Keyed on fiscal_periods.closing_entry_id, not source_type = 'year_end': avskrivningar, periodiseringsfond and skatt share that source_type and must keep whatever VAT effect they carry. A reversed closing entry is retained together with its storno so the pair still nets to zero, the same predicate trial-balance.ts uses for closingEntry: 'exclude-final'. Migration applied to the staging branch only; prod gets it via merge. The pg test is written but has NOT been executed locally (no DATABASE_URL configured and no local Postgres), so CI is its first real run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(kpi): keep the resultatavslut off the monthly chart The monthly income/expense chart summed every posted entry in the fiscal period. The closing verifikat posts the mirror image of every P&L account, so once a year was closed the fiscal-year-end month charted the whole year's revenue as negative income. Measured read-only on production: 28 companies across 34 month-rows. The worst case charted December income as -10 347 459,81 kr where the real figure is +12,88 kr. Other examples: -1 868 731 -> +128 730, -1 850 501 -> +431 709. Both paths are fixed together so they keep agreeing: the RPC's monthly section now joins the tb_ex_ye_entries CTE it already computes for tb_ex_year_end, and monthly-breakdown.ts (the dimension-filtered fallback and the MCP path) gains the matching source_type filter plus the storno/correction chain of REVERSED year-end entries, so an undone bokslut does not leave half a pair behind. Migration 20260723180000 had recorded the omission as deliberate, on the grounds that it mirrored the JS scan. It did, but the JS scan was wrong. Migration applied to the staging branch (function body identical; three comment lines differ from the committed file). Prod gets the file via merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(reports): pin every statement generator against a closed fiscal year The per-generator suites all exercised an OPEN fiscal period, which is the one state in which a generator that forgets the resultatavslut happens to work. Declarations are filed AFTER bokslut, so the untested state was the only state that occurs in production. That is why the same defect could ship three times. Two new suites over one shared fixture (closed-year-fixture.ts, a synthetic closed AB with a resultatavslut, a credit 1630 and a debit 2641): closed-year-statements.test.ts enumerates the generators and asserts each reports the year's revenue rather than zero, plus its own bottom line. The table IS the checklist: a new report either appears in it or nothing stops it shipping with this bug. Verified by regressing income-statement back to closingEntry 'include', which fails 2 of its assertions. cross-surface-agreement.test.ts asserts the surfaces agree with each other, which is what every customer complaint actually was. INK2R and the K2 årsredovisning must produce the same årets resultat, the same fritt eget kapital, the same sign reclassifications and the same balance total. The operational family (Resultaträkning, Resultatrapport) must agree internally, and the gap BETWEEN the families is asserted explicitly as bokslutsdispositioner + skatt, so when Stage 2 of #1051 lands the test names the expectation to change instead of failing vaguely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(guards): ratchet against new reports that scan the ledger directly A statement generator that aggregates journal_entry_lines itself has to remember, on its own, that the resultatavslut posts the mirror image of every P&L account into 2099 inside the same fiscal period. Three forgot, and each read ZERO revenue for a closed year while the balance sheet still tied out, so nothing warned. generateTrialBalance now requires an explicit closingEntry mode, which makes that decision a compile error. This guard is what keeps NEW reports on that path: any generator under lib/reports or lib/bokslut that reads journal_entry_lines and is not in the baseline set fails CI. Verified by adding a throwaway report, which the guard rejects by name. Voucher and line listings (general-ledger, journal-register, SIE export, reconciliation, diagnostics) are sanctioned: they show the ledger as posted and have no closingEntry decision to make. Four existing lib/bokslut files are grandfathered rather than migrated. One of them is a genuine open follow-up recorded in DECISIONS.md: sarskild-loneskatt-calculator sums 7410-7419 with no year-end exclusion, so its basis reads ~0 if it runs against an already-closed period. Left alone deliberately: it is a tax figure whose call chain has caused a customer bug before and deserves its own verified change. Also ratchets naive-ore-round down 646 -> 641. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(reports): pin where sign reclassification applies, in both directions No behaviour change. The sweep asked whether the 1630/2641 sign reclassification should be extended to the remaining balance-sheet surfaces; the answer is that there are none left. Both STATUTORY presentations already have it: the K2 iXBRL årsredovisning since 2026-07-23 and INK2R since 2026-07-29. The other two balance-sheet surfaces must NOT have it: /rapporter Balansräkning and Balansrapport are organised by account number under BAS-prefix headings, and balansrapport documents an invariant that depends on every row staying debit-positive where it was booked. Moving konto 1630 into a liability section would break the add-the-rows-to-verify-the-balance property and hide the account from anyone looking it up by number. Asserting both halves is the point. The first half stops the reclassification silently disappearing from one statutory surface again, which is how a customer ended up comparing two of our own reports against each other. The second half stops a future sweep "fixing" the operational reports into disagreeing with their own documented contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(reports): detect statement disagreement instead of waiting for a customer Every year-end problem reported so far was a DISAGREEMENT between two of our own screens, not a single wrong screen. The årsredovisning said one figure, INK2 said another, and the customer did the reconciliation for us. Nothing in the product noticed, because each screen tied out on its own. Two additions: INK2R self-checks. On a closed year it compares the årets resultat it is about to declare against the booked konto 2099, and warns in Swedish when they disagree. This is the alarm that was missing: when INK2R reported 0 kr against a booked 469 542 kr, the balance sheet still balanced, so no warning fired. Mirrors the equivalent check k2-mapper has had since 2026-07-23, so both statutory reports now catch the same fault. reconcileStatements + GET /api/reports/statement-reconciliation return årets resultat from every surface side by side, grouped into families. ledger + statutory must agree and a mismatch is named; operational legitimately differs by bokslutsdispositioner + skatt until Stage 2 of #1051 lands, so that gap is explained rather than flagged. The visual panel is deliberately not built here: it needs a /frontend-design pass against the locked concept conventions plus sv/en strings, and the warning above already puts the alarm where the user looks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(reports): address review findings from PR #1293 pg-real (7 failures, one signature): the new fixture called insertFiscalPeriod({ isClosed: true }) and then inserted journal entries into it, so enforce_period_lock (migration 017, legally required) refused the write. Not worked around: the RPC's predicate keys on fiscal_periods.closing_entry_id and never reads is_closed, so the fixture now links the closing entry and leaves the period open, which exercises the path that actually matters. CodeRabbit, closed-year-fixture: EX_YEAR_END_ROWS dropped only the P&L legs of the year_end entries (8811, 8910) and left their balance-sheet legs (2125, 2512) at pre-closing values, so the 'exclude-all-year-end' view sat 160 000 kr out of balance and misrepresented what generateTrialBalance returns. Latent, because today's consumers read class 3-8 only, but a shared fixture that does not balance is a trap for the next consumer. Both legs now go, and a new test asserts all three views sum to zero. CodeRabbit, INK2 totals: renamed totals.resultAfterFinancial to aretsResultat. It holds the result after bokslutsdispositioner AND skatt, which is årets resultat, not resultat efter finansiella poster, and build-data.ts uses the old name correctly for the different subtotal. The UI already labelled the value "Årets resultat", so the name was simply wrong. CodeRabbit, statement-reconciliation: the statutory branch called a generator and caught any throw as "wrong entity type", mapping genuine failures to a null figure that the comparison then skipped, so a real bug in a declaration generator made the function report isReconciled: true. That is the opposite of its purpose. It now dispatches on entity_type and surfaces a generation failure as a named disagreement. CodeRabbit, enable-banking (Emil's call to include): fetchClaimedIbans returned an empty Set on a cash_accounts read failure, which is indistinguishable from "nothing is claimed" and made every IBAN in the session offerable, including accounts another company already books to. Its own comment said it failed closed and its log said "offering nothing"; it failed open. Returns null now, and findReusableSessions offers nothing when the claimed set is unavailable. The test that pinned the fail-open asserted toHaveLength(1) under the name "offers nothing"; it now asserts []. Also removed an em dash per CLAUDE.md. The remaining enable-banking finding (consent-expiry cooldown stamped only on the selected connection, so it leaks one duplicate mail per sibling company) is deliberately left to Emil: it changes email-sending behaviour in his feature rather than fixing a stated contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(reports): resolve second-round review findings on PR #1293 pg-real, two NEW signatures (the closed-period one from cycle 1 is gone): kpi-report-aggregates-rpc.pg.test.ts asserted the exact contract migration 20260730090000 deliberately changes. Its comment read "year_end entries are NOT excluded from monthly" and expected December expenses 1250. That fixture's December holds only year-end-chain entries, so with the fix the month drops out of the chart entirely, which is the correct operational view: a month whose only activity is bokslut has no operating result. Assertion and file docstring updated to the new contract rather than the test being removed. vat-totals-closing-entry.pg.test.ts passed the wrong account arrays. p_net_ accounts is VAT_SETTLEMENT_NET_ACCOUNTS (2650/1650, the momsredovisning settlement pair), not the output-VAT accounts. Putting 2611 there made the extra year_end entry match the settlement-SHAPE detector, so an ordinary sale-with-VAT was classified a momsredovisning and dropped, and the test read 0 instead of 10 000. The RPC was right; the fixture was not. CodeRabbit, statement-reconciliation: resolveEntityType checked neither query's error, so a genuine DB failure (RLS, permissions, connectivity) returned null indistinguishably from "no entity type set", fell into the unsupported-form branch and reported isReconciled: true. That is the same silent-false-reconciled bug the cycle-1 refactor closed, one level down. The companies error now throws; a missing company_settings ROW stays tolerated, because .single() errors on zero rows and many companies have none. Mirrors the pattern the INK2 and NE engines already use. Still open by Emil's explicit choice: the consent-expiry cooldown is stamped only on the connection it was handed, so it leaks one duplicate mail per sibling company on the shared session. That changes email-sending behaviour in his feature rather than fixing a stated contract, so it stays his. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
65c6d4c178 |
Fix/07 27 (#1271)
* fix(enable-banking): keep bank account mappings across reconnects and surface dead sessions A PSD2 reconnect silently moved the user's ledger mapping. Account identity came from the provider's account uid, which does not survive a re-authorization at every ASPSP, and a fresh connect to an already-connected bank mints a new bank_connections row regardless. Both paths looked like "an account we have never seen", so the allocator handed out the next free 19xx slot and a 1930/1940/1941 mapping came back as 1942-1946 on every consent renewal, roughly quarterly per connection. Match on the IBAN instead. resolvePsd2LedgerAccount() finds the existing cash_accounts row by normalized IBAN before allocating, and upsertFromPsd2 promotes that row in place rather than inserting a second one, so it keeps its id and its linked transactions and is re-pointed at the connection that just authorized. The previous holder's connection status is deliberately ignored: one IBAN is one physical account, and the old row often still reads 'active' because the bank killed the session without telling us. The allocator also stopped treating a 19xx number as free just because no cash_accounts row holds it. A chart imported from SIE carries the company's real bank accounts by name with no PSD2 row behind them, which is how a SEK company account got proposed as an unrelated brokerage account. Overflow now skips chart-occupied numbers, falling back only when nothing unnamed is left. Dead connections kept rendering as "Aktiv": status only ever changed when a transaction fetch failed, so a session killed bank-side stayed healthy-looking with a stale last_synced_at while the user read old balances as current. Add probeSessionHealth() and run it in the daily cron over every connection that run did not prove alive, including the ones the loop skips silently (capability gate, all accounts deselected) and the ones parked in pending_selection that the cron never looked at. It acts only on a definite dead answer; anything ambiguous leaves the row alone, since a wrong flip costs a full BankID re-authorization. The all-accounts-deselected branch is reclassified 'synced' to 'skipped' for the same reason: it never contacts the bank, so it must not count as proof of life. The settings row warns when an active connection has not synced in three days or has never synced. Which company a connection belongs to was invisible. Everything was already scoped to ctx.companyId, so there was no cross-tenant leak, but a bank authorized while the wrong company was active looked identical to the right one. Name the company on the connect surface and in the account picker, and say where the connection went when the callback lands under a different active company. Warn (bypassably) before authorizing a bank where the same user already holds live connections in other companies: several ASPSPs allow one active AIS session per login, so the new authorization can kill the others. The history start date already defaulted to the fiscal-year start; the card above it recommended a mid-year date and contradicted the selected option. It now states the fact and offers the shortcut without presenting it as advice. Not addressed: sharing one PSD2 session across companies. company_id is the tenancy anchor on bank_connections and cash_accounts hangs off (company_id, bank_connection_id), so that needs the session to become its own entity. See DECISIONS.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): show the posted line description in the voucher preview The "Verifikation som bokförs" preview built its expense debit lines with description set to the raw account number, so the BESKRIVNING column showed "5615" or "6990" where the posted verifikat actually says "Leverantörsfaktura 123, ACME AB". A hardcoded 11-entry ACCOUNT_LABELS map masked this for 2440/2641/26xx, which is why the column read as a mix of friendly labels and bare account numbers, neither of which was the posted text. The preview now renders exactly the line_description the engine writes: the shared invoice-level text on expense lines and 2440, "Ingående moms {rate}% {desc}" on 2641, and the reverse-charge pair taken straight from generateReverseChargeLines instead of being re-derived locally. buildSupplierDescription moves into its own dependency-free module so the client-side preview can call it without pulling the journal engine (and its Supabase server client) into the browser bundle. The account name stays reachable on the AccountNumber hover card. Picked option A from the issue, keeping the fixed invoice-level description rather than propagating each item's own text: the customer-invoice side already writes invoice-level descriptions, so per-item text would create an inconsistency between the two invoice sides rather than remove one, and it would need an aggregation-collision policy in the journal engine. Rationale recorded in DECISIONS.md. Refs #1258 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bookkeeping): restore the copy icon on verifikat rows The row-language rewrite in #1123 reused the copy icon's slot for the new expand toggle, removing the zero-click copy affordance from the bookkeeping list without mentioning it. The leftover orphaned copy_voucher_tooltip key in both message files is what identifies it as collateral rather than a product decision. Restore a copy icon in the row's right-edge action cell, reusing that key for aria-label and title. stopPropagation keeps the click off the row's expand toggle. The icon is hover-revealed on md+ and always visible below it: #1123 collapsed the desktop table and the mobile card into one responsive table, so hover-only would leave touch users with nothing. Copy is no longer gated on posted. The copy_from handler and the GET journal-entries route never looked at status, so copying a draft already worked end-to-end and only the detail-page button hid it; the two list surfaces were already ungated. Both list affordances now respect canWrite, which previously dropped read-only users into a dialog they could not submit. The repo does not render components in tests, which is why #1123 removed this silently. Pin the source shape instead, the same way the copy-invoice query is pinned. Closes #1266 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(transactions): revalidate stale invoice match pointers before offering a match potential_invoice_id / potential_supplier_invoice_id are written once, at bank import, and never revisited. When one of several identical recurring invoices was settled by a different transaction, every other transaction kept pointing at the now fully paid invoice. The match dialog then measured the bank amount against a 0 kr remaining balance and reported a "Beloppen skiljer sig ... fakturan blir delbetald" partial payment, and the worklist offered the same dead suggestion as a one-click confirm row. Worse, the manual escape hatch was hidden exactly when it was needed: TransactionInboxCard only shows "Matcha mot leverantörsfaktura" when no suggestion exists, so a stale pointer left the user with no way at all to reach the correct invoice. Fixed by revalidating at read time rather than by clearing sibling pointers on settle. Invoices are settled through many paths (both match routes, mark-paid, MCP, bank reconciliation, SIE import), so write-time cleanup leaks the moment one is missed, while the candidate lookup covers every route into the list. The shared accept-lists in lib/invoices/matchable-statuses.ts mirror the CAS guards the match routes already enforce. - listSuggestedMatches and the transactions page candidate fetch filter on status + remaining_amount, so a settled candidate yields no suggestion and the manual picker reappears on its own. - InvoiceMatchDialog blocks a settled target with a distinct message and a disabled confirm. Not advisory: both routes reject it outright with MATCH_INVOICE_ALREADY_PAID / MATCH_SI_ALREADY_PAID, so no override could succeed. - The supplier detail card now shows remaining_amount like the customer branch, instead of total. On a partially paid invoice it used to print "1 250 kr" directly beside "Differens: 1 250 kr". - match-supplier-invoice clears potential_supplier_invoice_id on the transaction it just matched, mirroring the customer route. No bookkeeping was ever at risk: both routes already refused a settled target before creating a voucher. The damage was confined to a misleading dialog and a dead end. createQueuedMockSupabase gains passive call recording (calls / findCall / findCalls) because the proxy swallowed filter and update arguments, which made the new assertions inexpressible. Refs #1259, #1260 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(webhooks): dispatch on emit instead of waiting for the next cron tick (#1256) * feat(webhooks): dispatch on emit instead of waiting for the next cron tick The webhook dispatcher ran only on a per-minute cron, so the floor on delivery latency was up to 60 seconds plus the request. An external consumer that wanted to react as a transaction landed had only one alternative: polling /api/events, which the 100 rpm per-key limit makes expensive and which still cannot beat the tick interval. Schedules one dispatch cycle as soon as deliveries are enqueued. The cron is unchanged and remains the retry and sweep path; this only moves the first attempt forward. Wired into the event-bus fanout plus the two routes that enqueue a delivery directly: the :test verb, whose entire purpose is telling someone whether their receiver works, and the manual delivery retry. Three properties are load-bearing and covered by tests. The kick is never awaited, because eventBus.emit is awaited at ~99 call sites including journal_entry.committed and each delivery can burn a 10 s receiver timeout. It coalesces per function instance, so a bulk booking that emits once per row does not schedule one claim round trip per row. It claims 5 rows rather than the cron's 50, because it runs on the tail of a user-facing request. Double delivery is not a risk: claim_due_webhook_deliveries already claims FOR UPDATE SKIP LOCKED and flips rows to in_flight in the same statement, so a kick racing the cron sees disjoint rows. Does not close #1201, which asks for a realtime stream for API consumers. This is the cheap half. Refs #1201 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(webhooks): stop claiming the kick makes double delivery impossible Adversarial review of the previous commit caught an overstatement in its own comments. SKIP LOCKED keeps a kick and the cron from claiming the same row at the same moment, but claim_due_webhook_deliveries autocommits before any POST is issued, so from then on ownership is only status='in_flight' and a later cycle's recoverStuckInFlight sweep can re-arm a row still queued behind an earlier cycle's serial loop. Delivery is at-least-once, which is what the public docs already tell receivers ("the same delivery id may arrive more than once ... idempotency is on you"). The comments contradicted that. No behaviour change. The kick does not create this window: the cron claims 50 rows serially against the same 20 s stuck threshold, which is wider than what a batch of 5 can open. Refs #1201 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base (#1253) * fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base sumPostedYearEndDispositions reconstructs resultat fore skatt for the tax calculation, because generateIncomeStatement excludes every source_type='year_end' entry. It summed class 88 and 7533 but not 78xx, so planenlig avskrivning posted by the bokslut flow (lib/bokslut/assets/depreciation-engine.ts) was dropped from the income statement and never added back. The bolagsskatt base and the periodiseringsfond 25 % cap were therefore computed on an overstated result: tax too high by roughly 20.6 % of the depreciation. Also exclude the period's final bokslutsverifikation from the fetch. It carries source_type='year_end' as well and reverses every P&L account, 78xx/88xx/7533 included (verified against production closing entries), so once the year is closed it would cancel the add-back this function exists to produce. That hazard already applied to 88xx and 7533; the fix closes it for all three rather than widening it. Scope is deliberately the tax base only. Making the standalone resultatrakning show bokslut entries is a separate, larger change: the same exclusion is duplicated in the kpi_report_aggregates RPC, it moves displayed profit for every company that ran the bokslut flow, and it means removing the add-back at four call sites. Refs #1051 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): scope the closing-entry lookup to the company and fail loudly Review (CodeRabbit + the compliance swarm, ASVS V8.2.1) flagged the new fiscal_periods read in sumPostedYearEndDispositions on two counts, both fair. It filtered only on the period id while every sibling query in the same function carries the tenant scope. Primary key or not, service-role paths have no RLS to fall back on and the repo's rule is to filter company_id explicitly, so it now does. It also discarded the query error. That mattered more than it looks: a failed read fell through to closingEntryId = null, which silently re-admits the closing verifikat's 78xx/88xx reversals and understates the tax base, i.e. exactly the failure this lookup was added to prevent. It now throws, and the surrounding catch turns it into the existing 'Failed to read posted dispositions' error. A wrong bolagsskatt is worse than a loud failure. Two regression tests: the lookup carries both eq filters, and a lookup failure propagates instead of degrading to a wrong number. Refs #1051 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(storage): drop the client-side DELETE policy on the documents bucket (#1254) * fix(storage): drop the client-side DELETE policy on the documents bucket 20240101000024 documents this bucket as WORM: "No UPDATE or DELETE policies". That described the repo, not production. Production carries a users_delete_own_documents policy that exists in no migration file: FOR DELETE TO authenticated USING (bucket_id = 'documents' AND (storage.foldername(name))[2] = auth.uid()::text) Under it, the uploading user can delete the storage bytes of any document they uploaded under the legacy documents/{userId}/... layout, using nothing but their normal browser token. That includes documents linked to a posted verifikat, which are rakenskapsinformation under the BFL 7 kap 2 § seven-year retention duty. deleteDocument()'s linked-check and the block_document_deletion() trigger both guard the document_attachments ROW, not the object: the row survives, still pointing at a file that is gone. Reproduced against a local replay of the full migration stream: with the policy present the uploader's own DELETE removes the object; with it dropped the same statement matches zero rows. Company-scoped keys were never exposed (their second path segment is the company id, not auth.uid()), so this only ever reached the legacy layout, which is where most documents still live. Safe because every in-app remove() on this bucket already runs on the service role, covered by service_role_all_documents. Deliberately narrow: users_read_own_documents and users_upload_own_documents stay. The Phase B backfill from 20260726092000 has not run, so dropping the legacy SELECT policy now would make existing documents unreadable. That is Phase C. The pg-real test asserts no DELETE and no UPDATE policy over the bucket under ANY name: the hole arrived under a name this repo never used, so pinning a name would not have caught it. Refs #1208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(storage): make the WORM ratchet see FOR ALL and WITH CHECK policies Review caught two blind spots in the ratchet, both fair. It matched only polcmd 'd' and 'w', but polcmd '*' (FOR ALL) grants DELETE and UPDATE just as effectively, and FOR ALL is the shape the one legitimate policy on this table already uses, so a hostile one would look unremarkable in the catalogue. It also read only polqual, so an UPDATE policy carrying its bucket restriction in WITH CHECK was invisible. Both assertions now run through one helper that covers d/w/*, concatenates USING and WITH CHECK, and filters by grantee so service_role_all_documents (how the application does its authorized deletes) is excluded while every client-reachable role is not. A policy granted to PUBLIC has an empty polroles, which is the most permissive case there is, so it is treated as client-reachable rather than as "no roles". Matching on the substring rather than the exact `bucket_id = 'documents'` shape pg_get_expr emits today: a policy written as bucket_id::text or with the comparison reversed would slip past a stricter match, and for a WORM ratchet a false alarm is cheap while a silent hole is not. Adds a probe case that creates a FOR ALL policy and asserts the helper sees it, so the main assertion cannot pass vacuously. That case earned its keep immediately: it caught that node-postgres hands back a raw string for a name[] column, so the role filter needed rolname::text to work at all. Verified against a local replay of the full migration stream: red with the original prod FOR DELETE policy present, red with a FOR ALL probe, green without either. Full pg-real suite 933 passed. Refs #1208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(storage): catch a destructive policy that names no bucket at all Adversarial review of the previous commit found the ratchet still failed open, and reproduced it: a policy with no bucket_id predicate covers EVERY bucket, documents included, so gating on the bucket name discarded exactly the widest hole. The concrete shape is Supabase's own stock "Enable delete for users based on user_id" template, USING (auth.uid() = owner), which is the single most likely form of a future dashboard edit. A destructive policy is now in scope unless it provably cannot reach this bucket, i.e. only a bucket_id predicate naming some other bucket exempts it. The behavioural assertions had the matching blind spot: fixtures were seeded without an owner, so an owner-based policy matched NULL and the DELETE reported 0 rows for the wrong reason. Objects now carry an owner the way storage-api stamps them in production, so those tests fail loudly instead of passing by accident. Two probes pin both directions: a bucketless policy must be reported (and is shown to really permit the delete), and a policy scoped to another bucket must not be, so the ratchet cannot start crying wolf on receipts or sie-files and get switched off. Verified against a local replay of the full migration stream: red with the stock bucketless template installed, green without it. Full pg-real suite 935 passed. Refs #1208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(kontoplan): make a deactivated account reachable again (#1262) is_active=false read as "does not exist" on every read path but as "exists" on the (company_id, account_number) unique constraint, so a deactivated account vanished from the kontoplan with no way back and re-creating it answered "Kontonummer X finns redan i din kontoplan." The write side was already correct: POST /accounts/activate has a toReactivate branch and PUT /accounts/[number] accepts is_active:true. Both were simply unreachable, so this opens routes to them rather than relaxing the read filters, which are load-bearing for AccountsNotInChartError. - Kontoplan gets a "Visa inaktiva" filter; inactive rows carry an "Inaktiv" chip and the existing per-row switch reactivates them in one click. - Deactivating an account that has posted lines now warns first, using the usage count already loaded for the Verifikat column. - POST /accounts distinguishes the two collisions and returns the new ACCOUNT_EXISTS_INACTIVE code; AddAccountDialog offers "Aktivera kontot istallet" rather than a dead-end 409. The stored account is left exactly as it was; values typed into the failed create form are not applied. - bas-lookup consults the company's own chart before the static BAS reference, so a deactivated custom account reads as known and "Aktivera och bokfor" is no longer disabled for it. New in_chart / is_active fields let callers tell "will be added" from "will be revived". - BAS-katalog stops showing "Aktiverat" for an account the company holds but has deactivated; it falls through to a relabelled Aktivera button, and the per-class counts follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off (#1255) * fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off A foreign supplier charging no Swedish VAT is normally omvand skattskyldighet. With the reverse-charge switch off, createSupplierInvoiceRegistrationEntry emits neither the 26x4 output leg nor the 44xx/45xx basis lines, so ruta 20-24, 30-32 and 48 all stay empty and the momsdeklaration takes a shape Skatteverket rejects. For a fully deductible purchase the net moms att betala is unchanged, which is exactly why this goes unnoticed. The form already auto-ticks reverse charge for eu_business but not for non_eu_business, so that path slips through silently. Adds a pure helper plus a non-blocking banner cloned from the existing rc_account_warning block. Deliberately silent for swedish_business, where 0 % is a genuine exemption that belongs in no ruta at all, and phrased as a question rather than an assertion: a non-EU goods purchase cleared at customs is legitimately 0 % without reverse charge, and pushing that user into ticking the switch would manufacture a new wrong verifikat. Does not add the exempt/import/other picker the issue proposes: supplier_invoices.vat_treatment is metadata that no booking or ruta mapping reads, and the codebase cannot book import VAT at all, so an import option would imply ruta 50/60 were handled when they are not. Refs #1042 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): name the local-VAT case in the foreign 0 % hint Review flagged that the most common foreign document a Swedish small company sees is an invoice carrying the supplier's OWN local VAT, booked at 0 % Swedish VAT with reverse charge correctly off. The banner fires there, and the previous copy only offered "momsfri av annat skal, till exempel en varuimport" as the way out, which does not describe that invoice at all: it is not VAT-free, it carries foreign VAT. Names both legitimate cases explicitly and says 0 % is correct in them, so the hint cannot read as an instruction to tick reverse charge on a purchase where that would produce a wrong verifikat. Title also narrowed to "utan svensk moms" for the same reason. Refs #1042 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(sandbox): call the sandbox assistant Assistenten, not Anna (#1244) A named persona earns its name once someone has been through onboarding and chosen it: it is their assistant and they named it. Nobody in the sandbox chose anything, so a first name reads as a character the product invented and implies a relationship the visitor never opted into. Both halves move together, which is the point. profile_summary is the agent's own self-description inside the system prompt, so leaving it as "Du är Anna" would have the header say one thing while the assistant introduces itself as another in its first sentence. Nothing else in the stack checks that pairing, so a test now does. Scope: this changes the seed, so new sandbox companies get the new name. The 483 sandbox profiles already seeded keep 'Anna' (the seeder returns early once a profile exists, and its caller only runs while verified_at is null). Backfilling those is a production write on demo data and is being raised separately rather than smuggled into a code change. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(reports): show the last posted voucher per series in report headers Adds a "Senaste bokforda verifikat: A 214, B 37" line to the balans- and resultatrapport, so a printed or exported report answers which vouchers are actually in it rather than only which dates it spans (#1267). Reads MAX(voucher_number) over posted entries, never voucher_sequences.last_number. The sequence counter is an allocation high-water mark that drifts from the books in both directions: next_voucher_number burns a number when the follow-up insert fails, delete_last_voucher decrements by one instead of resetting to the new MAX, and pre-RPC SIE imports left it behind. Since the point of the line is avstamning, an allocated number would send a reconciler chasing a gap that does not exist, so the label says plainly that the number is the posted one. Scoped to the report own date range, so a Q1 report printed in November says something true about Q1. The balansrapport keeps the fiscal-year start as its lower bound because it accumulates. Skipped on a dimension-filtered resultatrapport: that report already discloses it is partial, and an unfiltered voucher range beside a filtered result invites the wrong conclusion. Populated in both engines, so the JSON, PDF and XLSX routes all inherit it without signature changes. Best-effort: a header nicety never breaks a report. The pure formatter lives in its own module so the client view does not pull the Supabase query path into the browser bundle. No new i18n keys; both report views and the PDF template are hard-coded Swedish per the "stays Swedish" report surfaces in .claude/rules/i18n.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(customers): stop rendering personnummer ciphertext, make unreadable rows editable, add a reveal path (#1263) customers.personal_number holds AES-256-GCM ciphertext (20260726110000). Three defects compounded into one broken surface for private customers. The list queried Supabase from the browser with select('*') and rendered the raw value, 76-82 chars of hex, into the nowrap identifier cell. It now reads GET /api/customers, which already masks every row, so the ciphertext never leaves the server. Searching by personnummer works again: the client filter had been matching against ciphertext and could never hit. A row whose value cannot be decrypted renders as the placeholder '********-????'. None of the three mask checks recognised it, each having its own '-1234'-only copy, so such a customer could not be edited in ANY field: name and address edits 400'd on a personnummer the user had no way to correct. All three now share one pattern from the new crypto-free lib/customers/mask-personal-number.ts, which the client form can import. Typing a fresh personnummer overwrites the unreadable value, which is the only repair possible: the rejected writes failed whole INSERTs, so there is nothing to backfill. The value was write-only by construction. GET /api/customers/{id}/personal-number is the deliberate drill-in, mirroring the employee convention, gated on the write role because .compliance/ropa.yaml listed no_full_value_read_endpoint as a safeguard for this column; that entry is rewritten rather than left stale, and reveals log actor and customer id but never the value. Also: arcim-migration wrote the identity number as plaintext, which aborts any import containing a Privatperson with 23514 since the constraint flip; and the customer embeds on /api/invoices shipped ciphertext to the browser on every invoice read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: enhance ruta 05 handling for dynamic revenue accounts - Introduced `fetchDynamicRuta05Accounts` to fetch company-specific revenue accounts marked with a VAT rate, addressing issue #1261. - Updated VAT declaration logic to include these dynamic accounts in ruta 05 calculations, ensuring accurate reporting for user-added accounts. - Modified `ACCOUNT_RUTA` to include account 3000 for completeness in ruta 05. - Enhanced tests to validate the inclusion of user-added revenue accounts in ruta 05 and ensure correct VAT calculations. - Seeded default VAT rates for BAS revenue accounts to ensure proper classification in the VAT declaration. * fix: enhance data handling and masking in customer and invoice APIs * fix(vat): resolve the 3000 gruppkonto's rate for the ruta 05 base split 3000 "Forsaljning inom Sverige" is mapped to ruta05 by ACCOUNT_RUTA, so a balance on it is filed in the right box already. What was missing is the rate split: unlike 3001/3002/3003 the account number carries no sats, and fetchDynamicRuta05Accounts skipped it because it is in ACCOUNT_TO_BOX. A company posting to the gruppkonto therefore got a ruta 05 total that breakdown.invoices.base25/12/6 did not add up to. Surface those rates separately as staticRateByAccount: rate-only on purpose, because the static map already sums the account and adding it to the dynamic account list would double the filed figure. A test pins that single-count property. Also add 3000 to the MCP server's RUTA_05_ACCOUNTS, which is the display list behind report.rutor.ruta05: without it a 3000 balance appeared in the filed projection but not in the report the agent reads back. The comment claiming SALES_OUTPUT_VAT_SHORTFALL reads base25/12/6 was wrong and is corrected. That check derives its expected base from the output-VAT rutor (ruta10/0.25 + ruta11/0.12 + ruta12/0.06); nothing reads the per-rate bases, which are reporting metadata. So the incomplete split never affected a filed return or a warning, only the breakdown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> |