f7f3a31f8eadebc447af8abb732642e4d97a43ce
604 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6458180bf5 |
fix(db): reconcile orphaned mail-hunt migrations applied to prod (#1447)
* fix(db): reconcile orphaned mail-hunt migrations applied to prod Prod carries 20260807090000_mail_connections and 20260807090100_inbox_mail_hunt_source (applied 2026-08-07 morning) with no files in supabase/migrations/, so the Supabase integration fails every push to main with "Remote migration versions not found in local migrations directory". This commits the byte-identical SQL recovered from prod's supabase_migrations.schema_migrations statements under the exact applied versions. No new DDL runs on prod: the versions already exist there, this only reconciles the repo. Also classifies mail_connections in full-archive-export.ts (excluded: live OAuth refresh tokens, not portable), which the archive-completeness test requires for any new company_id table. Whoever owns the mail-hunt feature branch: these files now exist on main; reuse them as-is instead of re-creating the versions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): annotate reconciled mail_connections migration as pg-test skip The file is a byte-recovered reconciliation of DDL already applied to prod; it never executes again, so pg-real coverage belongs to the mail-hunt feature branch that owns the schema. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
93f81f03e8 |
feat(providers): WINT migration provider behind WINT_MIGRATION_ENABLED (#1446)
* feat(providers): WINT migration provider behind WINT_MIGRATION_ENABLED Adds WINT (wint.se) as a sixth migration provider, built against the OpenAPI specs WINT's own API host serves publicly. Tier A scope: only the partner-facing v1 endpoints are used; the general ledger is fetched as vouchers/accounts and rendered as SIE 4E by our own sie-builder, with opening balances for earlier years derived backward from the current-year Ib anchor. Auth is the user's WINT login exchanged once for a JWT pair; the password is never stored. Ships dark: the wizard shows a disabled "Kommer snart" card, and the server-side /connect gate rejects WINT until WINT_MIGRATION_ENABLED=true. Live verification against a real WINT account is still outstanding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(providers): harden WINT provider per PR #1446 review findings Addresses CodeRabbit and Swedish accounting review feedback in one pass: - Ib anchor selection now uses WINT's unfiltered fiscal-year list, so an active year outside the allowed import window can never silently anchor the wrong year; the voucher chain is extended through the anchor and a per-year fetch failure fails that year loudly instead of sinking the whole migration. - Auth token exchange is strict: only LoginState Success with a complete access+refresh pair mints a consent (a pair without a refresh token is unrefreshable and would break days later). - WintApiError no longer retains full response bodies (bounded 300-char diagnostic; bodies can carry customer data and errors get logged). - sie-builder refuses to render structurally invalid vouchers (missing account number or booking date) and documents deleted-voucher gaps in a #PROSA record per BFL 5 kap 6-7 §. - Account classification: 20xx is equity, 83xx is financial income. - SIE validator accepts EUBAS97 as BAS-based (standard kontoplanstyp; it previously produced a false non-BAS warning on every WINT/Bollbok file). - New tests: resolveConsent WINT refresh flow, credential upsert payload (no mail/password persisted), WINT fetch failure path, EUBAS97 warning regression, builder invalid-data rejection, vi.clearAllMocks hygiene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): pin EUBAS97 acceptance to the exact SIE spec value Review follow-up on PR #1446: match EUBAS97 exactly instead of any EUBAS* prefix, so the non-BAS kontoplan warning stays pinned to the four kontoplanstyp values the SIE 4B spec enumerates (BAS95, BAS96, EUBAS97, NE2007) rather than silently accepting unknown future variants. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c0a106e591 |
feat(ux): Bucket A defaults pass: remove choices the system already knows the answer to (#1443)
* feat(booking): batch VAT seeds from category default, period derives from entry date BatchCategorySelector and BulkBookInboxDialog hardcoded standard_25 as the initial VAT treatment, overriding the server's per-category derivation and claiming 25% moms on VAT-exempt bank fees. Both now default to an explicit 'Enligt kategori' option that omits vat_treatment so the server derives it (exempt bank/card fees, 12% representation). Reverse charge is never derived. The embedded JournalEntryForm period Select is replaced by the same derived read-only text the standalone variant already uses: the period is a total function of the entry date, and the Select allowed picking a period that disagreed with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(booking): prefill cost account from counterparty history; period text in Bokfor direkt BookDirectlyDialog and the supplier-invoice form left the cost account deliberately blank even when the company's own confirmed history for the counterparty (categorization_templates) or supplier.default_expense_account knew the answer. Both now prefill from a counterparty-template hit (new ?counterparty= single-match mode on the settings route, same tiered matcher as the booking flows), only into still-empty fields, only from expense-shaped templates, with a provenance line. No generic fallback: a miss leaves the field blank exactly as before. Bokfor direkt's period Select is replaced by text derived from the entry date; the silent periods[0] fallback becomes a blocking explanation, since borrowing an arbitrary period could book into the wrong one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ux): single-company login skips the picker; filing surfaces default to filable periods /select-company auto-forwards when the user is a member of exactly one company with nothing else to decide (no new TIC engagements, no pending invite, enrichment fresh); the in-app 'Lagg till foretag' links pass ?choose=1 to keep the picker deliberately reachable. Byra/multi-company users are untouched. The VAT declaration now opens on the most recently ENDED month/quarter (lib/vat/period-defaults, tested) instead of the current one, which can never be filed and forced a step-back click on every filing visit; the periodicity switch resets the same way. Helarsmoms FyPicker gains preferLatestEnded and opens on the latest ended rakenskapsar instead of the newest started one. The 'momsperiod saknas' dead end now collects the answer inline through the same PUT /api/settings validation instead of bouncing to settings: until the period exists the deadline engine generates zero VAT deadlines, silently, so every extra hop kept a compliance hole open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(granskning): approve pill commits directly for low and medium risk The Godkann pill on /pending only opened a ConfirmationDialog demanding a second Godkann, regardless of tier. The review row already states source, title and risk and offers Detaljer, so for low/medium the pill now commits directly; high risk keeps the dialog, whose warning sentence carries information the row does not. Chat-side bulk approve is deferred: it needs ApprovalCard's state lifted (assistant-redesign seam 8.8), see DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reports): map inline momsperiod save errors through getErrorMessage Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): repair the dead login auto-forward and nine review findings The big one: setActiveCompany ends with a cookie write that throws during Server Component render (sealed cookie store), so the /select-company auto-forward silently never fired; the write is now best-effort since the cookie is write-only compat and the DB write is already verified. Also: supplier-switch un-plants history-prefilled accounts so the new supplier's own default applies; prefill routes through handleAccountChange so konto default moms rides along; batch 'Ingen moms' books exempt instead of the derived 25%; monthly VAT default tracks the actual 12th/17th filing deadline (over-40M stays M-1); inline momsperiod setup uses EmptyState, gates on vat_number (the PUT would 400 without it), keeps keyboard focus and announces errors; cost-account shape guard tightened to P&L accounts; attn tone on the new warning lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger workflows; the Actions outage swallowed the rebase push event Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger after outage (events dropped, not delayed) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger after GitHub Actions recovery Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): address CodeRabbit and compliance-bot findings Direct commit now prunes the op from the bulk selection (a stale id kept inflating the bulk bar and rode into bulk-commit) and the detail-panel Godkann gets the same risk gate as the row pill. The automatic account fill in the supplier-invoice form is requested, not applied inline: the applying effect waits for both the BAS chart and the request with fresh closures, so a fill can no longer land before the chart and leave a VAT-free konto on the 25% row default. Test dates use local-time constructors (ISO strings parse as UTC midnight and shift a day in negative-offset timezones). Stale ML 11 kap citation dropped from a comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger; push event dropped again Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
63d520719a |
fix(import): treat a voucher-less SIE file as a no-op, not a failed migration (#1445)
* fix(import): treat a voucher-less SIE file as a no-op, not a failed migration A Fortnox migration aborted with the generic "Något gick fel. Försök igen." when the current fiscal year had nothing booked yet: Fortnox exports an empty SIE file for such a year, the finalizer's 0-entry safety net flipped it to 'failed', and the wizard stopped before the customer/supplier/invoice phase ever ran. Three layered fixes: - finalizeImportRecord only downgrades a 0-entry run to 'failed' when the file actually contained vouchers (parsed count via the documentation object). A file with no vouchers completes as a no-op with an explanatory warning; the mapping-fix retry loop the downgrade exists for (Lookma case) is unchanged. - The migration wizard no longer routes messages that are already user-facing Swedish (server envelopes, ImportResult.errors) through getErrorMessage's Swedish-pattern heuristic, which swallowed unrecognized sentences into the generic fallback. - The heuristic itself learns the import-error family (verifikation/importera) so other surfaces rethrowing engine messages keep the real reason too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): cross-check raw #VER before accepting a 0-voucher file as empty The parsed voucher count alone cannot prove a fiscal year was empty: a field-separator or encoding mismatch can swallow every #VER block with only a warning-severity parse issue, and executeSIEImport does not fail on those. Only a raw content check proves the file never declared any vouchers. Addresses the truncation/corruption finding from the Swedish compliance review on #1445. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record the raw #VER safeguard in the empty-SIE-file decision entry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
707d597b2e |
feat(woocommerce): store order/refund feed extension (#1442)
* feat(woocommerce): store order/refund feed extension Connect a WooCommerce store via the wc-auth key handshake (manual key fallback) with per-store consumer key/secret AES-256-GCM encrypted at rest, and import paid orders and refunds into the transactions inbox as a bank-style feed on the 1680 cash account. Feed-only: nothing auto-books, gateway fees/payouts are out of scope (core wc/v3 does not expose them). Sync is cursor-paginated on modified_after (offset pages only inside same-second date_modified ties), terminates on an empty page, holds the cursor below failed refund fetches / ingest errors / deadline-skipped work, checks the time budget between refund fetches, and drops rows dated on or before bookkeeping_locked_through on every run. Nightly cron gated on the extension registry + new paid capability woocommerce_sync (backfilled to existing bank_sync grant holders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): move woocommerce migrations past main's 20260806090000 origin/main gained 20260806090000_recurring_schedule_interval_months while this branch was in flight; identical version timestamps abort the Supabase apply, so the two new migrations move to 20260806170000/20260806170100. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit review findings - callback 503s early when WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is unset: encryptCredential would otherwise throw after the probe and strand the pending row without error_message - disconnect and upstream-revoke clear the encrypted consumer key/secret: nothing reads them after revoke and keeping decryptable dead credentials is unnecessary retention - manual sync gets a 240s time budget and the panel reports a truncated run as 'partial, sync again' instead of a normal completion - listOrderRefunds terminates on an empty batch (hosts may cap per_page), dedupes by id against hosts that ignore page, and caps total pages - unparseable money strings count as errors and log instead of being silently identical to a zero total - pg test uses per-run unique store URLs so committed rows cannot hit the store_url partial unique index across pg-real runs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit cycle-2 findings - listOrderRefunds throws when the page cap is exhausted with data still flowing, instead of returning a silently partial list the sync cursor would advance past; the error routes into the existing held-cursor refund-retry path - partial sync results keep the row-error count, and the partial toast string surfaces it (ICU plural, hidden at zero) in both locales Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger CI after dropped push event Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cd344b6dbb |
fix(db): enforce balance check on directly inserted posted journal entries (v2) (#1439)
* fix(db): enforce balance check on directly inserted posted journal entries check_balance_on_post only fires on the draft-to-posted UPDATE transition, so any code path that INSERTs a row with status 'posted' directly skipped balance validation entirely. The invariant sum(debit) = sum(credit) on every posted entry was DB-enforced only for the engine's commit lifecycle. Add check_balance_on_posted_insert, a deferred constraint trigger on AFTER INSERT WHEN (NEW.status = 'posted') reusing the existing check_journal_entry_balance() function, which already handles the journal_entries INSERT context via NEW.id/NEW.status. Deferred semantics let an atomic transaction insert header and lines together; zero-line and unbalanced posted inserts are rejected at constraint evaluation. All existing checks stay intact; this only adds coverage. The one first-party posted-INSERT path outside an RPC, the sandbox seed, now books through the bookkeeping engine (createJournalEntry) instead of raw inserts. SIE import already inserts header and lines in a single transaction via its structured RPC and passes unchanged. pg tests cover the new path (zero-line rejected, unbalanced rejected at SET CONSTRAINTS IMMEDIATE, balanced same-transaction insert accepted) and existing posted-entry fixtures move to a transactional insertPostedJournalEntry helper so they stay valid setup. Fixes #327 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tests): insert list-filters pg fixtures in one transaction The list-filters suite (landed via a sibling merge) inserted posted headers with getPool().query, where each query autocommits: the deferred check_balance_on_posted_insert constraint fired at the header's own commit with zero lines and correctly rejected the fixture. Header and balanced lines now share one BEGIN/COMMIT so the constraint evaluates the complete entry, mirroring the insertPostedJournalEntry helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(seed): insert journal headers as drafts, post after lines land check_balance_on_posted_insert (renamed to apply-time version 20260806130000) rejects a posted header whose transaction has no lines. PostgREST autocommits each request, so every seed path that inserted posted headers first would die with "has zero total": the sandbox seed (ledger history, invoice vouchers, salary vouchers), seed-demo-account and seed-export-data. All now insert draft headers, insert lines, then flip to posted so check_balance_on_post validates the finished verifikat. The sandbox seed keeps its documented no-events design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): preserve a preset committed_at on draft-to-posted transition set_committed_at() stamped now() unconditionally, so the seed flows that post backdated drafts lost their historical booking timestamps and every demo verifikat read as booked today (CodeRabbit finding on PR 1439). Stamp only when committed_at is NULL: the engine path (drafts carry no committed_at) behaves exactly as before and a posted entry still always has a committed_at; an explicitly supplied value now survives posting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): preserve preset committed_at only for trusted roles The IS NULL guard alone (20260806150000, never shipped; replaced by 20260806160000) let any RLS-permitted member backdate committed_at through PostgREST by presetting it on a draft and posting, which the Swedish accounting review flagged: committed_at is what the BFL 5 kap timeliness checks and behandlingshistorik treat as the genuine transition time. Preset values now survive posting only for service_role/postgres/supabase_admin; authenticated and anon writers always get the now() stamp. Consequence: the sandbox seed (runs as the requesting user) gets committed_at = posting time, accepted and documented in the route; the demo scripts run as service_role and keep their backdated history. pg tests cover all four paths, with the upper timestamp bound CodeRabbit asked for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): restore superseded migration so the preview tracker stays consistent The preview branch had already applied 20260806150000 when the previous commit deleted the file, orphaning the preview's migration tracker ("Remote migration versions not found in local migrations directory"). Restored with a header explaining it is superseded in the same deploy by 20260806160000, so the unguarded semantics are never live on their own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): decide committed_at trust by JWT claims, not current_user The Swedish review found the current_user guard bypassable: commit_journal_entry is SECURITY DEFINER and granted to authenticated, so inside it current_user is the function owner and a member could preset a backdated committed_at on a direct-inserted draft and launder it through the RPC. The guard now reads the JWT claims role (same primitive as the RPC's own tenant guard): preset values survive only for service_role or claim-less backend connections; authenticated and anon callers are always stamped now(), on both the direct UPDATE and the RPC path (new pg test). Both migration files now carry the identical final body so no unguarded intermediate exists as a standalone applyable unit. Behandlingshistorik logging of trusted overrides is follow-up #1444. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
28b58aedc4 |
feat(auth): sign in with Google behind NEXT_PUBLIC_GOOGLE_AUTH_ENABLED (#1441)
Adds a 'Continue with Google' button to login and register, gated by NEXT_PUBLIC_GOOGLE_AUTH_ENABLED so it ships dark until the Google provider is configured in Supabase. The OAuth round-trip reuses the existing /auth/callback PKCE exchange, which already owns MFA routing, invite acceptance and silent-team creation. A flow=oauth marker on the redirect lets the callback tag failures (including provider consent denials) so the login page shows Google-specific error copy instead of the email-confirmation framing. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0bb0b89353 |
feat(auth): inline, specific error states on login and signup (#1440)
* feat(auth): inline, specific error states on login and signup Auth failures now render inline next to the form instead of as a top-right toast: a persistent alert with role=alert, aria-invalid field highlighting, and focus returned to the offending field. Login maps GoTrue error codes (invalid_credentials, email_not_confirmed, rate limits, user_banned) to specific Swedish/English messages, with a reset-password link embedded in the credentials error. The credentials message stays 'wrong email or password' by design: GoTrue returns one code for both cases to prevent account enumeration. Signup gets a live password-requirements checklist, field-level errors for weak/mismatched passwords, and inline handling of email-exists, invalid-email and rate-limit responses with a sign-in link where that is the recovery path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): treat email_provider_disabled as signup-disabled with specific copy Review follow-up: GoTrue signals disabled email/password signups with email_provider_disabled as well as signup_disabled; classify both (plus the message-string fallback for older GoTrue) and give the register form a specific inline message instead of the generic fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a0ca692fed |
feat(invoices): quarterly, half-yearly and yearly recurring invoice schedules (#1438)
* fix(mcp): offer the link tool in the uncategorized-transactions VAT blocker The gnubok_vat_close_check blocker hint only named categorize/auto-match, both of which create new bookkeeping. For a transaction whose affarshandelse is already booked on an existing verifikat, following the hint would double-book, so agents dead-ended the case into "contact support" (2026-08-06 support mail from Orto Engineering). The hint now also names gnubok_link_transaction_to_journal_entry, is extracted as an exported constant pinned by a test, and the tool joins the categorize_month recommended loadout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): quarterly, half-yearly and yearly recurring schedules User request: recurring invoice schedules only supported monthly cadence. Adds interval_months (SMALLINT 1-12, default 1) to recurring_invoice_schedules; the UI offers manadsvis/kvartalsvis/ halvarsvis/arsvis presets while API and MCP accept any 1-12. The cron advances next_run_date by whole intervals from the due date, and the new rollNextRunDateForward() helper rolls missed or edited interval schedules on their own month grid so a quarterly Jan/Apr/Jul/Oct schedule missed in an outage rolls Jan 15 to Apr 15, never Feb 15. Monthly (interval 1) keeps its existing today-anchored recompute semantics unchanged. Changing the interval alone never touches next_run_date: the new cadence applies from the next run, so an edit can never pull a send earlier. Existing rows default to 1 and behave byte-identically. The MCP slice of this feature (interval_months on the three recurring-schedule tools in server.ts) was committed in d2600907f alongside the VAT-blocker hint fix by a parallel session sharing this worktree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): address PR #1438 review findings CodeRabbit round 1, all three findings: - MCP descriptions now state the full accepted interval range (any integer 1-12) instead of enumerating only the 1/3/6/12 presets, and qualify that changing ONLY interval_months leaves next_run_date untouched. - assertValidCadence rejects fractional day_of_month. - rollNextRunDateForward rejects calendar-invalid anchors that pass the shape regex (2026-13-05, 2026-02-31), with regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
43386b4852 |
feat(agent): offer unmatched inbox receipts as confirmable underlag (#1436)
The originally reported scenario is still broken after #1425 and its backfill-by-document_id: a user photographs a receipt into WhatsApp, answers the bot's questions, then opens the app, clicks the bank transaction and asks the assistant to book it, and is told "UNDERLAG: saknas" about a receipt we are holding, then asked everything again. WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id (process-inbound.ts passes uploadAndExtract's matchedTransactionId as undefined) nor transactions.document_id (that mirror is written by the manual match route). Only TransactionMatchPicker fills either column. So the underlag list comes back empty, and a backfill that keys on document_id has nothing to key on. Unmatched, unconsumed inbox items are now scored against the transaction with the same pure scorer the picker uses and the strongest few are surfaced as TROLIGT UNDERLAG, carrying their captured chat answers. Proposals only: nothing writes matched_transaction_id, and the prompt tells the agent to get the match confirmed and to book only against a confirmed one. Setting the link at intake above a confidence bar is the obvious alternative and is deliberately left open. An uncomparable amount (cross-currency with no rate) disqualifies a candidate, because calculateMatchConfidence drops the amount signal in that case and date + merchant alone then score a confident match nobody checked the sums for. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0f7147a078 |
fix(agent): read a WhatsApp "nej" as an answer, not a half answer (#1433)
* fix(agent): read a WhatsApp "nej" as an answer, not a half answer #1425 gave the assistant the answers the user typed in WhatsApp. Rendering those inline off the raw channel_context blob gets the most common answer backwards. Answering "nej" to the representation question stores an EMPTY representation block: participants: [], purpose: null, denied: true. The renderer branched on `if (!rep.purpose)` and so emitted syfte SAKNAS: fråga bara efter syftet, inte om deltagarna igen. for a user who had just said the meal was not representation. `denied` was never read anywhere. The result is the assistant asking about the purpose of a private lunch, which is worse than the generic re-ask #1425 fixed, because the instruction is specific and confident. Clarifications now come from a structured summary that models the denial and the genuine half answer (participants named, purpose missing, which BFL 5 kap 6-7 § does want completed) as different states. #1425's syfte SAKNAS nudge is preserved for the case it was written for. Two smaller fixes in the same renderer, both about untrusted text: - The photo caption no longer reaches the prompt. It is the one field on the record nobody was asked for and nobody reviewed, and the rationale already written down in lib/documents/channel-context-notes.ts for keeping it off an immutable verifikat applies at least as strongly to a prompt that can call tools. - Human free text passes through flattenMemoryContent. An intent's promptTemplate output is seeded as a user message, so wrapToolResult never sees it and nothing else defends this path; a caption reading "# NYA INSTRUKTIONER: ..." previously rendered verbatim. All three tests fail against the current renderer and pass against this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agent): gate the chat-answer guidance on what was rendered CodeRabbit caught the same defect shape this PR is about: the prior-conversation paragraph was gated on chat_answers != null, but a caption-only context is non-null and now summarises to nothing, so the paragraph pointed at 'uppgivna av användaren' rows the prompt does not contain. Gate on whether a clarification line was actually emitted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2dff83e2f3 |
feat(bokslut): kontantmetoden year-end cut-off for fordringar and skulder (#1432)
* feat(bokslut): kontantmetoden year-end cut-off for fordringar and skulder Under kontantmetoden nothing reaches 1510/2440 during the year, but BFL 5 kap 2 § still requires fordringar och skulder to be booked at rakenskapsarets utgang. That conversion did not exist: the AR/AP tie-outs were permanently unreconciled by construction for all cash companies, and the balance sheet omitted every open invoice. Adds lib/core/bookkeeping/kontantmetod-cutoff.ts: Fordringar: Debit 1510 / Credit 30xx / Credit 2618|2628|2638 Skulder: Debit 4-6xxx / Debit 2648 / Credit 2440 Moms goes to the VILANDE accounts, never 2611/2641. Under bokslutsmetoden moms is reported at payment, and the vilande accounts are deliberately absent from ACCOUNT_RUTA / ACCOUNT_TO_BOX, so parking it there keeps it out of the momsdeklaration until the invoice is actually paid. Booking it to 2641 would claim the deduction a period early. Two aggregate verifikat, each reversed on day 1 of the next period, and no invoices.journal_entry_id link: the payment flows route on that link, so per-invoice linking would send every new-year payment down the accrual clearing path against a receivable the vandning already removed. Leaving it unset means a new-year payment still books the normal kontantmetoden cash entry at the real payment date. Outstanding is computed from payment DATES, not remaining_amount: an invoice settled in January was still a fordran on 31 December, and reading remaining_amount would shrink the cut-off every day the bokslut is delayed. Surfaced as a bokslut wizard reminder (warning, not a blocker: promoting it would newly block every cash company mid-bokslut, which is a separate call). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): address compliance review on the kontantmetoden cut-off Three findings from the Swedish compliance review, all real: 1. BFL 5 kap 6-7 § traceability. The aggregate verifikat collected invoice references but never wrote them, so an examiner could not trace the 1510/2440 posting back to the affarshandelser behind it. Invoice numbers now go into the entry `notes` via buildCutoffNote(), truncated past 50 so the note stays a pointer to the reskontra rather than a copy of it. 2. Non-atomic posting. The cut-off and its vandning were two sequential creates with no rollback: if the reversal threw, 1510/2440 stayed permanently inflated and every new-year payment would double-book, which is exactly what the module docstring warns about. postKontantmetodCutoff now asserts the target period exists, is open, and contains the reversal date BEFORE posting anything, so the common failures refuse without writing. If a reversal still fails after its cut-off committed, the cut-off is stornoed through reverseEntry() (BFL 5 kap 5 §: never edit or delete a posted entry) and the original error is rethrown. 3. Silent vat_treatment default. Missing vat_treatment fell back to 25 %, which would route a 12/6/undantagen invoice to the wrong vilande account AND the wrong revenue account. Such rows are now collected into CutoffCollection.unknownVatTreatment, excluded from the cut-off, refused by the posting step, and surfaced as their own wizard reminder. Adds 11 cases for postKontantmetodCutoff, which had none: every refusal path asserts nothing was posted, and the storno-compensation path is covered in both the happy and the storno-also-failed direction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): never split a reverse charge across the cut-off Second compliance round. Verified the three data-dependent findings against production before changing anything; two needed no change, one is hardened: - Credit notes are NOT silently dropped: all 22 credit notes on prod carry document_type='invoice', so they are inside the collected set exactly as the comment claims. The filter only excludes proforma and delivery_note. - Vilande account numbers verified against the BAS 2026 chart in lib/bookkeeping/bas-data: 2618/2628/2638 utgaende, 2648 ingaende. The suggested 2617/2627/2637 do not exist. - Reverse charge: all 123 RC supplier invoices on prod carry vat_amount = 0, so no RC moms could reach 2648 today. That was an implicit data invariant, not an enforced one. CutoffPayable now carries reverseCharge and forces the cut-off moms to 0 for those rows, so a stray amount can never post a one-sided reverse charge into the single vilande bucket. The self-assessed output/input pair stays with the payment entry, after the vandning. Also names the reskontra as the underlag in a truncated aggregate note, so the verifikat points at its specification rather than implying the listed subset is the whole of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): surface stray moms on momsfri invoices instead of absorbing it Third compliance round, one legitimate new finding: moms on a treatment that cannot carry Swedish output moms (export, omvand betalningsskyldighet, undantagen) was folded into the revenue line with only a log.warn. That balances the verifikat while silently swallowing a real invoicing error, which is the netting the swedish-vat reference prohibits, and it was inconsistent with how the same module already treats a missing vat_treatment. Those rows now travel the same path as a missing treatment: collected into CutoffCollection.strayVatOnZeroRate, excluded from the cut-off, refused by the posting step, and surfaced as their own wizard reminder. buildCutoffLines keeps its balancing fallback for the case where such a row reaches it directly: it is now a last resort rather than the normal path, and it must still never invent a moms account nor unbalance the verifikat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d73f288927 |
fix(supplier-invoices): reverse credit notes on paid kontantmetoden invoices (#1430)
Under kontantmetoden the credit flow skipped the reversing verifikat entirely, gated on accounting_method === 'accrual' in all three surfaces (dashboard route, v1 route, pending-operations commit). That is right only while the original is still UNPAID: nothing reached the ledger, so there is no entry to reverse and recognition waits for cash. But a PAID original was already booked by its payment verifikat (expense + 2641 ingaende moms). Crediting it marked the invoice 'credited' with zero accounting trace, leaving both the cost and the moms deduction overstated and nothing to link a later refund back to. Adds supplierCreditNoteNeedsJournalEntry(), the mirror of the customer side's creditNoteNeedsJournalEntry(): reverse whenever the original actually reached the ledger, whatever the configured method. createSupplierCreditNoteEntry's existing shape already suits the cash case, the 2440 debit leaves a claim on the supplier that the refund clears, just as the customer side leaves a 1510 credit for a refund owed. The v1 route's GDPR-minimised projection dropped exactly the booked-ness columns this needs; they are restored with a comment explaining why, since status alone misses a part-paid-but-booked original (rows predating #1413). Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5b0ca3d874 |
fix(copy): make K3 and year-end claims match what the code actually does (#1431)
* fix(copy): make K3, leasing and year-end claims match what the code does
Follow-up to the batch that removed the uppskjuten-skatt posting on
obeskattade reserver (K3 29.37 gross in juridisk person) and added the K2
asset-account gate. Six user-facing strings still described the old
behaviour or made claims the code cannot support.
1. Arsredovisning page: the K3 explainer promised an uppskjuten skatt-not
and a materiella anlaggningstillgangar-not in every K3 document. Both
are conditional (a 2240/8940 balance, assets in the register) and the
first is now absent in the normal case. The kassaflodesanalys is
dropped with a warning when it cannot be generated, so it is named
only when the document actually carries one.
2. Regelverk settings: kassaflodesanalys was presented as following from
K3. It follows from being ett storre foretag
(swedish-year-end-closing/references/reporting-and-filing.md:10,
legal-framework.md:42); the copy now says the product includes one and
states the storre-foretag rule separately. Komponentavskrivning was
presented as optional under K3; it is mandatory where component useful
lives differ materially (k2-vs-k3.md:5, asset-accounting
references/depreciation.md:33).
3. Note 1 and the Uppskjutna skatter-not no longer claim the 2240 balance
is hanforlig till obeskattade reserver. deriveLatentTaxMovement reads
the 2240/8940 balances only, and under K3 that account carries deferred
tax on all temporary differences (k2-vs-k3.md:11-13).
4. The deferredTax 'unknown' branch emitted the gross-reserve statement,
which is the denial phrased positively: the same affirmative claim
about books that could not be read. It now emits no deferred-tax
paragraph at all; build-data already warns on that path.
5. Capitalized-lease detection looked at 1260/1269 only. On the shipped
BAS 2026 chart 1260 is a free inventarier account and 1269 is ack.
avskrivningar pa datorer, so owned computers were reported as leased,
while 1217/1227 (finansiellt leasade) were missed. Detection now reads
the company's own account names in kontogrupp 12, which is where BAS
keeps capitalized leases (leasing-and-disposal.md:28) and which owned
inventarier on 1220 never matches. 1720 forutbetalda leasingavgifter
stays out: that is the operational treatment.
6a. gnubok_year_end_readiness listed FX revaluation as a blocker (it is a
warning) and omitted UNBOOKED_TRANSACTIONS, the common one. The
description now names every actionable blocker kind, within the
280-char budget, and a test pins it against YEAR_END_BLOCKER_KIND.
6b. companies.accounting_framework defaults to 'k2', so every enskild
firma hit the K2 asset gate and was handed a BFNAR 2016:10 punkt 10.4
citation plus a K3 remedy it cannot take: a sole trader prepares ett
forenklat arsbokslut, not an arsredovisning (legal-framework.md:29,
:48). entity_type now rides along on the companies read the routes
already do, and non-AB entities get wording with no citation and no
K3, keeping the 1090 remedy. The K1 counterpart of punkt 10.4 is not
sourced in the repo skills, so nothing was invented in its place.
* fix(copy): close the review findings on the copy-truth sweep
Three follow-ups from the source and code reviews. (1) The K2/K3 help text had upgraded a vague sentence into a definite boundary claim ('gransen gar vid <trosklar>'), which excludes the other routes into mandatory K3 that are live right now for this control's audience: noterade vardepapper, and from fiscal years starting after 2025-12-31 also utlandsk filial, kryptotillgangar, aktierelaterade ersattningar and fastighetsbolag. An AB in one of those categories would have read the sentence and stayed on a regelverk it may no longer use. (2) hasCapitalizedLeaseAsset compared per-side cumulative totals, so a lease acquired earlier and disposed this year still claimed the balance sheet carries a leased asset; it now compares the net balance. (3) The K3 warning enumerated a kassaflodesanalys the document may not contain, contradicting the newly conditional page copy on the same screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
a5c10e441a |
chore(analytics): configure posthog session replay masking (#1428)
* chore(analytics): configure posthog session replay masking Move session replay from the mask-everything default to pattern-based masking in lib/analytics/replay-masking.ts: currency-shaped text, person-/organisationsnummer (rendered and typed) and password inputs are masked; other interface text and typed input is recorded for debugging. data-ph-mask keeps force-masking tagged PII and data-ph-unmask is still honored for chrome. Privacy policy, RoPA and decision log updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(analytics): address review comments on replay masking PR Bump the privacy policy's visible last-updated date to 2026-08-06 and add the conventional vi.clearAllMocks() beforeEach to the replay-masking tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ab712f829a |
fix(arsredovisning): truthful K3 policy note and GRANSKNINGSUTKAST watermark (#1423)
* fix(arsredovisning): truthful K3 policy note and GRANSKNINGSUTKAST watermark The K3 redovisningsprinciper note asserted policies the engine does not follow: full balansrakningsmetoden deferred tax and finance lease capitalization. The note now states actual practice: deferred tax only on obeskattade reserver at 20,6 percent, all leases expensed as operational. Every K3 report is classified AR-K3-DRAFT-ONLY, so the K3 PDF now carries a fixed rotated GRANSKNINGSUTKAST watermark on every page, and the arsredovisning studio shows an always visible AttnLine for K3 explaining that the document cannot be locked or filed via Accounted yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(arsredovisning): stop the two K3 deferred-tax notes contradicting each other The redovisningsprinciper note said deferred tax on obeskattade reserver is not separately recognised in juridisk person, while the "Uppskjutna skatter" note right after it disclosed exactly that split plus the 8940 movement. A signed arsredovisning cannot say both. Both notes are now driven by one derivation (deriveLatentTaxMovement in build-data.ts, computed before note 1 is built): with no 2240/8940 activity the policy paragraph states the gross treatment K3 29.37 permits and no movement note is emitted; with a 2240 balance the paragraph discloses the recognised liability and points at the movement note. Neither text claims a measurement rate or an origin the builder cannot verify, since a balance can come from the K3 bokslutsdisposition, a legacy posting or an SIE import booked at 22 or 21,4 percent. The leasing paragraph now states the basis for its blanket treatment: the document is always a single juridisk person with no koncernredovisning, so the K3 punkt 20.29 exemption applies. Without that qualifier the paragraph asserted a treatment that is not the general K3 rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(arsredovisning): never assert a K3 policy the books can contradict Three cases where the principles note still made claims it could not back: a read failure on the 2240/8940 figures printed the affirmative denial anyway, the leasing paragraph asserted a fact about the entity's group obligations that a Koncernforhallanden note in the same document can contradict, and the blanket operational-lease claim survived even when the balance sheet carries leased assets on 1260/1269. Deferred tax becomes a tri-state (none/recognized/unknown) and leasing is driven by the same trial balance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
12c59399ee |
fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) (#1421)
* fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) and confirm K3 to K2 reversion In juridisk person K3 29.37 keeps obeskattade reserver at gross; the 79.4/20.6 split belongs to koncernredovisning. The old disposition double-counted the tax portion (result charged twice, 2240 overstated on top of gross 21xx). Removes the proposal step, POST kind, UI case, K2-to-K3 account seeding and the interim framework gate; keeps LATENT_TAX_DEFAULT_RATE for analytical soliditet presentation. Also adds the K3-to-K2 consequence confirmation dialog in settings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(decisions): scope the batch log to shipped code and record the 29.37 election nuance Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): stop promising deferred-tax accounting the engine no longer does The framework help text and the K2-to-K3 confirmation both told the user that switching to K3 means uppskjuten skatt is recognised separately on 2240/8940 with a 79.4/20.6 split. This PR removes exactly that behaviour, so the copy would have promised something the product does not do, which is the defect class this batch exists to remove. Both now describe what actually happens: kassaflodesanalys, komponentavskrivning and a wider note set, with obeskattade reserver carried gross per K3 29.37. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8f38baca05 |
fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults (#1422)
* fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults K2 companies (BFNAR 2016:10 punkt 10.4) may not capitalize internally developed intangibles, but the asset register defaulted the immaterial category onto 1010/1019 (Utvecklingsutgifter) for everyone and had no framework gate beyond K3_REQUIRED_FOR_COMPONENTS. - New K2_EXCLUDED_ACCOUNT gate (422) in POST /api/assets and PATCH /api/assets/[id]: when accounting_framework is not k3, reject any asset whose resolved asset or accumulated account is flagged k2_excluded in the BAS reference. Resolution mirrors the service defaults so category defaults cannot sneak onto 1010/1019; patches that leave category and accounts untouched skip the gate so legacy assets stay editable. - Shared guard helper in lib/bokslut/assets/k2-account-guard.ts; code registered in structured-errors.ts with Swedish and English messages. - CreateAssetDialog: non K3 companies now book immaterial assets on the purchased pair 1090/1099 with a quiet hint that egenupparbetad utveckling requires K3; K3 companies picking immaterial see a note about fond for utvecklingsutgifter (2089) per ARL 4 kap. 2 par. - Route tests: K2 rejected on 1010 defaults and explicit overrides, K2 accepted on purchased accounts, K3 accepted on 1010, PATCH equivalents and a gate skip regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(assets): cite punkt 10.4 only when the intangible group triggered the K2 gate The K2 gate fires on ANY account the BAS chart flags k2_excluded, but the rejection hardcoded an egenupparbetade immateriella / BFNAR 2016:10 punkt 10.4 citation. The flag also covers accounts excluded from K2 for unrelated reasons (1370/2240/8940 uppskjuten skatt, 1518, 2089, 2092, 2096, 2448, 3940, 7940, 8290 to 8480), so those users got a factually wrong legal citation in a compliance product. PATCH can reach them today: UpdateAssetSchema has no BAS range refinement, so an explicit bas_asset_account override outside the category range hits the gate before updateAsset() raises its range error. - k2ExcludedAccountMessages() now picks the wording from what actually triggered the gate. The boundary is derived from the chart itself (k2_excluded + account_class 1 + kontogrupp 10), which is exactly the egenupparbetade set 1010, 1011, 1012, 1018, 1019, 1081; no magic list, so a flag change in bas-data moves the boundary with it. Other Ej K2 accounts get a generic message: the chart marks it Ej K2 and it requires K3, with no invented paragraph reference. - Both messages are bilingual (message_sv / message_en, registry shape) and the routes now return message_en alongside message. - The static K2_EXCLUDED_ACCOUNT registry entry drops the intangible citation too: it is the code level fallback for every k2_excluded account. - Tests: route level distinction pinned in id.test.ts (1010/1081 cite 10.4, 1370 must not), plus a guard unit test asserting the derived group and that no non group 10 Ej K2 account ever cites 10.4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(assets): let K2 companies register acquired intangibles, server side The K2 gate blocked a lawful case. K2 forbids only EGENUPPARBETADE immateriella tillgangar; acquired ones may be recognized (k2-vs-k3.md:24, "Only acquired intangibles may be recognized"). But asset-service still resolved category 'immaterial' to 1010/1019 for everyone, and only CreateAssetDialog compensated with an explicit 1090/1099 override. EditAssetDialog sends just the changed fields and has no account inputs, so a K2 aktiebolag recategorizing a bought licence to "Immateriell tillgang" hit the defaults, got a 422, and was told to switch the company to K3, which would pull in komponentavskrivning and uppskjuten skatt and rewrite the whole arsredovisning. The asset stayed on 1220/1229 and kept being presented as a tangible asset. - defaultAccountsForCategory(category, framework) is the single resolution point: immaterial resolves to the acquired pair 1090/1099 unless the framework is k3, every other category is unchanged. Both createAsset() and updateAsset()'s category realign go through resolveDefaultAccounts(), which reads companies.accounting_framework only for the intangible category and throws rather than guessing when that read fails. Explicit overrides and the realign-skip semantics are untouched. - Both routes resolve gate accounts through the same function, so the check mirrors what the service will persist. A K2 company on the defaults now passes; a deliberate override onto 1010/1011/1012/1018/1019/1081 still 422s. - CreateAssetDialog drops its now redundant client override so the two surfaces cannot drift; the hint text stays. - The 422 no longer asserts the company's framework (the companies read behind it discards its error, so a transient failure would assert it against a K3 company) and no longer proposes a regelverk change. It states that the account is reserved for egenupparbetade utvecklingsutgifter, which require K3, and points at 1090 for an acquired intangible. Punkt 10.4 stays scoped to the kontogrupp 10 group, derived from the chart as before. sv and en. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
78a37f6396 |
fix(year-end): typed preflight blocker codes so remediation links render (#1420)
* fix(year-end): typed preflight blocker codes so remediation links render validateYearEndReadiness emits Swedish blocker strings but the wizard's BlockerRow matched English phrases, so no remediation link ever rendered, and the voucher-gap branch pointed at /bookkeeping/voucher-gaps which only exists as an API route. Blockers now carry stable machine codes end to end (YearEndBlockerCode on YearEndValidation.blockers, mirrored additively as blockerItems on BokslutReadinessReport); errors stays the plain string mirror so the v1 compliance check and MCP tool keep their exact shapes. BlockerRow matches on code and links only to pages that exist; the voucher-gap and dead-link branches are removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(year-end): code the unbooked-transaction blockers #1414 added #1414 landed two new blockers in validateYearEndReadiness using the old errors.push style, which this branch had already renamed to a typed blockers array. Merging main left them referencing a variable that no longer exists. Converted both to the typed scheme: UNBOOKED_TRANSACTIONS (the safety guard that stops executeYearEndClosing from aborting at the step 7 lock AFTER the closing entry posted at step 4) and UNBOOKED_CHECK_FAILED (the fail-closed variant). Neither behaviour changes; both keep their Swedish wording verbatim. The MCP year_end_readiness classifier now routes on the stable YearEndBlockerCode instead of regexing the Swedish message, with the wording heuristic kept as a fallback for an unmapped or legacy English message. The public `kind` values are unchanged, so MCP consumers see the same output; both new codes map to 'unbooked_transactions' as before, since an agent reacts to "we could not tell" the same way it reacts to a real count. UNBOOKED_TRANSACTIONS gets a /transactions remediation link in the preflight step: that page is where a transaction is booked or marked private, the two remedies the message names. UNBOOKED_CHECK_FAILED gets none: the remedy is to re-run the check. --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
31fba5e8f9 |
fix(accruals): carry origin dimensions onto dissolution entries (#1419)
* fix(accruals): carry origin dimensions onto dissolution entries
A project-tagged deferred invoice line lost its dimensions bag on every
monthly dissolution: the schedule never stored the bag, so dissolution
lines booked untagged and the per-project P&L drifted from the origin.
Persist the merged bag (invoice default_dimensions with the item bag on
top, same merge the origin generators use) on accrual_schedules and
attach it to BOTH dissolution lines: both origin generators tag the
interim 17xx/29xx line too, so per-dimension views of the interim
account keep netting to zero. Pre-existing schedules stay at '{}' and
keep today's untagged behavior; backfill is a separate follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(accruals): let dissolutions post when the tagged dimension value is archived
Carrying the origin's dimensions bag onto dissolution lines pulled every
monthly periodisering into validateEntryDimensions. With dimensions_enabled,
a project archived after the origin invoice was booked made the validator
reject the dissolution: the accrual service caught the rejection, wrote
last_error and left the installment pending forever. The remaining months of
prepaid cost would never reach 5xxx/6xxx, the interim 17xx/29xx account would
stay overstated, and the trial balance still balances, so no year-end check
fires and the arsredovisning is filed with understated cost.
Add a second, narrow exempt set (DIMENSION_VALIDATION_EXEMPT_SOURCE_TYPES =
{'accrual'}) with an isDimensionValidationExemptSource() helper, and skip the
soft registry validation for it in createDraftEntry. A dissolution is the
mechanical continuation of an already-approved, already-posted decision: the
same category as a storno, which the engine already bypasses.
The tag is kept, never stripped: an archived value still exists in the
registry and the cost genuinely belongs to that project. The rules set
(required/default/fixed) stays untouched, and the other two
validateEntryDimensions call sites keep validating: updateDraftEntry is a
user edit of an editable draft, and replaceOpeningBalanceEntry rejects any
source_type other than opening_balance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
d7320a6c87 |
feat(invoice-inbox): show receipt fields on receipts, invoice fields on invoices (#1426)
* feat(invoice-inbox): show receipt fields on receipts, invoice fields on invoices A kassakvitto has no due date, no OCR reference, no invoice number and no bankgiro: it is already paid. The rail rendered all four as empty boxes anyway, so a perfectly extracted receipt looked like a failed extraction. The date was mislabelled too: on a receipt the document date is the purchase date, not a "Fakturadatum". When extracted_data.documentKind is 'receipt' the rail now labels the date "Inköpsdatum" and folds the five invoice-only fields behind a quiet "Visa fakturafält (N)" link. Two rules keep it safe when the classification is wrong: a field holding a value is never hidden (a hybrid restaurangnota with an invoice number still shows it), and the fold is one click from being undone. Logic lives in lib/documents/inbox-field-visibility.ts rather than the component: this repo has no jsdom or testing-library, so display rules are only testable as a React-free module. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoice-inbox): reset the invoice-field fold when switching documents Review catch (PR Agent on #1426): showAllFields was component state with no per-item reset, and EditableFieldsList stays mounted across selections in the rail. Expanding "Visa fakturafält" on one receipt therefore left the invoice-only fields open on the next document, which reads as if that one carried them too. Reset alongside drafts and edit provenance in the existing itemId effect, and moved the declaration up next to the other state so it is defined above its first use rather than relying on hoisting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
576ed4d290 |
fix(agent): let the assistant see answers the user gave in WhatsApp (#1425)
Two field findings from the first live receipts.
1. The assistant re-asked for information the user had already given.
The user answered the representation question in WhatsApp ("Elias
Karlsson från Canguro Media, Jakob Wennberg från Arcim"), the answer
was stored correctly on invoice_inbox_items.channel_context, and then
the in-app assistant said it could see no participant names and asked
for them again. The intent's inbox query selected only document_id and
extracted_data, and nothing under lib/agent/ read channel_context at
all. It is now selected, threaded onto each underlag as chat_answers,
and rendered into the prompt as "uppgivna av användaren" with an
explicit instruction that human answers outrank anything read off the
image and must never be re-asked. Also backfilled by document_id: a
receipt can reach the intent through the document paths without its
inbox row being matched to the transaction.
2. The representation question accepted half an answer in silence.
Naming participants but no purpose stored purpose=null and replied
"Tack!", leaving the deduction undocumented while looking complete.
Skatteverket wants both (BFL 5 kap 6-7 §). It now asks once, for the
missing half only, and keeps the question open so the reply routes
back to the same receipt. Anti-loop: the follow-up fires only when no
representation block exists yet, so a second incomplete answer is
taken as-is rather than nagging.
Tests cover the prompt half and the query half separately: the earlier
prompt tests injected chat_answers directly and would have stayed green
with the column still missing from the select, which is precisely how the
bug shipped. Both mutation-checked.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
c0825e9bd2 |
fix(bokslut): surface unbooked transactions and AR/AP tie-outs in year-end preflight (#1414)
* fix(bokslut): surface unbooked transactions and AR/AP tie-outs in year-end preflight Two gaps in the year-end readiness layer: 1. Unbooked bank transactions were enforced only by lockPeriod, which runs at step 7 of executeYearEndClosing, AFTER the closing entry has posted at step 4. A period with unbooked transactions reported ready: true from gnubok_year_end_readiness and the wizard, then aborted mid-flow, leaving a posted closing entry on an unlocked, unclosed period. The readiness check now runs the same counter as the lock guard (countUnbookedInPeriod, so the number reconciles with the "att bokföra" badge) as a blocking error, failing closed if the check cannot run. The lockPeriod guard stays as defense in depth. The MCP classifier tags the new blocker as kind unbooked_transactions. 2. The Phase-1 avstamningar (kundreskontra vs 1510, leverantörsreskontra vs 2440) existed as reports (lib/reports/ar-reconciliation.ts, supplier-reconciliation.ts) but were wired only to the ledger report routes, never to the bokslut preflight. The readiness aggregator now runs both tie-outs and surfaces mismatches as warning-severity reminders with deep links, mirroring the bank-reconciliation reminder. Warnings only, never blockers: a difference can be legitimate (FX-settled partials). Skipped entirely for kontantmetod companies, where open invoices are deliberately not on 1510/2440 until the year-end conversion exists and the tie-out is permanently unreconciled by construction. Unconvertible-FX rows produce a "could not reconcile" message instead of a phantom difference. YearEndValidation gains an optional unbookedTransactionCount field; the v1 compliance endpoint and MCP readiness tool pick the new blocker up automatically since they share the same engine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): classify the next-period-IB readiness blocker instead of kind other The blocker "Nästa räkenskapsperiod har redan ingående balanser bokförda" was the only validateYearEndReadiness error with no classifier regex, so it always surfaced as kind: 'other'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): log swallowed AR/AP tie-out failures in the readiness aggregator Compliance-review finding: a rejected tie-out produced no reminder and no log entry, making a failed avstämning control indistinguishable from a reconciled one. Still degrades to no reminder (advisory check), but the rejection reason is now traceable, mirroring the unbooked-transaction check's logging. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
17d48ebb84 |
fix(invoices): guard cash-method partial payments across every payment path (#1413)
* fix(invoices): guard cash-method partial payments across every payment path A never-booked kontantmetoden invoice can only be settled by the generated cash entry (createInvoiceCashEntry / createSupplierInvoiceCashEntry), and that entry always books the FULL invoice: it takes no payment amount. Three payment surfaces still let partial payments through to it, corrupting books: - settleInvoicePayment dropped the fully-paid term entirely, so a partial payment (Stripe sync, mark-paid) booked the entire invoice: over-recognized revenue, over-declared output VAT, and a bank debit that did not match the money received. - The dashboard and agent match-transaction paths fell back to an accrual-style clearing entry against an EMPTY 1510: negative receivable, no revenue, no moms (ML 13 kap 8 § puts each installment's moms in its own receipt period). The comment claimed the credit "gets resolved on final payment", but the cash builder never touches 1510 and books the full total, so the final payment double-debited the bank instead. - The supplier routes had no full-settlement term at all, so a partial payment booked the full expense + input VAT. Fix: one shared predicate (cashPartialBlockReason in booking-mode.ts) rejects generated cash entries unless the payment settles the invoice in full from a fully unpaid state, wired into all six POST surfaces, the agent commit paths, and the three preview routes (so dialogs cannot propose a verifikat the POST refuses). New bilingual error codes INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED / SI_CASH_PARTIAL_UNSUPPORTED. Invoices booked at issue are unaffected: their partial payments keep the normal 1510/2440 clearing path. The v1 match-invoice route already had this guard (VALIDATION_ERROR); its behavior is unchanged. Proper per-installment recognition (proportional revenue + moms per receipt) is the follow-up feature that would lift this restriction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(review): drop stale ML 13 kap 8 § cites for kontantmetoden VAT timing Compliance-review finding: the section is the old ML 1994:200 numbering; in ML 2023:200, 13 kap covers input-VAT deduction, not redovisningstidpunkt. The substantive rule (bokslutsmetoden reports moms at payment, per installment, except at year-end) is unchanged and stated without a section cite until the current-law section is verified. Comments and cookbook prose only; no behavior change. Also fixes the two pre-existing occurrences. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
629069e281 |
feat(whatsapp-inbox): conversation layer with clarifying questions (#1340)
PR4 of the WhatsApp intake track: turns the per-message PR3 pipeline into a
conversation. Media replies are burst-debounced into ONE combined ack (M4
single / M5 numbered list) sent by the single winner of the atomic
pending_ack claim; losers stay silent. Multi-company senders get the company
question (reply buttons <=3, list 4-10, numbered text >10) with an 8h
sliding pin ('byt' clears it); their receipts park as staged message rows
until the answer and then run through the normal intake path.
Clarifying questions are evaluated per receipt after extraction, max one per
receipt, priority unreadable > representation > partial, keyed on the
Phase-0 classification (legibility/documentKind/merchantCategory) with
heuristic fallbacks (compressed-chat-photo signal, extended meal regex).
Budgets: <=2 content questions per burst, <=6 per sender per Stockholm day;
over budget acks only and flags the item moved_to_app. Questions expire
after 48h (sweep, silent hand-off) and are asked exactly once.
Free-text answers route through the ONE new LLM call
(lib/interpret-answer.ts): Sonnet via Bedrock, max_tokens 600, no thinking,
forced tool call validated by Zod with hard caps, gated by
checkAgentRateLimit, reply framed as untrusted data. Any failure degrades to
storing the raw text as a note; exact 'nej' short-circuits without the LLM.
Answers land in invoice_inbox_items.channel_context
(representation/user_note/quality) with ChannelQuestionAsked/Answered
processing-history events. Late answers match by quoted wamid or the most
recent open question within 7 days.
New per-minute sweep cron (registry-gated physical route, 503
EXTENSION_DISABLED when off) re-claims stuck rows (max 3 attempts), rescues
crashed burst acks, expires questions and pins. One new migration
(20260802210000) adds whatsapp_messages.acked_at, the relational burst-
membership marker, with pg-real coverage for the single-winner claim.
Verified: full vitest suite (12270), pg-real against a migrated
supabase/postgres 15 (977), lint 0 errors, tsc at the 405 baseline,
check:guards green, crontabs regenerated. Mutation-checked the debounce
claim and the daily budget gate.
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
d1c411ad6f |
feat(invoice-inbox): surface WhatsApp chat context in booking flows (#1339)
* feat(invoice-inbox): surface WhatsApp chat context in booking flows
The WhatsApp intake bot writes verified human answers (photo caption,
representation deltagare + syfte, sender note, open-question state) to
invoice_inbox_items.channel_context. This makes the in-app booking flows
READ it:
- New core renderer lib/documents/channel-context-notes.ts: deterministic
compact Swedish line ("Representation: Anna Berg (Volvo), Jakob W ·
Syfte: uppföljning av avtal"), capped at 220 chars by dropping whole
participant names ("… och N till"), never mid-name. Representation
first, then user_note; caption only when nothing else exists.
- FieldsRail "Från WhatsApp" block in InvoiceInboxWorkspace: caption,
deltagare, syfte, anteckning rows plus an ochre AttnLine when a chat
question expired unanswered (pending_question.status = moved_to_app).
- Notes threading: book-direct and convert default their notes to the
rendered string server-side when the request carries none (a supplied
value always wins); BookDirectlyDialog prefills its notes input with
the same string so the user can edit it before it lands. Bulk-book
(categorize-core) joins the shared batch note with the per-item
rendered context so the representation trail survives batch booking.
- Inbox list: whatsapp rows get a chat icon and a quiet "Fråga obesvarad"
badge for moved_to_app items. No worklist count change: unresolved
whatsapp items are already counted by countInboxDocuments.
- sv/en strings for every new key; renderer + route + bulk tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(invoice-inbox): honor cleared notes and keep unreviewed captions out of verifikat
Two adversarial-review findings on the WhatsApp surfacing flows, both about
text that lands on an immutable verifikat.
1. A cleared note was silently re-applied. BookDirectlyDialog prefills the
rendered chat context, and the dialog sent `notes.trim() || undefined`
while book-direct and convert defaulted from channel_context on any falsy
value. A user who read the prefill, disagreed and deleted it therefore got
it written back onto a posted entry, removable only through a formal
rättelse. Both code comments claimed "an edited value always wins", which
was false for exactly that edit. Now PRESENCE of the field decides: the
dialog always submits `notes` (empty string included) and the routes only
default when the field is absent from the request (MCP, older clients).
The Zod `.optional()` carrying that distinction is documented at the
schema so it is not "tidied" into a `.default('')` later.
2. The photo caption was auto-burned into verifikat text with no review.
renderChannelContextNotes fell back to the raw caption, and bulk-book
appended the result per item with no per-item notes field at all (the MCP
approval preview deliberately shows no per-item PII either), so unreviewed
chat text reached a WORM record nobody had seen. The renderer now takes
{ includeCaption } and leaves the caption out by DEFAULT: representation
answers and user_note are replies to a question the bot asked, the caption
is not. Only the Bokför direkt prefill opts in, where the user reads the
string in an editable field before booking.
Tests: cleared-notes and whitespace-cleared on book-direct, cleared-notes on
convert, caption-never-defaulted on both routes, caption-not-threaded in
bulk-book, and the renderer's opt-in. The book-direct cleared-notes tests
were mutation-checked (restoring the truthiness fallback fails them).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reports): export the chat answers behind a verifikat in the full archive
The verifikat line caps the representation trail at 220 chars and drops whole
participant names ("… och N till"); the complete list (deltagare, syfte,
raw_answer) exists only in invoice_inbox_items.channel_context. That table was
in ARCHIVE_EXCLUDED_TABLES with a rationale predating channel_context ("inbox
workflow state"), so a company leaving Accounted and keeping the full-archive
export as its BFL 7-year record kept an incomplete deltagare documentation for
its representation deductions.
Dumped as a column PROJECTION, not the whole row: the new
MasterDataTableSpec.columns narrows the select to the underlag provenance
(document, matched transaction, created verifikat / leverantörsfaktura) plus
channel_context, so the answers are tied to what was booked from them while
the inbox workflow state (email bodies, OCR output, error messages) stays out
of the archive. The documents themselves remain in dokument/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
398c734b93 |
feat(whatsapp-inbox): intake extension with webhook, phone linking and receipt ack (#1338)
Webhook lifecycle: GET hub.challenge handshake (constant-time verify-token compare); POST verifies X-Hub-Signature-256 over the RAW body before any parse, Zod-parses the envelope, persists inbound rows (partial-unique wamid = dedupe against Meta's up-to-7-day redelivery), acks 200 fast and defers media processing via the after() idiom. Rejected and rate-limited content always acks 200 and lands as skipped/error rows, never a retryable status. Linking: the settings panel (Installningar -> WhatsApp) mints AC- one-time codes (sha256 stored, 10 min TTL, single use, ambiguity-free alphabet); the webhook consumes the code, binds phone to user (HMAC-peppered hash + AES-256- GCM at rest) and confirms with M3. Keyword commands stopp/start/hjalp; unknown senders get one throttled M1 greeting (1/h, 3/day) behind the sender-quota RPC, with no media download and no content persistence. Intake worker: atomic claim on the message row (the durable job record), company resolution (default -> sole membership -> M6 fallback, no item), per-company inbox quota (ack-and-drop, M17 once per 10 min per sender), MIME allowlist, 10 MB stream-checked media download, exact sha256 duplicate check, then the shared uploadAndExtract funnel (source 'whatsapp', channel_context caption, whatsapp_message_id) and the M4 ack with extracted merchant/total/date. Failures wrap to 'error' + error_message + one M18. uploadAndExtract widened: source 'whatsapp', optional channelMeta + actorId; email/upload paths behaviorally unchanged. Deferred to PR4: burst debounce + combined ack (M5), in-chat company choice (M6 buttons + 8h pin), clarifying questions M7-M10, interpret-answer LLM call, sweep cron, retention cron. Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bd3592cb99 |
feat(db): WhatsApp channel schema (phone links, messages, inbox source) (#1337)
Foundation for WhatsApp receipt intake (plan 2026-08-01), no runtime callers yet: - whatsapp_phone_links + whatsapp_link_codes: verified phone -> user binding via single-use 10-min codes (invite-token pattern). Peppered HMAC lookup hash + AES-GCM encrypted number; one ACTIVE link per phone and per user (partial unique, revocation preserves history). User-scoped RLS (a binding belongs to a person, not a company). - whatsapp_conversations + whatsapp_messages: deterministic state machine state and the message log, which doubles as the durable job record for persist-first webhook processing. Partial unique index on inbound wamid = the at-least-once dedupe key. Service-role only. - whatsapp_sender_rate_counters + check_and_increment_whatsapp_sender_quota: the pre-binding limiter keyed by phone hash; EXECUTE granted to service_role only. - invoice_inbox_items: source CHECK widened to 'whatsapp', plus whatsapp_message_id (one item per delivering message) and channel_context jsonb, kept separate from extracted_data so verified human answers never share a container with untrusted OCR output. document_attachments.upload_source CHECK gains 'whatsapp'. - whatsapp_conversations triaged into ARCHIVE_EXCLUDED_TABLES (full-archive coverage contract). pg-real on a fresh DB: 975/975 incl. the new whatsapp-channel suite (RLS visibility, unique/rebinding semantics, wamid dedupe, CHECK widenings, quota RPC caps + grant lockdown). Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
af9db54405 |
docs(self-hosting): AI runs on AWS Bedrock, not ANTHROPIC/OPENAI keys (#1407)
* docs(self-hosting): AI runs on AWS Bedrock, not ANTHROPIC/OPENAI keys A self-hoster followed SELF-HOSTING.md, set ANTHROPIC_API_KEY and OPENAI_API_KEY, and found document interpretation dead (with a 30s extraction-poll hang per upload). Neither key has been read since the ai-chat / receipt-ocr / ai-categorization extensions were removed in PR #157: all AI (document extraction and the assistant) goes through Claude on AWS Bedrock via AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY. - SELF-HOSTING.md, DOCKER.md, .env.example: document the Bedrock credentials and model overrides; point plain-key support at #1406 - SELF-HOSTING.md: drop the receipts-bucket setup for the removed receipt-ocr extension - EXTENSIONS.md: replace removed extensions with live ones in trees and examples; drop the AI-consent system claims (lib/extensions/ ai-consent.ts no longer exists); services-pattern example now uses the real stripe/skatteverket services - lib/init.ts: startup env warning now checks the AWS keys instead of the two dead vars, so a misconfigured self-host logs the truth Direct Anthropic API key support and pluggable providers: #1406. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: address review, dedupe tree entry and match provider-chain claim to code extractInvoiceFields bails without both static AWS keys, so only the assistant client actually falls back to the credential provider chain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
86c6af6976 |
feat(api): v1 REST company-settings write endpoint (PATCH) (#1405)
* feat(api): v1 REST company-settings write endpoint (PATCH)
Adds PATCH /api/v1/companies/{companyId}/settings, closing the gap where
the v1 REST surface had no company-settings write (only the staged MCP
tool gnubok_update_company_settings could change them).
- Field set is identical to the MCP tool: payment details (bank account,
bankgiro, plusgiro, swish, iban, bic), invoice contact details (email,
phone, website), contact_person (aliased onto default_our_reference,
exactly as the MCP tool maps it), and invoice_email_texts.
- Validation reuses the shared UpdateCompanySettingsParamsSchema (Luhn
bankgiro/plusgiro, invoice email placeholder whitelist), so REST and
MCP can never drift apart on the Swedish-domain rules.
- Writes directly with an explicit .eq('company_id', ...) filter,
following the v1 customers PATCH precedent: no staged operation, since
REST callers are already gated by the companies:write scope.
- Dry-runnable, mandatory Idempotency-Key, registered in the endpoint
catalogue, scope map, and load-routes; spec snapshot updated.
- The companies:write scope description now mentions the REST endpoint.
No GET endpoint yet (possible follow-up); reads stay on the MCP tool.
Fixes #1348
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(v1): harden company-settings PATCH contract, align risk tier
Adversarial-review follow-up for the settings PATCH endpoint (#1348):
- Declare risk: 'medium' in registerEndpoint, matching the
update_company_settings tier in lib/pending-operations/risk-tiers.ts
(payment settings control where customers send money on future
invoices). The spec snapshot does not pin the risk field, so no
snapshot regeneration is needed.
- Pin the partial-PATCH contract: every column the caller did not
supply must arrive as undefined in the update payload, never null.
A future ?? null on the literal 13-column payload would silently
clear every unsupplied column; the new test fails on exactly that
regression (verified by mutation).
- Cover the body-parsing branches: invalid JSON and non-object JSON
bodies (bare array, string, number, null) each return 400 with the
handler's respective message and never reach the update call.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
9f5a43310b |
fix(salary): recompute entitled_days on existing ledger rows and record pre-cutover taken days (#1403)
The vacation ledger sync carried entitled_days verbatim on existing open rows while re-deriving accrued and taken, so a stale entitled value (for example the flat 25 stored before Semesterlagen 7 § pro-rating existed) survived every sync. The recompute loop now re-derives entitled the same way the lazy-seed path does, with the opening-balance cutover still outranking recomputation for the year containing cutover_date. Opening balances could also not record paid vacation days already taken in the cutover year under the previous payroll system. New additive column employee_opening_balances.vacation_days_taken_this_year (NUMERIC NOT NULL DEFAULT 0, CHECK 0..40) threaded through the shared service, the Zod schema, the MCP staging tool (schema + mergeable fields), the staged-operation executor, the v1 REST routes, and the employee editor form. Ledger semantics for the cutover year, on both seed and recompute paths: entitled = remaining + taken_this_year, taken = booked-run taken + taken_this_year, so remaining keeps meaning remaining and the seeded value survives every subsequent sync. Fixes #1347 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2296c0cd59 |
fix(auth): provision invitees server-side when signups are disabled (#1404)
* fix(auth): provision invitees server-side when signups are disabled Self-hosted installations with GoTrue disable_signup broke the invite flow silently: invitees without an account were routed to /register, where supabase.auth.signUp fails with "Signups not allowed for this instance", surfaced only as a generic toast. New server-only env flag AUTH_SIGNUPS_DISABLED (documented in .env.example) mirrors the GoTrue setting. When true, POST /api/company/members/invite checks check_email_exists and, for invitees without an account, provisions one via auth.admin.inviteUserByEmail with a redirect back to /invite/<token>, before the Resend email and before the invitation row is written so a provisioning failure leaves nothing half-created and the admin can retry. The response now carries user_provisioned alongside email_sent, and a provisioning failure returns 502 with a Swedish message mapped through getErrorMessage instead of a silently-successful invite. /auth/callback now routes type=invite verifications to /reset-password (the existing set-password surface) instead of dropping the passwordless user on the dashboard, and preserves the invite token from next=/invite/<token> as the pre-auth invite cookie so the existing reset-password invite handoff accepts the membership right after the password is saved. getErrorMessage learns two GoTrue patterns: "Signups not allowed" (account creation closed on this installation, contact your inviter or administrator) so the /register dead end is explained even for flows that bypass provisioning, and "Error sending ... email" (GoTrue SMTP not configured) so the 502 above is actionable. Hosted is untouched: the flag is unset there and every new code path is gated on it. Fixes #1335 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): restore check_email_exists RPC and harden self-host invite config Adversarial review of #1404 found that the check_email_exists function the invite flow depends on does not exist anywhere: it shipped in PR #229 and was lost in the #244 migration consolidation before ever reaching prod (verified missing on the hosted production database directly). Today app/api/team/accept destructures only { data } from the RPC call, so alreadyHasAccount is silently null on every deployment and the invite page routes even existing-account invitees toward /register. - New migration 20260804140000 restores the function exactly as originally shipped: SECURITY DEFINER over auth.users, EXECUTE revoked from PUBLIC, anon and authenticated, granted to service_role only (prevents email enumeration). Fixes hosted prod behavior too once applied. - New tests/pg/check-email-exists.pg.test.ts locks in existence, case-insensitive matching, false-for-unknown, and the role grants. - .env.docker.example gains the AUTH_SIGNUPS_DISABLED block self-hosters actually use; both env templates now note that the GoTrue redirect URI allow-list must include /invite/* or the invite email redirect silently falls back to SITE_URL. - Invite route test for the existsError branch: RPC failure logs a warning and provisioning proceeds anyway (GoTrue is authoritative). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): mask invitee email in provisioning-failure log (#1335) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a9242551eb |
fix(reports): stop double-counting arets resultat on open years (#1401)
* fix(reports): stop double-counting arets resultat on open years The balance sheet computed the synthetic Arets resultat section purely from class 3-8 rows while 2099's posted balance already sat inside the class 2 Eget kapital sections. When a resultatavslut was posted to 2099 on an open period with its counter-line outside class 3-8 (class 0/9 or missing class), the result was counted twice and the report raised a false imbalance whose differens equaled the 2099 balance. The period-result filter now takes every row NOT in class 1-2, written as a negated range so null/undefined account_class rows are included. The invisible counter-line of a mangled resultatavslut then offsets inside the period result and 2099 is never counted twice, while a genuinely untransferred prior-year result still produces a real differens and the existing diagnosis still fires. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record balance-sheet residual classification decision (#1333) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
97bbba323f |
fix(salary): report F-skatt compensation on FK131 only, not FK011 too (#1402)
* fix(salary): report F-skatt compensation on FK131 only, not FK011 too An F-skatt payee's cash compensation was passed through as grossSalary unconditionally while also being routed to fSkattPayment, so the same payment was double-reported in the AGI individuppgift as both FK011 (KontantErsattningUlagAG, underlag for arbetsgivaravgifter) and FK131 (KontantErsattningEjUlagSA). Per Skatteverket's AGI spec these fields are mutually exclusive for the same payment stream: F-skatt payments form no underlag for arbetsgivaravgifter. Fix at the data layer: generateAgiDeclaration now zeroes grossSalary for f_skatt payees so FK011 is never emitted for that payment, while fSkattPayment keeps carrying it to FK131. A generator-side guard was deliberately not used because an IU can legitimately carry both fields for genuinely mixed payments. The empty-IU filter already keeps f_skatt rows via fSkattPayment, so no individuppgift is dropped; FK487/avgifter totals were already correct (calculation engine sets avgifter_basis 0 for f_skatt) and are covered by a regression test. Both the dashboard route and the v1 public route call this helper, so both are fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): exclude f-skatt rows from avgifter override aggregation (#315) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5ca64bde30 |
feat(bokslut): IL 18 kap pooled tax depreciation with method election (#1393)
* feat(bokslut): IL 18 kap pooled tax depreciation with method election Rakenskapsenlig (huvudregel 30 / kompletteringsregel 20) and restvarde 25 as a company-level annual pool separate from per-asset book depreciation. Method election persisted with immutable snapshots and book-conformity confirmation for rakenskapsenlig. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(db): move tax depreciation migrations to coordinated versions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): keep tax depreciation view loadable when a saved election goes stale A predecessor's changed closing value can push a saved elected deduction above the new statutory maximum; the view now falls back to the statutory recomputation so the snapshot is flagged stale instead of crashing. Ratchet naive-ore-round baseline down by 3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): resolve tax-depreciation period selects statically The no-phantom-columns guard counts every select it cannot resolve toward a hard ceiling, and the PERIOD_COLUMNS join pushed the repo 4 over (364 > 360). Inline the literal column list at the four call sites so the guard verifies these columns instead of skipping them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): address review findings on tax depreciation election - DepreciationPanel: gate the saving flag on a dedicated save sequence so a successful save (which refreshes the view and bumps the request version) no longer leaves the card permanently busy - computeTaxDepreciation: refuse kompletteringsregel_20 with a positive basis and no acquisition cohorts instead of degenerating to a full write-off the cohort evidence does not support (IL 18 kap. 17 §) - migration 227000: judge the asset-method guards on NEW.disposed_at so reversing a disposal cannot reactivate a grandfathered non-linear row - migration 227200: require snapshot column completeness in the CHECK; SQL NULL semantics let partially populated snapshots pass the pure arithmetic comparisons - depreciation route: use the string issue code 'custom' like the rest of the codebase instead of the Zod 3 compat enum Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
00ae3540db |
feat(customers): carry contact person and invoice copy recipients through migration (#1392)
* feat(customers): carry contact person and invoice copy recipients through migration Extends the arcim-migration entity mapper, Fortnox provider mapper, canonical DTOs, customer APIs (web + v1) and invoice send flows so contact person and customer-level invoice CC/BCC addresses survive provider migrations. NULL means unconfigured and empty means an explicit clear, so re-syncs enrich legacy gaps without resurrecting deliberately removed values. Fortnox fixed assets are split into a dedicated follow-up issue. Fixes #1345 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(db): bump customer metadata migration past pack-slug version Main already contains 20260803230000; keep new versions strictly newest so Supabase branching applies them in order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(customers): complete Customer type consumers and make enrichment payload resolvable The preview-pdf mock customer and the makeCustomer fixture now carry the three new metadata fields, fixing the type-check failure in Build (zero extensions) and Vercel. The enrichment update in the migration orchestrator now spells its payload as an object literal typed CustomerMetadataEnrichment (absent keys drop at serialization), so the phantom-column guard resolves the columns instead of counting another unresolvable dynamic payload past its ceiling. The cc/bcc guards also verify element types instead of casting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cb3ef45f14 |
feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse) (#1391)
* feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse) Disposal books depreciation to the disposal date, clears cost and accumulated depreciation, books gain (3973) or loss (7973), applies output VAT on third-party sales, honors the ML 5 kap. 38 § verksamhetsoverlatelse exemption, and recalculates ML 15 kap. jamkning server-side from tax years and original input VAT. The voucher, the disposal-date depreciation schedule and the immutable register state commit in one dedicated commit_asset_disposal RPC transaction that delegates voucher numbering to commit_journal_entry. Fixes #325 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(assets): harden disposal per review and pg-real findings - commit_asset_disposal now uses the NULL-safe caller_is_company_member() guard (tenant-guard ratchet) and passes the allowed 'user_accept' commit_method instead of the unlisted 'asset_disposal' value - disposal metadata invariants validated in the RPC (non-negative proceeds/VAT, VAT requires a treatment, VAT <= gross, scrap carries no proceeds) since the RPC is independently callable - new FK and CHECK constraints added NOT VALID + VALIDATE CONSTRAINT so the migration never blocks writes on the hot journal_entries table - disposeAsset paginates fiscal periods and depreciation schedules with fetchAllRows; jamkning_remaining_years keeps a valid 0 (?? not ||) - engine imports shared AssetDisposalType/AssetJamkningDirection/ VatTreatment unions; post-commit reload retries once and logs before surfacing, so a transient read cannot masquerade as a failed disposal - dispose page parses Swedish-formatted amounts (125 000,50) and blocks submission on unparseable proceeds - assets pg tests write disposal attributes in the disposal transition itself and gain a regression test that the register is frozen after Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1c9d378df8 |
feat(auth): enforce session idle and absolute timeouts (#1387)
* feat(auth): enforce session idle and absolute timeouts Hosted browser sessions now carry an HMAC-signed, HttpOnly cookie holding session start, last activity and sign-in method, bound to the Supabase session. Middleware enforces a 30 min idle and 12 h absolute limit (reason-coded redirects to /login), a heartbeat route advances idle activity from real user input, and a client controller warns 2 minutes before expiry. BankID users are routed back to BankID on re-auth via a short-lived method hint. API-key and MCP bearer surfaces are exempt; self-hosted installs default off and can opt in via env vars. Fixes #362 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): derive session-timeout signing key via HKDF The HMAC key is now HKDF-derived with a purpose-bound info string, so the SUPABASE_SERVICE_ROLE_KEY fallback never uses the privileged credential directly as a signing key. Addresses the security review finding on PR #1387. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): back signature bytes with a plain ArrayBuffer crypto.subtle.verify requires a BufferSource; Uint8Array.from is typed over ArrayBufferLike, which the Vercel TypeScript build rejects. Decode base64url into a Uint8Array constructed over a fresh ArrayBuffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): address session-timeout review findings - signSessionTimeoutState returns null on signing failure instead of throwing, so a missing secret degrades the timeout feature in line with verifySessionTimeoutState rather than crashing authenticated requests; middleware and heartbeat skip the cookie write when null - heartbeat initializes a fresh signed state for a missing or session-mismatched cookie, mirroring middleware, instead of returning SESSION_EXPIRED during normal initialization - sessionStateMatchesUser treats an unresolved current session id as a mismatch for session-bound state so another session's cookie is never accepted on the userId fallback alone - drop aria-live from the countdown DialogDescription so screen readers are not interrupted every second Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f55bfc478b |
fix(bookkeeping): three static templates named accounts that break or mis-book (#1397)
Cross-checked all 86 booking templates (26 packs + 60 static) against BAS 2026.
The pack catalogue is clean; the static registry had three defects, none of
which any existing check could see.
Two named accounts that do not exist in BAS 2026, so account-backfill.ts cannot
seed them and EVERY booking through them failed with AccountsNotInChartError:
vehicle_parking 5614 -> 5619 the BAS 561x run skips 5614; 5619 is the
sibling "Ovriga kostnader for personbilar"
it_cloud_hosting 5421 -> 5420 BAS has no 5421; both sibling SaaS templates
already use 5420 Programvaror
One that posted successfully to the wrong account, which is worse because
nothing complained:
travel_hotel 5820 -> 5830 5820 is Hyrbilskostnader. The Hotell template
filed hotel nights under car hire, balanced,
and looked fine. 5830 is "Kost och logi".
An existing test asserted the 5820 behaviour, so the bug was pinned as
expected output. That assertion now reads 5830 and carries the reason.
Adds template-accounts-exist.test.ts so the 5614 class cannot return: every
account any static template names must resolve in BAS 2026, account numbers
must be strings, and a VAT-bearing purchase may not debit equity or revenue.
That last check is deliberately narrow: class 1 is legitimate, equipment_capital
debits 1250 Inventarier and reclaims VAT, which is how capex is booked. Verified
the guard fails on 5614 and passes on 5619 rather than trusting it.
The pack catalogue already had this check via scripts/validate-packs.ts. The
static registry had nothing, which is how it drifted.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a3d8fe733c |
fix(packs): representation template named itself avdragsgill while booking 6072 (#1396)
Raised by the Swedish compliance bot on #1395. The template called itself "Representation (avdragsgill, 25% moms)" and labelled its cost line "Representation avdragsgill", but booked to 6072, which BAS 2026 defines as Representation, EJ avdragsgill. The swedish-vat skill lists that exact confusion under Representation errors: "Not separating avdragsgill/ej avdragsgill on correct accounts (6071 vs 6072)". The account was right and the words were wrong. Meal representation stopped being income-tax deductible in 2017, so 6072 is where the cost belongs; only enklare fortaring (max 60 kr per person) is still deductible, on 6071. Renamed to "Representation, maltid (ej avdragsgill)" and relabelled to match. VAT moves from 25% to the 12% restaurang-och-catering rate our own static representation_external template in booking-templates.ts already used, so the two template systems stop contradicting each other, with the paired net ratio 1/1.12. A legal_note now carries the 300 kr per person VAT cap, which this format cannot compute because a template sees only a total and never a participant count, plus the reminder that the rate follows the underlag rather than the template. Correcting the record on one point: the seeded ratio 0.8 was NOT a phantom 80% deductibility rule, as the bot suggested and as I first repeated. 1/1.25 = 0.8, so it was the net-of-VAT fraction pairing with the 25% rate, and the arithmetic balanced. Only the naming and the missing cap were wrong. The ratio is now commented in the file so the next reader does not make the same misreading. Slug deliberately unchanged though it still reads "avdragsgill": a slug is an identifier, not a label (packs/README.md). Renaming it would retire this template and insert a new one, breaking every company's booking_template_usage history. The drift guard caught the rename, which exposed that the test joined packs to seeded rows by NAME. That is the same fragility that made pack_slug the sync's key, so INTENTIONAL_DIVERGENCES now records each entry's seededName explicitly. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
281cb3989d |
fix(build): break Turbopack chunk-name hash collision from #1385's import edge (#1394)
Since the #1385 squash-merge every production build failed with 'Two or
more assets with different content were emitted to the same output path'
on [root-of-the-server]__1ge0sz5._.js: two distinct server chunk groups
(an AWS smithy helper chunk and the withRouteContext auth chunk) hash to
the same chunk name. The graph change that tipped the chunk layout into
the colliding state was entity-mapper.ts (arcim-migration extension
entry graph) importing lib/vat/supplier-invoice-line-checks, which
drags lib/money into the extension root.
Move normalizeVatRateToFraction into an import-free leaf module
(lib/vat/vat-rate-unit.ts), re-export it from
supplier-invoice-line-checks for all existing callers, and point
entity-mapper at the leaf. Behavior is unchanged (511 targeted tests
pass); the server chunk graph returns to the pre-#1385 shape that
builds cleanly. Verified: npm run build fails on
|
||
|
|
ff864ad3db |
feat(packs): sync system templates from packs/ instead of the frozen migration (#1390)
Phase 2b. The packs become the source of truth; the table stays the query surface, so the existing read path (one RLS query returning system + company + team templates) and every template id are untouched. pack_slug is the stable upsert key (migration 20260803230000, backfilled onto all 26 seeded rows). Matching on name instead would have meant that correcting a Swedish label looks like a new template, and because booking_template_usage.template_id is ON DELETE CASCADE, retiring the old row would silently wipe every company's "recently used" history for it. For the same reason a removed pack DEACTIVATES its row rather than deleting it: the read path already filters is_active, so it leaves the picker while usage history survives. The sync fails closed. A pack that will not parse or validate aborts the whole run with zero writes, because a database left in a state no commit of the repo describes is worse than a stale one. An empty catalogue is treated as a broken deploy (packs/ not bundled) rather than an instruction to retire every system template. Runs as a daily cron rather than at boot: boot-time work would have every serverless instance racing to write the same rows, and would re-apply a bad catalogue continuously instead of once a day where it is visible. Idempotent, so a database already matching the packs performs zero writes. Three database guards, each covered by tests/pg/booking-template-pack-slug.pg.test.ts: a partial unique index (one pack, one template), a format CHECK mirroring PACK_SLUG_RE so the database refuses what the loader would, and a CHECK keeping pack_slug off company templates, where it would shadow the pack it collides with. Verified the upgrade path locally the way the pg-upgrade job will run it: base schema, seeded fixture company with posted verifikat, an existing company template, then this migration alone. 26 slugs backfilled, company template intact, upgrade assertions pass. This is that job's first real migration. Payloads are spelled out rather than spread so the phantom-column guard can check them, and docker/crontab.* are regenerated for the new vercel.json entry. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0ef3c03904 |
fix(salary): book net deductions as settlement lines so salary entries balance (#1374)
* fix(salary): book net deductions as settlement lines so salary entries balance Net deduction line items were skipped entirely in createSalaryEntry, so the credit side (2710 tax + 1930 net) fell short of the gross debit by exactly the deducted amount and the balance trigger rejected the voucher. Net deductions now book on their mapped settlement account (1613 advance repayment, 2794 union fee, 7385 benefit co-payment, 2799 other; explicit account_number overrides), aggregated and undimensioned like the other settlement legs. The default mapping in account-mapping.ts moves off 7210 so payslip lines, the booking preview and the voucher all agree. Fixes #316 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): address compliance review on net-deduction accounts Label 7385 with its BAS 2026 name (Kostnader för fri bil) instead of the benefit-generic Bilförmån, document why the single benefit-payment item type defaults to 7385 with per-line override for other benefit kinds, and add a repayment-direction test (positive net deduction books as debit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8443062b1f |
fix(vat): enforce fraction unit for supplier invoice vat_rate writes (#1385)
Closes the remaining #310 write paths: credit-note item copies (web, v1, pending-operations) and arcim-migration supplier imports now normalize vat_rate to the decimal-fraction unit before storage, and a NOT VALID CHECK constraint guards every new supplier_invoice_items row. Customer invoice items deliberately stay percent; legacy supplier rows are left untouched so posted-entry reversals reuse the exact original values. Fixes #310 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5b9605d8e9 |
fix(packs): repair the four broken system templates the validator found (#1388)
Phase 2a quarantined four defects rather than guessing at Swedish accounting. Each is now resolved against a domain source. KNOWN_BROKEN is empty. Löneutbetalning could never post. It debited 2710 @0.3 + 2920 @0.12 + 7010 @1.0 against a single 1.0 credit, totalling 1.42x the amount, so the balance trigger would reject every entry built from it. Rebuilt per the swedish-payroll skill: Debit 7010 gross, Credit 2710 tax, Credit 1930 net. The 2920 semesterlöneskuld line is gone because vacation accrual is its own verifikat (7290/2920), and a legal_note now says the 30% split is schablon and must be adjusted to the actual skatteavdrag. Periodiseringsfond avsättning/återföring referenced account 2113. Per swedish-year-end-closing the year-tagged block is 2120-2129 (2126 = tax year 2026), so 2113 was the fund for tax year 2013: long since reversed and absent from BAS 2026. Both now use 2110 Periodiseringsfonder, which does not rot annually, with a legal_note pointing at the year-tagged accounts for a company that tracks funds per year. Preliminär F-skatt (EF) turned out to be RIGHT, and the reference was wrong. Account 2012 "Avräkning för skatter och avgifter" was simply missing from lib/bookkeeping/bas-data (the file jumps 2011 -> 2013), while the swedish-year-end-closing skill uses it in two places as an enskild firma equity sub-account. That is not cosmetic: account-backfill.ts only seeds accounts present in BAS_REFERENCE, so any entry touching 2012 failed with AccountsNotInChartError. Added it with the equity SRU code its siblings share, and a description separating it from 1630, which carries a confusingly similar name on the asset side. The port test now distinguishes deliberate divergence from accidental drift: a pack not listed in INTENTIONAL_DIVERGENCES must still match the seeded JSONB exactly, and a listed pack must actually differ, so neither an unnoticed edit nor a stale entry can survive. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8299ee9fb4 |
fix(bookkeeping): resolve settlement account in all categorization flows and ship mis-booking audit (#1383)
Completes the #985/#986/#987 caller sweep: categorize-core, v1 batch-categorize, pending-operation edits and the MCP categorize path now resolve the settlement leg from the transaction's cash account instead of inheriting a hardcoded or stale account. Extends the correct_entry preview with currency, tax and dimension line metadata so staged corrections preserve full line fidelity. Adds a read-only audit query and a runbook for reviewing and correcting historical mis-bookings via staged storno with explicit approval; no automated bulk mutation. Fixes #1001 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e40dc64485 |
feat(invoices): invoice list column sorting and row-level status styling (#1375)
* feat(invoices): add column sorting and row-level status styling to invoice list Client-side sorting on number, customer, due date, amount and status with Swedish collation, null-last ordering and stable date/id tie-breaks. Status chips move to the shared RowStatus descriptor so normal states stay muted and exceptions carry semantic color. Invoice fetch now pages past the PostgREST 1000-row cap via fetchAllRows so sorting covers the full list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(invoices): make rounded-amount sort assertions non-degenerate Distinct rounded totals now prove the comparator orders by displayed value; the rounded tie case is kept as an explicit tie-breaker test since integer rounding is monotonic and can only create ties, never reorder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a2f7132c94 |
fix(year-end): conservative historical repair for carried-forward 2099 (#1373)
* fix(year-end): conservative historical repair for carried-forward 2099 The steady-state year-end flow already reclassifies the opening 2099 (Arets resultat) to 2098 (Foregaende ars resultat) right after the opening balance is generated. Periods opened before that fix still carry the prior year's result on 2099. Add lib/core/bookkeeping/result-appropriation-repair.ts: a pure classifier plus assess/post helpers that auto-post the 2099 -> 2098 transfer only when it is unambiguous (open unlocked aktiebolag period, posted explicit opening_balance entry, active 2099/2098 accounts, no posted result_appropriation yet, current posted 2099 still equal to the explicit opening amount, and no other entry touching 2099). Everything else is skipped or listed for manual review; nothing is reconstructed from cumulative history. All writes go through the bookkeeping engine. Rework scripts/repair-result-appropriation.ts into a thin CLI over the library: global/company/period dry-runs, and commit mode that requires one exact --company-id, --period-id, and --user-id and re-assesses immediately before posting. Fixes #735 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(year-end): require company membership for repair attribution Compliance review (ASVS V8.2.1): commit mode accepted any --user-id and attributed the posted journal entry to it unvalidated. The service-role client bypasses RLS, so nothing downstream would catch an outsider uuid. postHistoricalResultRepair now verifies a company_members row for the target company before posting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(year-end): reference the opening-balance underlag on the repair verifikat Swedish compliance review (BFL 5 kap 6-7 §§): the historical repair entry validated against a specific opening-balance entry but never recorded it. Link it machine-readably via source_id and human-readably in the entry note ("Underlag: ingående balans, verifikat A1 (<id>)"). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(year-end): harden repair CLI arg parsing, pagination and exit code CodeRabbit review on #1373: - arg() rejects flag-shaped or missing values instead of silently consuming the next flag as an id - global company and period scans paginate via fetchAllRows() so deployments past the PostgREST 1000-row cap are fully covered - exit code is non-zero when any period failed to list, assess or post Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5d7952a01e |
feat(mcp): model-free document upload via signed URL (#1378)
* feat(mcp): model-free document upload via signed URL (#748) Adds gnubok_create_document_upload + gnubok_complete_document_upload so document bytes reach storage through a short-lived signed PUT URL and never pass through the model context. Fixes silent base64 corruption on real-size PDFs and the context blowup on batch uploads. - pending/ staage keys with TTL cleanup; completion validates magic bytes + SHA-256, moves bytes to the WORM key and adopts the reserved UUID as document id, making retries and concurrent completions idempotent - legacy gnubok_upload_document kept for clients without file access, description now points to the signed-URL pair; shared mime resolution and inbox-item creation extracted - both new tools mapped in TOOL_SCOPE_MAP (transactions:write) and MCP_TOOL_CAPABILITY_MAP (ai) so the paywall and scope gates hold - payload guard ceiling 58.5K to 59K after trimming the create tool's outputSchema to upload_id/upload_url/expires_at Fixes #748 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): satisfy capability-map lock and phantom-column scanner The exact-entries lock in capability-maps.test.ts now includes the signed-URL pair as dispatch-only AI tools, and the inbox insert uses a literal payload (explicit UUID instead of a conditional spread) so the no-phantom-columns scanner can resolve every column. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
00d4c8a49e |
feat(invoices): ROT/RUT payout file dialog and file guards (#1380)
* feat(invoices): ROT/RUT payout file dialog and file guards Rebuild the UI for the existing headless HUS V6 payout-file flow (demanded via #789): a dialog on the invoices page to pick eligible paid ROT/RUT invoices, generate the XML, download it and track request status. Adds file-level guards from the Skatteverket spec: future payment dates blocked, one file per payment year, max 100 cases per file, with per-invoice blocker messages. Submission stays manual (upload + sign in the SKV e-service); no direct submission API exists. Fixes #789 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): compute rot-rut gating date in Europe/Stockholm The candidate and begäran date defaults used the UTC calendar day, which near midnight Swedish time could wrongly block or admit an invoice via FUTURE_PAYMENT_DATE and shift the 31 January deadline warning. Use getSwedishLocalDate() like the bookkeeping engine. Raised by the Swedish compliance review on PR #1380. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |