Commit Graph

716 Commits

Author SHA1 Message Date
Jakob Wennberg dfed55cb6c feat(periods): undo klarmarkera so an externally closed year can be reopened (#1978)
markPeriodClosedExternally ("klarmarkera") closes and locks an imported
year without a closing entry, and nothing could reverse it: unlockPeriod
refuses closed periods and the SIE replace flow refuses closed or locked
years. An owner who klarmarkerade five imported years and then found the
prior-year SIE file was wrong had no way back (Forsslund Systems,
2026-08-27).

reopenExternallyClosedPeriod reverses the mark while the closed state still
comes from klarmarkera (closed_externally set, no closing entry), clears the
lock, writes the audit_log row, and emits period.unlocked. New route
POST /api/bookkeeping/fiscal-periods/[id]/reopen-external with envelope codes
PERIOD_REOPEN_NOT_CLOSED / PERIOD_REOPEN_NOT_EXTERNAL; "Öppna igen" action
and "Avslutat i tidigare program" chip in Settings > Bookkeeping > Fiscal
years; unlock and SIE replace refusals now point at that path.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 14:34:02 +02:00
Mattsson c981384a0e fix(transactions): keep the assistant usable through the whole booking flow (#1975)
* fix(transactions): keep the assistant usable through the whole booking flow

Three gaps around the agent sheet during booking on Transaktioner:

- The AgentTrigger bubble lived inside #dash-shell, so the inert that
  non-modal dialogs set on the shell made the assistant impossible to
  OPEN once a booking dialog was up. Moved outside the shell (it is
  position: fixed) and raised to z-[45]: above the DialogVeil (z-40) so
  it stays clickable, below dialog content (z-50). Under true modal
  dialogs Radix's body pointer-events lock keeps it dead as before.
- Wide dialogs centered on the full viewport while the docked sheet
  (z-60) covered their right edge, hiding e.g. the Granska button.
  DialogContent now centers in the space left of --agent-dock-w, and
  the wide booking/review variants cap their width against it.
- Step 1 of the flow (template picker, QuickReviewDialog) was still
  fully modal, so the assistant was dead there. Both are now non-modal
  with DialogVeil + a shared ref-counted useDashShellInert hook (also
  replacing the duplicated inert effects in TransactionBookingDialog
  and NewInvoiceDialog). Their Esc/veil-click dismissal is kept: they
  hold no half-filled form. Clicks in the agent sheet or its trigger
  never dismiss any dialog: data-agent-ui counts as inside, same
  mechanism as data-dialog-companion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): dialog centering reads a docked-only sheet width variable

Skeptic review refuted the first cut: globals.css seeds --agent-dock-w
at the 10px frame gutter on :root by design, so the 0px fallback in the
new dialog centering never applied and every dialog sat 5px left of
center (full-bleed dialogs clipped 5px off-screen).

Introduce --agent-sheet-w, set inline by AgentSheetProvider only while
the sheet is docked and removed otherwise, so the 0px fallback is real:
sheet closed or floating renders byte-identical to the old left-[50%]
and old max widths. Also cap the template picker and QuickReview's
narrow variant, which could clip off-screen left on narrow desktops
with the sheet docked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 14:22:20 +02:00
Mattsson 99c94d467a fix(white-label): back-to-clients link points at the byra cockpit's home domain (#1973)
* fix(white-label): back-to-clients link points at the byra cockpit's home domain

A byra member working a company homed on another host (e.g. a pre-byra
company on canonical) got a relative /clients on the wrong host instead
of their white-label cockpit. resolveCockpitHref mirrors WL-14's home
rule: relative when the current host is the cockpit's home (brand domain,
or canonical for a brandless byra), else an absolute URL there. Cross-
host links render a plain <a> with a 'Hanteras via' hint; the hop lands
on the brand host's login (per-host sessions, WL-01) and WL-14 then
lands on /clients.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): show the cross-host cockpit hint as visible text

CodeRabbit: title-only hints never surface on touch devices, so the
expanded-sidebar and mobile external back-links now render the
'Hanteras via {domain}' line as small muted text under the label
(same pattern as the switcher's foreign entries). Also corrects the
comments claiming the no-company branch never renders the back-link:
it can, on its cockpit/settings surfaces, where the relative fallback
matches pre-change behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 13:44:23 +02:00
Jakob Wennberg 23776337c8 feat(onboarding): the hosted checklist closes with Connect to Claude (#1971)
Founder call 2026-08-27: the strategic split is Claude-first onboarding
for brand-new businesses (no migration friction) and hosted onboarding
for prior-system users, whose closing CTA now hands them to Claude with
everything already connected: the MCP onboarding skill then opens with
reconciliation findings and the Att göra-list instead of setup steps
(shipped in #1967).

The final checklist step 'Bygg din bokföringsassistent' becomes 'Anslut
till Claude': the button opens claude.ai's Add-custom-connector dialog
prefilled (connectorName from the brand, connectorUrl built from the
page origin so self-hosted and white-label domains link to themselves).
The in-app assistant calibration stays reachable from the assistant
page. sv + en strings added.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 11:35:47 +02:00
Mattsson 98aff7c577 fix(transactions): keep booking dialog open while typing in the agent sheet (#1966)
* fix(transactions): keep booking dialog open while typing in the agent sheet

TransactionBookingDialog was a default modal Radix dialog; the agent
sheet is a fixed z-[60] panel portaled outside its DOM, so a click in
the assistant's text field counted as an outside interaction and
dismissed the dialog, losing the half-filled booking.

Apply the established non-modal convention (NewInvoiceDialog /
NewJournalEntryDialog): modal={false} + DialogVeil, with escape and
outside-dismiss prevented. Closing is explicit via the header X.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): restore page modality via inert while booking dialog is open

Skeptic review caught that the non-modal convention was ported
incompletely: NewInvoiceDialog pairs modal={false} with an inert effect
on #dash-shell. Without it the background page stayed keyboard-, AT-,
and (mobile nav) tap-reachable behind the veil, so a stray Shift+Tab
plus Enter could navigate away and destroy a half-filled booking.

Also corrects the comment that cited NewJournalEntryDialog as non-modal
precedent (it is plain modal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 23:21:29 +02:00
Mattsson ff88e3de05 fix(bookkeeping): remove false 1580 tax-receivable label, let company account names win (#1968)
The hardcoded ACCOUNT_DESCRIPTIONS entry labeled 1580 'Fordran for skatt'
with a Skatteverket explanation. That is wrong on both counts: tax
receivables are 1640 (skattefordringar) / 1650 (momsfordran), and 1580
was traditionally 'Fordringar for kontokort och kuponger', which BAS has
since moved to 1686 (why 1580 is excluded from our BAS 2026 catalog).
Reported by a user who books card/Swish settlements there.

Also flip AccountNumber display precedence to the company's own
account_name over the hardcoded reference name: chart rows are
user-editable data and must not be visually overridden by our copy.
The tooltip keeps showing the BAS reference info.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 23:11:05 +02:00
Mattsson 84e6b1eb17 fix(ui): cap DialogContent grid track so nowrap children cannot widen modals (#1962)
DialogContent is display:grid with an implicit auto column, and the auto
track sizes to the widest child's min-content. Chrome counts nowrap text
(truncate, whitespace-nowrap) at its full width in that calculation even
though it truncates at layout time, so one long description (e.g. a
candidate row in MatchVerifikationPicker) widened the track past the
dialog, stretched every sibling, and clipped the right edge behind a
horizontal scrollbar. grid-cols-[minmax(0,1fr)] caps the track at the
content box, hardening every modal at once; callers can still override
via className (tailwind-merge resolves the conflict in their favor).

Verified with a headless-Chrome replica of the reported dialog:
scrollWidth 696 vs clientWidth 510 before, 510/510 after.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 21:14:06 +02:00
Mattsson fdb5f6f891 feat(white-label): byra white-label infrastructure: brands, cockpit, home domains, branded email (#1956)
* feat(white-label): brand and team-kind foundation

- brands table: one white-label identity per byra team (unique mutable
  domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
  ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
  demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
  chrome tone and WCAG contrast gate; no brand row = default appearance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): per-request brand theming, wordmark slot and source footer

- root layout resolves the brand from the Host header and injects a
  server-rendered style block (light + dark), font pair classes and a
  BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
  images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
  for non-default entries)
- AGPL source-code footer link on login and public pages, both brands

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra team invites, member management and team billing

- team invites unfrozen behind a kind gate (byra teams only, owner/admin
  invite); members route handles multi-team membership; members/[id]
  unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
  read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding

- every outbound mail is sent in the brand of the company it concerns:
  getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
  "via Accounted" fallback, canonical default) wired into invites,
  payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
  dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra cockpit, home-domain rule and tab guard

- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
  next deadline via the status engine, last booked) for byra team members,
  who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
  guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
  is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
  signpost page for companies homed elsewhere

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): brand-aware app name across UI strings

- 24 message keys per locale converted to the {appName} ICU parameter,
  27 call sites pass the active brand name (useBranding client-side,
  getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
  surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
  guard, signpost and source footer (sv/en parity verified)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(white-label): domain glossary and decision log entries

- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
  home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): lean byra cockpit sidebar with company-mode back link

Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings

In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): keep company picker in cockpit with nothing selected

The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(db): renumber white-label migrations past main and add byra settings scope

Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.

Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(byra): cross-client nyckeltal view in the cockpit

Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra self-service brand logo and app name

New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): route root layout through the shared brand resolver

app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(byra): automations roadmap teaser and cockpit i18n strings

The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company

After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.

The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(white-label): drop brand color theming, keep monochrome everywhere

White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook

ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(byra): drop the 'what's coming' tail from the automations intro

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra owners/admins with zero companies land in the empty cockpit

Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): auth wordmark shows the brand logo alone

Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): per-brand favicon via brands.favicon_url

Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): wire the villkor and integritetspolicy footer links

Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): styled popup for the team role dropdowns

The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(email): branded sender shows the brand name alone, no via-platform

Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): byra landing applies to every team member, not only owners/admins

An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(email): branded team invite names the byra, not "ett team pa <platform>"

Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): sidebar keeps cockpit mode after refresh on settings

The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): hide the active-company chip in byra-scoped settings

The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): tab guard no longer fires in the tab that initiated the switch

BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): styled popups for every settings dropdown

SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): home-domain affinity redirect in middleware

Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): render hero brand logo at 64px on auth pages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): shareable invite link and re-send for byra team invites

A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:

- persistent share-link line after invite create/re-send: ochre attn line
  with a copy action when the mail did not go out, quiet muted line with
  the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
  token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
  by create and re-send so the two paths cannot drift

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label

Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): close the four skeptic refutations before merge

- trial seed: migration 130300 now carries the seven-key PAID body from
  20260818170000 plus the byra guard, instead of silently reverting it;
  pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
  create_company_for_user (v1 API + MCP path), and both surfaces resolve
  the default team personal-only, so a consultant's private company can
  never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
  longer redirected off the platform host; the signpost handles per-company
  homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
  (ROT/RUT work-type picker)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect

The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 16:56:39 +02:00
Jakob Wennberg 1a41119682 perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline (#1942)
* perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline

Two chunks rode along in the first-load JS of almost every dashboard route:
the full BAS 2026 chart (315 KB uncompressed, in 81 route manifests) and
the browser polyfill for Node's crypto/vm/Buffer (327 KB, in 26 routes
incl. login and register). Neither was needed on first paint; both got
there through static imports of helpers that happen to live next to code
that needs the data or the builtin.

Node polyfill (4 pure splits, behaviour unchanged, re-exported from the
original modules for server callers):
- lib/auth/bankid-flags.ts: isBankIdEnabled (login, register, security
  settings imported it from bankid.ts, which imports crypto).
- lib/import/bank-file/formats.ts: the format registry + detection (the
  import history imported getFormat from parser.ts, which hashes).
- lib/salary/personnummer-format.ts: parsing/validation/formatting (the
  employee forms reached the encrypting personnummer.ts via tax-column).
- lib/auth/api-key-scopes.ts: scope catalogue, groups, tool map, helpers
  (the API key panel imported STAGING_SCOPES from the key generator).

BAS chart:
- lib/bookkeeping/bas-lazy.ts + use-bas-reference.ts: the chart becomes a
  dynamic import, fetched once per session after first paint; components
  that show BAS names/descriptions call useBasReference() and re-render
  when it lands. Until then (and on the server) only the hardcoded
  account-descriptions answer, so SSR and hydration agree.
- lib/bookkeeping/bas-labels.ts: class/group labels out of bas-reference.ts
  (account-descriptions needed a label and paid for the whole chart).
- lib/bookkeeping/bas-account-numbers.ts (generated, ~11 KB) +
  scripts/generate-bas-account-numbers.ts (--check) + parity test:
  isStandardBASAccountNumber for AddAccountDialog/ChartOfAccountsManager.
- lib/bookkeeping/account-classifier-{heuristic,client}.ts: the BAS-aligned
  heuristic shared by the server classifier and a client variant that uses
  the lazy chart.
- lib/bookkeeping/invoice-accounts.ts: INVOICE_FX_RATE_MISSING,
  InvoiceFxRateMissingError, getRevenueAccount, getOutputVatAccount out of
  invoice-entries.ts, whose engine import pulled account-backfill and the
  chart into SendInvoiceDialog/PaymentBookingDialog.
- CorrectOpeningBalanceDialog re-seeds names when the chart lands;
  OpeningBalanceRowEditor builds its Fuse indexes lazily; the
  ChartOfAccountsManager BAS-katalog tab awaits the chunk.

Tooling:
- scripts/perf/client-import-closure.mjs: static import closure of every
  'use client' module with the shortest chain to a target (file or bare
  specifier); found every path above without a build.
- scripts/checks/client-node-builtin.mjs wired into check:guards: a client
  module reaching a Node builtin is a hard failure (0 today).

Left as is: invoices/[id], its credit page and SendInvoiceDialog still
reach the chart through lib/invoices/issue-credit-note -> invoice-entries
-> engine -> account-backfill; splitting the engine is out of scope here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(perf): unambiguous import-edge regex in the closure walker (CodeQL js/redos)

One quantifier per span: a greedy [^'"]* up to the specifier quote, which it
cannot cross, so a run of whitespace has a single parse. Same edges as
before (multi-line named imports, re-exports, side-effect imports; type-only
imports still skipped).

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>
2026-08-26 15:07:49 +02:00
Jakob Wennberg 41424a1650 feat(ux): one silhouette from route fallback to detail content (#1944)
Opening a row on the customers, invoices, verifikat, supplier-invoice,
supplier, article and salary-run lists flashed three unrelated layouts:
the segment's list-shaped loading.tsx (or, for suppliers and articles, the
Hem-shaped dashboard fallback) during the RSC round trip, then the client
page's bare centred spinner in an h-64 box while it fetched, then the
content with a full layout change. Two flashes per click on the most
travelled drill-down path.

- components/common/DetailPageSkeleton.tsx: back link + title row + card
  grid + line block, the silhouette of a document/register detail page;
  InvoiceEditorSkeleton for the editor routes (same shape the Ny faktura
  dialog shows while its chunk loads).
- loading.tsx for every [id] segment (customers, invoices, invoices/edit,
  invoices/credit, bookkeeping, supplier-invoices, suppliers, articles,
  salary/runs) and for the two list segments that had none (suppliers,
  articles, cloned from customers/loading.tsx).
- The client pages render the same skeleton while they fetch instead of
  the centred Loader2, so the RSC fallback to client fallback handoff is
  invisible. The reference-data gates are already gone (A1 to A6); this
  only covers the primary-entity fetch.
- app/(dashboard)/__tests__/detail-loading-states.test.ts pins both.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 15:02:26 +02:00
Jakob Wennberg 3ee3565d6d perf(reference-data): sweep the remaining raw reads onto the session cache, ratchet to 0 (#1941)
Final consumer migration of the responsiveness plan: the 35 files still
fetching fiscal periods, settings, accounts, cash accounts, dimensions or
templates on their own now read lib/reference-data, and every client
write site invalidates the shared cache instead of refetching locally.

Settings and registries: FiscalYearsManager, FiscalPeriodEditor (period
snapshotted once per company so a revalidation cannot reset dates being
edited), BookingTemplatesPanel, ChartOfAccounts, ChartOfAccountsManager,
EditAccountDialog, CorrectionEntryDialog, StrikeLinesDialog,
InvoicePaymentAccountsSettings; the dimensions registry (DimensionsManager,
DimensionCombobox, LineDimensionFields, DimensionFilter, bookkeeping/[id])
reads useDimensions and the ad-hoc fetchDimensions/fetchDimensionsCached
helpers are deleted.

Pages and pickers: CashAccountSelector (FyPicker-shaped restore, once per
company load), use-account-names, FiscalYearGapNotice,
OpeningBalancePeriodStep, BankFileConfirmStep, ImportReviewStep, the import
page (invalidates accounts + periods after a SIE execute), customers list,
invoices list + detail, pending, salary employee, asset dispose, year-end
and periodisering pages (invalidate periods after closing), reports
DimensionPnlView (its pivot picker read the wrong payload key and was
always empty; it now populates), SkatteverketPanel, TemplatePicker,
ArticleForm (vat_registered).

Invoice dialogs and extensions: SendInvoiceDialog, PaymentBookingDialog
(init reduced to the credit-note lookup + catalogue, proposal and voucher
preview fire on open when cached; a local getSession replaces the network
getUser for the fallback CC), InvoiceInboxWorkspace, TicWorkspace,
ArcimMigrationWorkspace (invalidates after each SIE import step),
enable-banking AccountPickerDialog.

raw-reference-fetch ratchet: 35 -> 0 files.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:56:37 +02:00
Jakob Wennberg 4560ccbfc9 perf(forms): supplier-invoice form, register forms and review dialogs read the session cache (#1938)
The supplier-invoice editor issued four requests on every mount (suppliers,
accounts, settings, fiscal periods) and defaulted vatRegistered=true,
entity type and rounding until /api/settings landed, so the moms controls
visibly flipped. The register forms fetched the whole chart of accounts to
fill one konto combobox, and each transaction review dialog refetched
accounts, cash accounts or settings per open.

- use-supplier-invoice-data: thin composition of useSuppliers, useAccounts,
  useCompanySettings and useFiscalPeriods; the settings-driven gates come
  from a pure deriveSupplierInvoiceDefaults() (tested) instead of state
  that flips when the fetch returns; the per-invoice öresavrundning toggle
  is the one local override. Inline supplier create invalidates the shared
  list instead of patching local state.
- SupplierForm, ArticleForm (posting accounts), QuickReviewDialog,
  InvoiceMatchDialog, supplier-invoices/[id] (payment dialog chart):
  useAccounts; ArticleForm's inline account create invalidates the chart.
- BulkBookDialog, MatchVoucherDialog, DuplicateBookingDialog: cash
  accounts from useCashAccounts (resolveAccount over the cached list; an
  empty list still resolves to 1930 with the fallback note).
- QuickReviewDialog, BulkBookDialog, NewEmployeeDialog, customers list
  (default payment terms), salary run page (payment format, bank, IBAN,
  dimensions): derived from useCompanySettings; the salary page's
  post-settings-modal refetch becomes a cache invalidation.

raw-reference-fetch ratchet: 45 -> 35 files.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:50:26 +02:00
Jakob Wennberg 40e773548c perf(invoices): the invoice editor renders on the first paint from the session cache (#1937)
"Ny faktura" was the slowest form to fill in: the list page lazy-loaded
NewInvoiceDialog, which lazy-loaded InvoiceEditor (ssr:false), which then
issued four requests on mount (customers, articles, chart of accounts,
company settings) and hid the ENTIRE form behind a spinner until the
customers query alone resolved, even though the other three had landed.
Reopening the dialog paid all of it again.

- InvoiceEditor reads customers, articles, posting accounts and settings
  from lib/reference-data (seeded by the dashboard layout). The whole-form
  spinner gate is gone; the customer picker shows "Hämtar kunder ..." only
  while the list is genuinely uncached. Company settings are applied once
  per editor instance through a guarded effect, so a background
  revalidation can never re-run the create-mode prefills over notes or a
  reference the user has typed. Inline customer/article creation
  invalidates the shared cache (awaited, so the new option resolves before
  the line points at it). Customers now come through /api/customers, which
  masks the personnummer column; nothing in the editor rendered it.
- NewInvoiceDialog imports the editor statically: the dialog is itself a
  next/dynamic chunk on the list page, so this is one deferred chunk
  download when the dialog opens instead of two sequential ones.
- New strings: invoice_editor.loading_customers (sv + en).

Per "Ny faktura": 2 sequential chunk loads -> 1; blocking mount requests
4 -> 0 (cached) with every field populated on the first render.
raw-reference-fetch ratchet: 46 -> 45 files. SendInvoiceDialog and
PaymentBookingDialog (init() flows) stay in the baseline for a later PR.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:43:09 +02:00
Jakob Wennberg 567fae654c perf(bookkeeping): booking dialogs render populated on open from the session cache (#1935)
The bookkeeping dialogs were the customer's "fields load late" in its
purest form: Bokför (TransactionBookingDialog + the embedded
JournalEntryForm) issued five requests on every open (fiscal periods,
accounts, settings, cash accounts, then the voucher preview once the first
two had landed), Nytt verifikat the same minus one, BookDirectlyDialog
four, and the template dialogs two. Each Radix dialog unmounts on close, so
every reopen paid the full price again, and several fields visibly
flipped: the bank line seeded '1930' then rewrote itself, the series
defaulted to 'A' until settings arrived, the period select was empty.

All of them now read lib/reference-data (seeded by the dashboard layout):

- JournalEntryForm: periods, accounts and settings from the hooks;
  dimensionsEnabled derived, not fetched; the voucher-number preview is
  keyed on the entry date (the route resolves the period from it) so it
  fires as soon as the series is known instead of after the period fetch;
  after activating accounts it invalidates the shared accounts cache; the
  create-period dialog callback invalidates the periods cache.
- TransactionBookingDialog: settlement account and its name derived with
  useMemo from the cached cash accounts; the form mounts on the first paint.
- BookDirectlyDialog: cash accounts, periods and accounts from the hooks;
  the '1930'-then-rewrite disappears because the resolved account is known
  on the first render.
- TemplateBookDialog, BookingTemplatePicker, TemplatePicker: templates
  (and periods) from the hooks.
- BookingTemplatesPanel (delete, import) and CreatePeriodDialog (create)
  invalidate the corresponding cache entries so every picker sees the
  change at once.
- fetchers.ts: booking templates are booking_templates rows
  (BookingTemplateLibrary), not the static BookingTemplate shape.

Per open: Bokför 5 requests -> 0 blocking (voucher preview is a
non-blocking hint), Nytt verifikat 5 -> 1 non-blocking, BookDirectly
4 -> 0, Mall 2 -> 0, template pickers 1 -> 0.
raw-reference-fetch ratchet: 51 -> 46 files.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:37:28 +02:00
Jakob Wennberg 9a56b7aff9 perf(bookkeeping): fiscal-year pickers and cash accounts read the session cache (#1934)
First consumer migration onto lib/reference-data. FyPicker and
FiscalYearSelector (14 consumer surfaces, 47 fiscal-period fetch sites
before this series) now read useFiscalPeriods(); with the layout seed the
restore of the persisted scope runs in the first effect tick and onReady
fires on mount instead of after a round trip. Their restore rules are
extracted into a pure resolveInitialFiscalScope() (lib/reference-data/
fiscal-scope.ts) so the two pickers cannot drift apart again, and the
restore runs once per company load, not on every background revalidation.

- /reports: the static catalog renders immediately; only the "no fiscal
  year" empty state waits for the picker (previously six skeleton bars
  until /api/bookkeeping/fiscal-periods resolved).
- JournalEntryList (/bookkeeping): resolves its initial scope from the
  cached list instead of its own fetch; the saved-scope shortcut still
  unblocks the entries fetch first when nothing is cached, and resolution
  is guarded to once per company so a revalidation can never snap a
  deep-link "all years" visit back to the stored year.
- /transactions: the account chooser reads useCashAccounts({ enabledOnly })
  (seeded) instead of fetching /api/cash-accounts on every visit; the bank
  sync button invalidates that entry after a sync.
- STORAGE_KEY_PREFIX / ALL_YEARS_VALUE move to a dependency-free
  fiscal-year-storage.ts (re-exported from FiscalYearSelector) so lib/ code
  can import them without a React component.

raw-reference-fetch ratchet: 55 -> 51 files.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:24:24 +02:00
Jakob Wennberg b436d47b64 feat(mcp): onboarding flow v2: migration-first SIE import in chat, one-confirm momentum, extended first-year heuristic (#1949)
Three changes from the first real E2E run (Arcim, 2026-08-26):

1. gnubok_sie_preflight: read-only scan of a SIE file shared in chat
   BEFORE anything is staged: parse, validate (per-verifikat balance, IB,
   closed-year P&L residual), CP1252-mojibake tripwire, duplicate
   file/period check, org-number match against the company (the
   wrong-company import is the worst silent failure this flow can have),
   and suggested account mappings shaped for direct passthrough to
   gnubok_import_sie. Both tools now also accept file_content_base64,
   decoded with the same encoding detection as the HTTP upload route so
   CP437 exports keep their åäö.

2. Onboarding skill v2: opens with TWO questions (orgnr + 'vilket system
   hade du innan?'), imports history before the bank (PSD2 rarely reaches
   far enough back), and a momentum rule: the create preview is the ONLY
   stop; connect tools are called without asking, and categorization
   starts as soon as the bank is active. connect_bank instructions now
   describe the account-selection dialog that actually gates the first
   sync, and the bank history cap.

3. deriveFirstYearDefaults: no closed fiscal period in the registry now
   extends the first-year window from 12 to 18 months (BFL 3 kap 3 §):
   a 13-month-old company with no annual report is still in its first,
   extended räkenskapsår (the Arcim case the 12-month rule missed).
   Applied in the web journey and the lookup tool.

tools/list ceiling 61.5K to 62K, documented in the bench.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:16:40 +02:00
Jakob Wennberg 47fe193c48 feat(perf): session-cached reference data layer, server-seeded, with a raw-fetch ratchet (#1932)
* feat(perf): session-cached reference data layer, server-seeded, with a raw-fetch ratchet

Customer report (2026-08-26): "it takes time before all fields load when
clicking around". The cause is on the client: fiscal periods, settings,
accounts, cash accounts, dimensions and templates are fetched raw from 47 /
27 / 14 / 8 / 12 / 5 independent call sites, uncached, on every mount and
every dialog open, each request paying the auth proxy and route wrapper
before its own query. SWR was adopted for exactly this on 2026-07-13 but
reached only three files.

This PR adds the layer; consumers migrate in the follow-ups.

- lib/reference-data/keys.ts: one key builder per data set, company id in
  position 1, null without a company; company_settings keeps the shape
  useCompanySettings already uses so that hook is seeded without a change.
- lib/reference-data/fetchers.ts: browser Supabase for fiscal periods and
  cash accounts (mirroring period.list and listForCompany ordering, pinned
  by tests), /api for the lists whose routes do real work (accounts RPC,
  dimensions ensure, template scoping, customer masking).
- lib/reference-data/hooks.ts: useFiscalPeriods, useCashAccounts,
  useAccounts, useDimensions, useBookingTemplates, useCustomers,
  useSuppliers, useArticles (+ re-exported useCompanySettings); one-minute
  dedupe, keepPreviousData, background revalidation kept on so writes from
  MCP/agents/other tabs surface.
- lib/reference-data/invalidate.ts: invalidateReferenceData(kind) for the
  success path of every client write.
- lib/reference-data/seed.ts + components/providers/ReferenceDataSeed.tsx:
  the dashboard layout fetches fiscal periods and cash accounts in its
  existing batch and hands them, with the settings row it already had, to
  SWR as fallback, so the first form of a session renders its period, bank
  account and settings-driven fields on first paint. getDashboardSettings
  now selects the full row for that (its other consumers read a subset).
  The chart of accounts is not seeded (hundreds of KB for large charts).
- scripts/checks/raw-reference-fetch.mjs, wired into check:guards as a
  per-file ratchet: GET-shaped fetch('/api/<reference path>') anywhere in
  client-facing code and .from('<reference table>').select( in 'use client'
  files. Baselined at 55 files; new sites fail CI; at 0 the entry goes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(checks): anchor every optional whitespace run in the raw-reference-fetch regex

CodeQL js/redos flagged the `\s*,?\s*\)` tail: two adjacent optional
whitespace runs around an optional comma backtrack polynomially on a long
near-miss. The URL and init-object pieces are now named fragments and
every whitespace run is followed by a literal, so there is one way to
match. Behaviour unchanged (same 7 fixtures + baseline count of 55);
a worst-case timing test pins the linear scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(checks): make the use-client directive regex unambiguous (CodeQL js/redos)

An unclosed /* let the lazy comment body be re-split at every later /*.
The body is now (?:[^*]|\*(?!\/))* which cannot cross a */, so the outer
repetition has one parse. Pinned with a 3000-comment worst-case test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: re-trigger checks for the rebased head

No workflow ran for dd560a7af (nor after close/reopen); an empty commit
gives the pull_request event a fresh head. No code change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(checks): single-character whitespace alternative in the use-client detector (CodeQL js/redos)

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>
2026-08-26 14:14:54 +02:00
Jakob Wennberg a8980a3a41 perf(hem): stream the home page in three sections and trim its query plan (#1945)
Hem was one ~33-query render behind a single fallback: the greeting waited
for the slowest worklist scan, and the request also paid a sequential
bank_file_imports read after the batch, a second scan of the suggested
matches (getWorklistCounts counted the same 200 rows the pane listed) and
an awaited stale-dismissal delete on the read path.

- page.tsx awaits only what the greeting shell and the redirects need
  (settings, profile, agent profile, the Skatteverket flag); the notice
  line, the setup checklist and the Att göra + Fortsätt panes are async
  server components behind their own Suspense (hem-sections.tsx). RSC
  streaming applies to client navigations too, so the greeting paints
  first on every visit and each block fills in as its queries land.
- DashboardContent becomes the shell with three slots; HemNotices keeps
  the one client-side action (the wrong-account sign-out); HemSkeletons
  are the two fallbacks.
- getWorklistCounts accepts the suggested matches the caller is already
  fetching (a promise, so it stays parallel); listSuggestedMatches runs
  once at the scan cap and the pane shows the first five.
- countInboxDocuments runs its id chunks in one wave instead of N
  sequential round trips.
- getCompanyNotices takes deferReap; Hem passes Next's after() so the
  stale-dismissal delete runs after the response.
- bank_file_imports joins the checklist section's batch.

Tests: worklist aggregate (precomputed matches skip the rescan), notices
aggregate (deferReap receives the reap; the delete does not run inline
and runs when the task does).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:02:09 +02:00
Jakob Wennberg a3326a0296 perf(nav): hover-intent prefetch for the dashboard nav + 30 s client router cache (#1943)
* perf(nav): prefetch dashboard routes on hover intent, not on viewport

DashboardNav renders ~45 links, all dynamic routes with a loading
boundary, so Next prefetched every one of them as soon as the nav mounted.
Each prefetch is a full request through the auth proxy (Supabase Auth
round trip, active-company RPC, MFA check) whose only payload is the
shared loading skeleton; prod logs showed 1,000 to 1,300 such hits per nav
route per day.

NavLink wraps next/link with prefetch={false} and an explicit
router.prefetch on mouseenter/focus/touchstart (Link's own hover prefetch
is disabled together with viewport prefetch, so the warm-up must be
explicit). The link to the current route and non-routes are skipped
(shouldWarmNavRoute, tested). A source-shape test pins that DashboardNav
has no bare next/link left.

Cost: an un-hovered click shows the route's loading skeleton ~50-100 ms
later than before; the skeleton is all a dynamic prefetch ever carried.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(router): keep dynamic routes in the client router cache for 30 s

experimental.staleTimes.dynamic was 0: every back/forward or repeated nav
click re-requested the RSC payload through the auth proxy. 30 s covers the
click-around pattern the customer described while the 16 router.refresh()
sites after mutations keep the pages that must not go stale fresh.
Separate commit so it can be dropped on its own if stale numbers are
reported.

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>
2026-08-26 14:01:37 +02:00
Jakob Wennberg b8605aabfc fix(settings): derive API-key scope groups and tool counts from the scope catalogue (#1924)
The API-key settings panel carried a hand-copied list of scope groups that
had drifted to 24 of the 30 scopes in API_KEY_SCOPES: articles:read/write,
companies:write and the three reconciliation scopes were missing, so a key
minted in the dashboard could not call gnubok_create_company, the article
tools, the seven reconciliation tools or the matching v1 endpoints. The
per-scope "N verktyg" counts in the panel and in the API_KEY_SCOPES
descriptions were hand-maintained and wrong (reports:read said 18, actual
30; bookkeeping:write said 11, actual 22).

- Move the pure scope catalogue (API_KEY_SCOPES, scope lists, SCOPE_GROUPS,
  TOOL_SCOPE_MAP) into lib/auth/scope-catalog.ts with no server imports, so
  the client-side panel can bundle it. api-keys.ts re-exports everything,
  so existing imports are unchanged.
- SCOPE_GROUPS becomes a list of { domain, label, scopes } covering every
  scope (reconciliation has three), shared by the panel and the OAuth
  consent page. scopeKind() replaces the ad hoc suffix checks.
- TOOL_COUNT_BY_SCOPE is derived from TOOL_SCOPE_MAP at module load; the
  hand-written counts are removed from the catalogue descriptions.
- The panel renders groups and cards from the catalogue; i18n keys are
  derived from domain and scope id. The "(REST API)" heading suffix is
  computed from the counts instead of baked into the labels.
- New unit test asserts every scope belongs to exactly one group and that
  counts equal TOOL_SCOPE_MAP occurrences.
- New sv/en strings for the articles, companies:write and reconciliation
  scopes.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:35:42 +02:00
Jakob Wennberg f338850bd0 fix: hide API-archived customers and suppliers from lists and pickers (#1927)
* fix: hide API-archived customers and suppliers from lists and pickers

The v1 API soft-archives customers and suppliers (archived_at, plus
is_active=false on suppliers) and its own list routes hide those rows
behind ?include_archived=true. No other surface filtered archived_at, so
an archived counterparty stayed a normal row in the dashboard rosters,
the internal /api/customers and /api/suppliers list routes, the MCP list
tools and every customer/supplier picker.

Apply the same canonical `archived_at IS NULL` filter on every non-v1
list and picker path:

- /api/customers GET, /api/suppliers GET (feeds the customers page and
  the supplier-invoice form)
- suppliers dashboard page (reads suppliers via browser Supabase)
- InvoiceEditor and NewRecurringScheduleDialog customer pickers; an
  invoice or schedule being edited keeps its current customer visible
  (archiving does not refuse on drafts, so a draft can point at one)
- deadlines page and CalendarWorkspace customer pickers
- InvoicePreviewCard sample customer
- gnubok_list_customers and gnubok_list_suppliers: hidden by default,
  optional include_archived boolean mirroring the v1 flag; rows now
  carry archived_at so an agent can tell them apart when opted in

Detail routes and by-id lookups are untouched: an archived row still
opens. The delete-vs-archive semantics are unchanged.

The tools/list payload guard moves 60.7K to 60.8K: main had ~6 tokens
of headroom, so even the bare boolean contract crossed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(schema): raise the unresolvable-expression ceiling by 2 for the archived-customer picker filters

The two .or('archived_at.is.null,id.eq.<uuid>') filters keep an edited
draft's archived customer selectable. The uuid is a runtime value, so the
scanner cannot resolve the expression; both columns exist and the filter is
covered by the archived-counterparty tests.

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>
2026-08-26 13:35:27 +02:00
Mattsson 00e7ac92ae feat(support): attach images and PDFs to the in-app contact form
Add optional image and PDF attachments to the existing in-app support contact form, with client-side limits, server-side validation, and email delivery. Preserve the existing subject, rate-limit, analytics, and storage behavior.
2026-08-26 12:32:29 +02:00
Mattsson d035d283ef feat(woo): selectable revenue accounts per VAT rate in the bulk order sweep (#1908)
* feat(woo): selectable revenue accounts per VAT rate in the bulk order sweep

The bulk sweep hardcoded the revenue side to the standard 3001-series, so
a store selling both goods and services could not route tjansteordrar to
its own revenue accounts (user request, follow-up to #1900). The bulk
dialog now has a "bokforingsmall" section: per-VAT-rate revenue account
inputs, shown only for rates present in the selection, prefilled with the
effective defaults; only diffs from the default map are sent.

Server side, BulkBookWebshopOrdersSchema gains an optional
revenue_accounts map (class 3 accounts only) that buildOrderBookingLines
routes each rate bucket's revenue line through; output VAT accounts stay
derived from the rate and are not overridable. User-chosen accounts are
never auto-created: the route verifies them against the company chart up
front and aborts the whole sweep with
WEBSHOP_ORDER_REVENUE_ACCOUNT_UNKNOWN naming the offenders, while
accounts in the closed prefill set keep riding the existing chart
repair. No hardcoded varor/tjanster preset on purpose: BAS 2026 has no
standard 30xx goods/services subdivision (see DECISIONS.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): harden the bulk revenue template per skeptic and review findings

Three findings from the adversarial review of the revenue-template
commit, fixed in one pass:

- Build breaker: revenueAccountByRate was typed Partial<Record<...>>,
  making Object.values() return (string | undefined)[] and failing the
  production build's type-check (Vitest and ESLint both miss it). Typed
  as Record<number, string>; only truthy strings are ever inserted.

- 3740 template collision (two skeptics, independently): choosing 3740
  as a revenue account passed the class-3 gate, skipped the chart guard
  (it is in the closed prefill set), and made the residual bound read
  the templated revenue line instead of the residual, so a mangled
  gift-card order the sweep must refuse could book a ~499 kr gap as
  "oresavrundning" in an immutable verifikat. 3740 is now banned by the
  schema and the dialog mirror, and the residual line is identified
  structurally (always the last line) instead of by account lookup,
  which also fixes the pre-existing misdiagnosis when 3740 is used as
  payment_account.

- Rate-classification guard (Swedish accounting review): output VAT
  books 2611/2621/2631 per rate regardless of template, but a custom
  account counts toward ruta 05 only when configured for that rate
  (explicit momssats, rate-mapped treatment, or rate-conforming
  30x1/2/3 number + name, i.e. exactly inferDomesticSalesRate, now
  exported and reused). A mismatched pair is refused up front with
  WEBSHOP_ORDER_REVENUE_ACCOUNT_RATE_MISMATCH naming the offenders;
  default-set accounts are valid only for the rate they are the default
  for; rate-0 buckets are exempt (no output VAT, legitimate momsfri/
  export accounts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): explicit momssats wins over name inference in the revenue-template guard

Two Swedish accounting review findings on the rate-classification guard:

- Precedence: the OR check let number+name inference qualify an account
  whose explicit default_vat_rate says a DIFFERENT rate (6%-configured
  account passing a 25% slot on its name). The guard now resolves ONE
  effective rate exactly like fetchDynamicVatAccounts does (explicit
  momssats, then rate-mapped treatment, inference only when nothing is
  configured) and compares that.

- Rate 0 slots no longer skip the check entirely: an account whose
  resolved rate is TAXABLE contradicts the 0% bucket and is refused,
  while unconfigured momsfri/export/EU accounts stay accepted (no
  contradicting configuration required, not positive proof of 0%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 21:04:06 +02:00
Mattsson a82328da84 fix(ui): make ContextPicker dropdown clickable inside modal dialogs (#1907)
The picker's listbox is portaled to document.body, so inside a modal
Radix dialog it inherits the pointer-events: none body lock: clicks on
items never register, the picker's outside-click handler sees the
mousedown as outside and closes the menu, and selection silently fails.
In the ROT/RUT payout dialog this made the ROT/RUT type switch (and the
year picker) dead, so a paid RUT invoice was unreachable (#1884
follow-up).

Apply the sanctioned companion-overlay pattern already used by
AccountCombobox and HelpPopover: pointer-events-auto undoes the body
lock, data-dialog-companion keeps DialogContent/SheetContent from
dismissing the dialog on a click in the list. Both are no-ops for
page-toolbar pickers outside dialogs.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:18:41 +02:00
Mattsson 328ccda10d fix: protect public Auth flows from automated abuse (#1904)
* fix: add Turnstile to public auth flows

* test: isolate Turnstile auth tests
2026-08-25 19:14:06 +02:00
Mattsson c634430677 feat(woo): select multiple orders and book them with one template sweep (#1900)
* feat(woo): select multiple orders and book them with one template sweep

Adds bulk booking to the orders page (issue #1880): hover-reveal checkbox
column, a bulkbar with select-all/clear, and a confirm dialog that books
every selected order with the standard order template (per-store payment-
method mapping, optionally one override account for the whole selection).

Server side, POST /api/webshop-orders/bulk-book books each order as its
OWN verifikat through the exact same flow as the single-order endpoint:
the guards, FX retry and race-free draft -> claim -> commit sequence are
extracted to lib/webshop-orders/book-order.ts and shared by both routes,
so nothing added to the single path can miss the bulk path. Partial
failure is reported per order and never aborts the batch.

Fixes #1880

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): replace mangled NUL byte in bulk dialog grouping key with a pipe

The account-group key template literal picked up a raw 0x00 byte during
generation (known escape-mangling hazard), making git treat the file as
binary. Same grouping semantics, plain '|' separator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): bulk sweep only books derived lines, never guessed ones (skeptic findings)

The sweep has no reviewing user, so everything the single dialog relies
on a human to catch is now refused per order or aborted:

- empty vat_breakdown: the ratio-inferred fallback split (a 25%+6% mixed
  sale classified as 12%, refunds reversing zero moms via 3004) is only
  allowed as the single dialog's editable prefill; bulk refuses with
  WEBSHOP_ORDER_VAT_BREAKDOWN_MISSING
- invoice-mode payment methods: booking would foreclose Skapa faktura
  and post a wrong clearing leg; refused with
  WEBSHOP_ORDER_INVOICE_MODE_METHOD (the account override does not
  bypass the merchant's configured flow)
- 3740 residual above ore scale (gift-card gaps booked as
  'oresavrundning'): refused with WEBSHOP_ORDER_RESIDUAL_TOO_LARGE
- settings-fetch failure now aborts the sweep instead of silently
  rebooking every order to 1686 against the confirmed dialog
- maxDuration 300 so a platform kill cannot strand an order between
  claim and commit
- per-order guard details (e.g. journal_entry_id) survive into the
  failure envelope

The dialog mirrors the skip rules up front (named order numbers, not an
anonymous count) so the confirmation describes exactly what will book.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): refuse non-Swedish VAT-rate buckets in the bulk sweep

A foreign OSS bucket (e.g. German 19%) passes the non-empty breakdown
gate with zero residual, but the rate-to-account maps would fall back to
the 25% accounts and book foreign VAT as Swedish utgaende moms 2611
(skeptic finding). The sweep now refuses such orders per order with
WEBSHOP_ORDER_UNSUPPORTED_VAT_RATE (details.rates names the offending
rates); the dialog mirrors the rule and names the skipped orders. Only
the single dialog may show that prefill, as an editable guess.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:34:34 +02:00
Mattsson 5fc0be9ed7 feat(webshop): generate orderunderlag and attach it to the verifikat at booking (#1899)
* feat(webshop): generate orderunderlag PDF and attach it to the verifikat at booking

Booked webshop orders only carried the VAT split; the verifikat showed no
product lines, customer or payment method although the sync already stores
all of it in webshop_orders.line_items (#1881).

- lib/webshop-orders/order-underlag.tsx: pure model builder + react-pdf
  template (order lines, customer, payment method, per-rate VAT summary,
  SEK conversion facts) + archiveWebshopOrderUnderlag, which renders and
  archives the PDF on the committed verifikat through uploadDocument
  (upload_source system, extraction none), mirroring archiveIssuedInvoicePdf.
  Never throws: the booking is immutable by then.
- book route: archive after commitEntry; response gains underlag_archived.
  FX-retry now also syncs the in-memory row so the underlag shows the
  resolved SEK facts.
- webshop_order added to NEEDS_DOC_SOURCE_TYPES and (new migration
  20260825140000) to the verifikat_without_documents needs-doc list, so a
  failed attach or a historical booking surfaces on the saknar-underlag
  worklist. transactions_without_documents is deliberately unchanged.
- tests: underlag model/render/archive unit tests, book-route archive and
  failure-isolation cases, pg test extended (per-source-type probe now
  covers webshop_order; explicit flagged/silenced pair).

Fixes #1881

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): move webshop needs-doc migration after main's 20260825150000

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(webshop): add manually_booked fields to the underlag order fixture

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webshop): skeptic findings on the orderunderlag (#1881)

Two refutations from the skeptic pass on PR #1899, both fixed:

1. Correctness: sv-SE Intl emits U+2212 MINUS SIGN for negatives, which
   Helvetica/WinAnsi PDF fonts drop silently, so refund and discount
   amounts on the archived underlag rendered as POSITIVE. formatAmount now
   replaces U+2212 with an ASCII hyphen (same guard as formatPdfCurrency),
   is exported, and is pinned by a regression test.

2. Regression: NEEDS_DOC_SOURCE_TYPES had two hardcoded copies that missed
   webshop_order, so flagged rows rendered without the "Underlag saknas"
   chip, waiver toggle, or batch-exempt selection, and the weekly
   missing-underlag push cron disagreed with the badge. The constant now
   lives in dependency-free lib/worklist/types.ts (client-safe), is
   re-exported from categories.ts, and both JournalEntryList.tsx and
   push-notifications/notification-scheduler.ts consume it instead of
   their own copies.

Also: "Bokfört i SEK" reworded to "Motsvarande i SEK" (compliance skeptic
observation: the dialog's lines are user-editable, so the underlag must
state the order's conversion, not claim a booking fact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:15:33 +02:00
Mattsson c6f2bebab9 fix(sie): selectable IB voucher series, smarter IB toggle on re-import, orphan-IB guard (#1896)
* fix(sie): selectable IB voucher series that never collides with the file's numbering

The Ingående balanser voucher was hardcoded to series A and created before
the file's vouchers, so it consumed the A series' next number and shifted
every imported A voucher one number higher than in the source system
(issue #1882).

- IB voucher series is now selectable in the import wizard; the default is
  the first of M,O,P,Q,R,S,T,V,W,X,Y,Z not used by the file's #VER records
  (M matches the existing migration-adjustment series).
- Plumbed end to end: wizard -> /api/import/sie/execute -> executeSIEImport,
  v1 REST options.openingBalanceSeries, MCP gnubok_import_sie
  opening_balance_series -> commitImportSie.
- The wizard's 'Importera ingående balanser' toggle now defaults OFF when a
  posted IB voucher already exists inside the file's fiscal year, with a
  hint saying why.
- Orphan-IB guard in executeSIEImport: replace_sie_import deletes only
  source_type='import' entries and clears the period's OB pointer, so a
  prior import's IB voucher survived every replace cycle and each re-import
  created another one (field report: five accumulated). The import now
  skips IB creation with a warning when a posted opening_balance entry
  already exists in the period.
- MCP import_opening_balances default (false) vs web (true) documented as
  deliberate in the tool schema and DECISIONS.md.

Fixes #1882

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sie): harden IB series fix after skeptic review (relink orphan, exclude fallback series, type-check option)

Skeptic findings on PR #1896, all four blocking items:

- Orphan-IB guard now relinks a single surviving opening-balance voucher
  as the period's OB entry (permitted by the immutability trigger while
  the pointer is NULL): without it, reports showed IB 0, year-end's
  duplicate-IB blocker never armed, and the manual IB flow could
  double-book. It also diffs the survivor's lines against the file's IB
  and calls out stale amounts in the warning instead of keeping them
  silently; reverseEntry clears the pointer again for the
  storno-then-reimport path.
- Series-less #VER records resolve to the transaction fallback series at
  import time, so the IB default picker now treats that series as used by
  the file (the same #1882 shift pattern through the fallback). The
  wizard recomputes its IB default with the effective transaction series
  once loaded.
- openingBalanceSeries is type-checked on the web execute route, the MCP
  stage, and the staged-operation commit: a non-string falls back to the
  default instead of crashing mid-import after side effects.
- The wizard's IB series select flags series used by the file and shows
  an attention line when the chosen series collides; the engine warns
  when an explicitly chosen series collides with the file's series (the
  choice is honored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sie): uppercase caller-chosen IB series before persisting

Swedish accounting review on PR #1896: a lowercase series from v1 or
MCP was persisted as-is, booking a case-distinct parallel series next
to its uppercase sibling (BFL 5 kap requires one systematic series)
and slipping past the file-collision warning. Normalize centrally in
executeSIEImport, the single funnel for web, v1, and MCP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:14:59 +02:00
Mattsson 79013cf092 feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883) (#1897)
* feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883)

Two deliverables from the community report where a bad SIE test import
left no way out short of deleting the company:

A) Discoverability: the voucher list shows one attn line linking to
   /import?history=sie whenever the page contains import-sourced
   vouchers, and /import?history=sie deep-links straight into the
   fold-open SIE import history where per-import Angra already lives.

B) Reset of an UNLOCKED fiscal year regardless of how the entries
   arrived: new reset_fiscal_year RPC (same gnubok.allow_delete escape
   hatch as undo_sie_import; no enforcement trigger touched) behind
   GET/POST /api/bookkeeping/fiscal-periods/[id]/reset and a typed
   type-the-year-name confirmation dialog on the fiscal years settings
   list. Refuses on: locked/closed year, company lock date over any part
   of the year, executed year-end, arsredovisning state, later year
   depending on this year's UB, VAT-declared evidence (vat_settlement
   verifikat, SKV lock/submit audit rows, extension workflow keys, fail
   closed) and AGI-declared months. Entries referenced by RESTRICT/NO
   ACTION FKs abort the whole reset (all-or-nothing). Documents are
   detached, never deleted (BFL 7 kap); every delete is audit-logged
   plus one behandlingshistorik summary row.

Fixes #1883

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): harden fiscal-year reset after skeptic review (#1883)

Blocking skeptic findings on PR #1897, one consolidated pass:

- New snapshot blocker cross_year_reference: an entry outside the year whose
  correction_of_id / reverses_id / reversed_by_id points into the year made
  the delete crash with an uncaught P0001 (immutability trigger refusing the
  ON DELETE SET NULL referential UPDATE) after an eligible:true preview, and
  silently severed draft chains. 12 such chains exist in prod today.
- New snapshot blocker rot_rut_state: a begaran om utbetalning that reached
  Skatteverket (submitted/paid/partially_paid/rejected) was silently
  unlinked via SET NULL, erasing the bokforing behind a filed and possibly
  decided myndighetsarende.
- Rakenskapsinformation preservation (BFL 7 kap): line-level trigger audit
  rows carry no company_id and header rows no amounts, so a reset destroyed
  konton/belopp with no company-readable trace. The RPC now archives the
  full content of every verifikat in company-scoped RESET_SNAPSHOT audit
  rows before deleting (action added to audit_log_action_check, NOT VALID),
  and behandlingshistorik renders them.
- Dimension registry lockstep on reset (mirrors undo_sie_import): flipped
  imports can never be undone again, so their dimensions/values would have
  been orphaned forever.
- EXCEPTION WHEN raise_exception now returns a typed
  FISCAL_YEAR_RESET_LINKED_ENTRIES envelope instead of a bare 500;
  gnubok.allow_delete is cleared before leaving the guarded block.
- Voucher-list attn line fires only for source_type 'import':
  opening_balance is also written by year-end closing and the manual IB
  flows, which mislabelled every year-2+ company as SIE-imported.
- /import?history=sie now scrolls the SIE history into view.
- Reset dialog copy (sv+en) discloses that linked invoices, payments and
  bank transactions become unbooked; new blocker strings in both locales.
- pg fixture fix: document_attachments seeded without company_id (23502);
  new pg tests for both blockers, RESET_SNAPSHOT rows and the lockstep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:36:18 +02:00
Jakob Wennberg d80103a2f5 fix(skatteverket): skattekonto-OCR is 13 digits, and the AGI panel stops guessing that you have not signed (#1888)
* fix(skatteverket): skattekonto-OCR is 13 digits, and the AGI panel stops guessing that you have not signed

Two reports from the same salary run (Fabian, Specific AI Sweden AB).

1. The payment file carried an OCR Skatteverket does not accept.
   generateSkattekontoOcr built the reference from the TEN-digit org number
   plus a Luhn check digit (11 digits). Skatteverket's reference is the
   TWELVE-digit identity plus a check digit: an organisationsnummer carries
   the "16" prefix, a personnummer its century. For 559547-0021 we emitted
   55954700211 where Skatteverket prints 1655954700217.

   The twelve-digit form is the same "redovisare" identity the AGI and moms
   APIs take, so it now goes through the shared toRedovisare12 converter
   instead of a second local rule: the payment file and the declaration it
   pays must not disagree about who the taxpayer is. That needs the entity
   type, which the route now reads alongside org_number.

   The route also prefers saldo.ocrNummer from the cached skattekonto
   snapshot over the derived value. It is Skatteverket's own answer for the
   account we actually sync, it covers identities the converter has no rule
   for (samordningsnummer, GD-nummer), and it covers the companies whose
   companies.org_number has drifted from company_settings.org_number.

2. AGI status stayed on "väntar på BankID-signatur i Mina Sidor" after the
   user had signed.
   Reading the kvittens needs a live Skatteverket session, and the personal
   token lives ~65 minutes, so by the time anyone signs in Mina Sidor the
   2-hourly kvittens cron finds a dead token and skips quietly. The panel
   kept asserting a state it could no longer observe.

   It now says so instead, and the reconnect action already on the panel is
   the fix: runPostConnectRefresh reconciles pending declarations on a fresh
   consent. sessionExpiredStatus also counts the needs_reconsent health flag,
   which a cron can set while the access token is still inside its hour;
   without it the panel reported a dead connection as healthy.

   Background reconciliation without a reconnect needs the läsombud grant,
   which is a registration decision and not part of this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(skatteverket): say why the entity_type collapse in the payment-file route is total

companies.entity_type is NOT NULL with CHECK IN ('enskild_firma',
'aktiebolag'), so the ternary cannot silently mis-tag an enskild firma as
a legal entity and give a personnummer the "16" prefix. Two review bots
read it as an unguarded default; write down the constraint that makes it
safe instead of leaving the next reader to re-derive it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(decisions): record why the cached skattekonto OCR needs no freshness gate

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>
2026-08-25 14:25:00 +02:00
Mattsson cbfb2201ff fix(rot-rut): surface drop-out reasons in payout request dialog and keep selectors usable (#1884) (#1891)
* fix(rot-rut): surface drop-out reasons in payout request dialog and keep selectors usable (#1884)

Four silent drop paths made a paid RUT invoice invisible in the begaran
dialog (neither eligible nor blocked), and the empty list hid the year
picker so the dialog looked dead:

1. deduction lines without a header deduction_total: a second line-based
   candidate query now finds them and they block as DEDUCTION_TOTAL_MISSING
   (also at file generation: the 1513 receivable was never booked).
2. partially_paid with the customer share settled: remaining_amount = 0
   (total - paid_amount - deduction_total, migration 20260817191708) now
   counts as paid in evaluateInvoiceForFile; a genuine partial blocks as
   NOT_PAID with the outstanding amount.
3. NO_DEDUCTION_OF_TYPE is no longer filtered out of blocked: the message
   points at the other type, and the dialog's empty state adds a
   switch-type hint.
4. invoices held by a generated/submitted begaran block as
   ALREADY_REQUESTED naming the request; decided requests stay omitted
   (finished business, visible in the history list).

The dialog keeps the year picker rendered when the list is empty (current
year as inert fallback) and opens the blocked list by default when nothing
is eligible.

Fixes #1884

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(rot-rut): skeptic hardening: decided requests vanish on both tabs, customer share derived from header fields (#1884)

Two skeptic refutations against the frozen PR head:

1. Regression: the wrong-type branch ran before the active-request lookup,
   so invoices of the OTHER type whose begaran was already decided
   resurfaced forever as NO_DEDUCTION_OF_TYPE in the opposite tab's blocked
   list, and the empty-state hint pointed at a tab where they never appear.
   The decided-request skip now runs first, on every tab.

2. Correctness: the paid gate and the NOT_PAID message trusted
   remaining_amount, but payment-sync's storno path recomputes it WITHOUT
   subtracting deduction_total, so the stored column can carry Skatteverkets
   1513 share and the dialog could assert a wrong customer-outstanding
   figure. The gate now derives the customer share as
   total - paid_amount - deduction_total (the buildInvoiceWriteData /
   migration 20260817191708 formula) from fields every settlement path
   maintains.

Tests pin both: decided+wrong-type omitted from both lists, corrupted
remaining still classified and reported from the derived share.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(rot-rut): align CANDIDATE_STATUSES comment with the derived-share gate (#1884)

The skeptic-hardening commit moved the paid gate off remaining_amount to
the derived customer share (total - paid_amount - deduction_total); the
comment still named remaining_amount as the signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(rot-rut): explicit decided-status set + correction-path wording (#1884)

Swedish accounting review findings on the candidate list:

1. The decided-begaran skip inferred 'decided' by exclusion (anything not
   generated/submitted), so a future request status would make an invoice
   vanish from both lists, exactly the silent drop the module forbids.
   DECIDED_REQUEST_STATUSES now names paid/partially_paid; any other
   status held by a request lands in blocked as ALREADY_REQUESTED with a
   generic message. Test pins it.

2. The DEDUCTION_TOTAL_MISSING message said only 'ratta fakturan', which
   could read as an invitation to edit a booked invoice directly. The
   invoice edit route already refuses sent/paid/booked invoices, and the
   message now names the sanctioned path: drafts edit directly, sent or
   paid invoices are corrected via credit note + new invoice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:24:01 +02:00
Mattsson 0bb482bf6e feat(bookkeeping): edit the lines of a proposed kontering (Andra rader) (#1894)
* feat(bookkeeping): edit the lines of a proposed kontering via Andra rader

Proposal views (AI suggestion, static template, counterparty template with
or without a line pattern) previously offered only accept-or-start-over: the
verifikation preview was pure rendering and the only line-editable path was
library templates. This adds an "Andra rader" affordance to the proposal
view in QuickReviewDialog that hands the COMPUTED lines (accounts, SEK
amounts, VAT legs, exactly what the preview shows) into
TransactionBookingDialog / JournalEntryForm as an editable prefill, reusing
the same initialLines mechanism library templates already use.

- lib/bookkeeping/proposal-lines.ts: line computation extracted from
  JournalEntryPreview into computeProposalLines() (single source for preview
  and prefill, so they cannot drift) plus proposalLinesToFormLines() mapping
  to the JournalEntryForm prefill shape. The settlement leg is flagged so
  the booking dialog swaps in the transaction's resolved cash account and
  stamps currency metadata, mirroring buildInitialLinesFromTemplate.
- JournalEntryPreview now renders computeProposalLines() output unchanged.
- TransactionBookingDialog accepts proposalLines (takes precedence over
  preselectedTemplate); the booking still goes through JournalEntryForm's
  normal manual validation and the engine, no validation bypassed.
- Ore rounding funnels through roundOre(); guard baseline ratcheted down.
- New strings in messages/sv.json and messages/en.json (tx_quick_review).
- Unit tests for all three proposal branches incl. VAT legs, reverse
  charge, multi-line patterns, 3740 rounding diff and FX metadata.

Fixes #1878

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): make the Andra rader prefill engine-exact (skeptic findings)

Three skeptics refuted the first cut of #1878: the extracted preview math
was a lossy approximation of the engine, and making it bookable made every
loss a real booking defect. This commit closes each refuted scenario by
mirroring the exact engine path per proposal branch:

- Balance: VAT is single-rounded and the net leg is gross minus that VAT
  (transaction-entries.ts semantics). Independently rounded net+VAT went
  off by 1 ore for 12% grosses at 14 mod 28 ore (e.g. 102.06, 100.94),
  prefillling an unbookable verifikat.
- 'Ingen moms' deviation: the dialog resolves the UI 'none' sentinel via
  resolveExplicitVat before computing lines, so an explicit no-VAT choice
  prefills no VAT line instead of re-deriving the 25% category default
  into a bookable 2641 leg (ruta 48 inflation on e.g. loan repayments).
- Ore parity: engineRound (plain Math.round(x*100)/100, matching the
  engine) replaces roundOre where the engine is naive; roundOre kept only
  where the engine uses it (category VAT leg). No more 1-ore drift between
  preview, prefill and the booked verifikat (8.62 RC, 34.30@12%).
- Legacy counterparty pairs: new counterpartyLegacy mode mirrors the
  legacy booking path: reverse charge emits the 2645/2614 fiktiv-moms pair
  (previously dropped: an RC expense would have booked without fiktiv
  moms, understating rutor 30/48), VAT on expenses only, income gross, and
  sign-mismatched matches mirrored like buildLegacyMismatchResult.
- Pattern mirror: sign-mismatched line patterns flip learned sides like
  buildMultiLineMappingResult; ratio allocation filters business/tax types.
- Entity accounts: static template accounts resolve debit/credit_account_ab
  for aktiebolag (resolveTemplateAccountsForEntity), so an AB no longer
  previews or books EF-only accounts like 2013.
- Settlement swap: only a literal-1930 settlement leg is swapped to the
  resolved cash account (applySettlementAccount parity); learned non-1930
  money legs (1510/2440/2890/19xx) stay authoritative.
- FX: QuickReviewDialog hands its enriched transaction row to the booking
  dialog so the settlement leg's exchange_rate metadata matches the rate
  the SEK amounts were computed with.

34 unit tests incl. every skeptic counterexample; guard baseline ratcheted
to 622 (below main's 626).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): line-pattern settlement leg uses the learned legacy pair (skeptic refutation)

Two independent skeptics refuted the pattern branch: the engine books the
money leg on the counterparty template's learned legacy account (credit
for an expense, debit for an income, mirror-swapped, falling back to
1930), while the preview/prefill defaulted to 1930. A SIE-learned
pattern settling on 2440 showed kredit 1930 in the preview but booked
kredit 2440 on confirm. QuickReviewDialog now passes the learned pair
raw (no entity resolution, engine parity) and computeProposalLines
selects the settlement account exactly like buildTransactionEntryLines;
the literal-1930 swap to the resolved cash account is unchanged.

CodeRabbit findings declined deliberately (see DECISIONS.md): the 3740
rounding line keeps the engine's business-side placement for both diff
signs (parity contract; an unbalanced set is rejected at commit), and
the naiveOreRound baseline stays at 622 (engineRound is a documented
parity exception).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:23:58 +02:00
Mattsson 1f9578ca76 feat(woo): mark an order as already booked outside the integration (#1895)
* feat(woo): mark an order as already booked outside the integration

Orders booked by hand before the store was connected sat under Att
bokfora forever: the only exits were the book and create-invoice routes.

- Migration: manually_booked_at/_by + optional
  manually_booked_journal_entry_id on webshop_orders (informational link,
  no financial freeze; the mark produced no accounting objects).
- POST/DELETE /api/webshop-orders/[id]/mark-booked: mark with optional
  posted-verifikat reference (validated per company), conditional claim
  against concurrent booking/invoicing; unmark is a plain revert.
- book and create-invoice routes refuse marked rows (409
  WEBSHOP_ORDER_MANUALLY_BOOKED) and exclude them in their atomic claims.
- List route: booked/unbooked filters treat a manual mark as a closed
  exit, so marked rows leave the Att bokfora tab and join Bokforda.
- Orders page: row overflow menu with Markera som bokford / Angra
  markering, MarkOrderBookedDialog with a searchable candidate list of
  posted entries near the order date, muted status text linking to the
  referenced verifikat.

Fixes #1879

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): close skeptic findings on the manual-booked mark

- mark-booked applies the same open-twin gate as book/create-invoice:
  an OPEN legacy feed transaction blocks the mark (409
  WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN); ignored or booked feed rows
  unlock it, so no open path to a duplicate remains.
- ingest treats manually marked rows as frozen for drift purposes:
  remote financial deltas set remote_changed_after_freeze (same badge as
  booked rows) instead of silently refreshing the row under the user's
  assertion.
- re-marking with a journal_entry_id updates the informational link
  instead of silently dropping it.
- dialog: candidate amount computed from the returned lines (the list
  API does not return total_amount), newest-first ordering, cap hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): bump webshop manual-booking migration past freshly merged 20260825120000

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): resolve PR review findings in one pass

- freeze v3 migration: financial fields are frozen at the DB level while
  a row is manually marked as booked (review finding: the mark's freeze
  lived only in ingest.ts, so any other write path could silently mutate
  a marked row); unmark stays the escape hatch. pg test added.
- pass the active locale to getErrorMessage in the orders page and
  MarkOrderBookedDialog (CodeRabbit: English users got Swedish errors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:23:56 +02:00
Mattsson cd46d936f3 fix(bokforing): account search results in the booking dialog search field (#1877) (#1889)
* fix(bokforing): account search results in the booking dialog search field (#1877)

The search field in the "Bokfor transaktion" dialog only matched template
metadata, so typing an account number or name (e.g. active 5460
Forbrukningsmaterial) gave zero hits and no path to booking; the real
account search was only reachable via the discreet "Bokfor manuellt" link.

- TemplatePicker now also searches the company's active chart of accounts
  (reusing lib/bookkeeping/account-search.ts) and shows hits as a "Konton"
  result group; picking one routes into the same manual booking flow as
  "Bokfor manuellt" with the account prefilled on the counter line.
- New buildActiveAccountIndex helper indexes chart rows active-only, so
  deactivated accounts never surface as bookable results.
- searchTemplates additionally prefix-matches all-digit tokens against a
  template's business account (debit for expense, credit for income, both
  legs for transfers, AB variants included); the settlement leg is
  deliberately excluded so "1930" does not light up every template.
- Library template search prefix-matches line accounts on all-digit queries.
- Empty search results show a hint pointing at "Bokfor manuellt" for
  accounts outside the active chart; placeholder now says "Sok mall eller
  konto...". New strings in both messages/sv.json and messages/en.json.

Fixes #1877

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bokforing): match only business lines when digit-searching library templates (#1877)

CodeRabbit review on PR #1889: the all-digit library-template match also hit
settlement and VAT lines, so searching "1930" lit up every user template with
a bank settlement leg. Restrict the predicate to business lines, mirroring
the static catalog's settlement-leg exclusion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 13:30:03 +02:00
Jakob Wennberg a717f03898 feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup (#1814 PR 1) (#1855)
* feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup

Identity unlock for agent-first onboarding (#1814, shape B+). A person
with no Accounted account can now connect from an MCP client, create the
account inside the Connect popup and finish the OAuth dance.

- authorize/token no longer require a company: consent renders a
  companyless variant and the key is minted with company_id NULL.
- validateApiKey returns companyId string|null and binds an unbound key
  to the user's first company on the first validation after it exists.
- MCP server: company-dependent tools and data resources answer with a
  structured NO_COMPANY_YET error; the company-independent tools still
  run; telemetry skips when there is no company scope.
- /api/events fails closed instead of throwing for an unbound key.
- authorize forces TOTP enrollment (not just verification) for password
  accounts with no factor, since the middleware skips enrollment for
  zero-company users; BankID-linked accounts stay exempt.
- /login forwards next to /register; register, GoogleAuthButton and
  /auth/callback carry it back to the consent page (callback honours
  only /api/mcp-oauth/authorize, via safeReturnTo); /mfa/enroll
  hard-navigates to /api/* destinations like /mfa/verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* refactor(company): move getActiveCompanyId out of the next/headers module

lib/auth/api-keys.ts needs the resolver for unbound-key binding, but
lib/company/context.ts imports next/headers for the legacy company cookie
and Turbopack refuses that import on some of api-keys' import paths (the
preview build failed). The resolver and CompanyContextError now live in
lib/company/active-company.ts; context.ts re-exports them so every caller
and test mock is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(mcp-oauth): fail closed on a failed assurance lookup; enroll Back aborts instead of looping

Review findings on #1855: requireAal2 let consent through at AAL1 when
getAuthenticatorAssuranceLevel() returned nothing and a verified factor
existed. Only a positive AAL2 answer passes now; a failed lookup and the
inconsistent verified-factor-at-AAL1 case both step up to /mfa/verify.
Back on /mfa/enroll with the consent page as returnTo went straight back
into the redirect loop; it now aborts to the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:25:42 +02:00
Jakob Wennberg 43a569d248 fix(reconciliation): declutter the Avstämning header and quiet the underlag section (#1886)
The header keeps only the page scope (räkenskapsår, intervall); the
Översikt/Matcha manuellt switch moves down to the body it switches, and the
Bokslutsbilagor link sits under the account rail it summarizes. The underlag
section is one quiet line when nothing is attached, lists files only when
there are some, and comes after the numbers instead of before them. The
native file input is display:none: sr-only still leaked the browser's
"Ingen fil har valts".


Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:43:32 +02:00
Jakob Wennberg 3dce94d39e fix(bokslut): årsredovisning for a klarmarkerad year, bokslutsbilagor under Bokslut, bilagor reminder (#1875)
* fix(bokslut): årsredovisning for a klarmarkerad year, bokslutsbilagor under Bokslut in the menu, bilagor reminder on Kontroll

A year closed in a previous system (Klarmarkera perioden) has no closing
verifikat in these books by definition, so the statutory pre-closing guard
in the trial balance has nothing to strip; it now lets that case through
instead of failing the whole Årsredovisning page with a 500.

Bokslutsbilagor now sits in the Bokslut fold of the menu (a byrå looks for
the bilagor inside the bokslut module, not under Rapporter), and the
wizard's Kontroll step says how many balance accounts are still unsigned
per balansdagen with a link to the pärm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(nav): admit the bokslutsbilagor label key

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* feat(reconciliation): fold the manual accounts in the rail, with count and unsigned hint

Twenty-odd balance accounts pushed the bank rows out of view on a migrated
company. The group opens when a manual account is selected and otherwise
remembers the last choice per browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:08:46 +02:00
Jakob Wennberg 9ce1ebc65f feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4) (#1874)
* feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4)

One bilaga per balance account as of the balansdag: IB, movement and UB
from the trial balance, what it was reconciled against, the difference,
the sign-off with who, when and note, and every attached file with its
SHA-256; the closing checklist as the first page. JSON and PDF through
/api/reports/bokslutsbilagor, in the reports library and on the
Avstämning page, and written into every period folder of the full
archive. Built from the attested rows, never by recomputing live status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(reports): load the pärm renderer on demand in the full archive so PDF stubs elsewhere keep working

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(reconciliation): neutral rail dot for a manual account that is merely not attested yet

An unsigned manual account without a system specification has nothing to
compare against, so an amber dot read as a problem on every balance account
of a freshly migrated company. Neutral until it is signed or a
specification differs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:35:02 +02:00
Jakob Wennberg c62321988b feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)
* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2)

A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an
account was reconciled against to (account_key, through_date), before or
after the sign-off, from every account body on the Avstämning page. Rows
live in account_reconciliation_attachments (append-only, removal stamp by
trigger, RLS like account_reconciliations), bytes in the documents bucket
under the company prefix so its RLS applies unchanged, and the full
archive copies them into bilagor/ with a hash manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867)

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3)

The bokslut checklist is a catalogue in code with one state row per period
(bokslut_checklist_items): the steps the system can judge (sign-offs through
balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are
computed live and a stored row only overrides them; the manual steps are
the konsult's ticks, with who and when. It sits on the wizard's Kontroll
step and is dumped into the full archive.

A hole between fiscal years (one-file SIE migrations) is now named on the
bokslut readiness screen and on the import result screen, where the next
file is one click away. Non-adjacent period links are #1849's fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
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>
2026-08-25 09:34:05 +02:00
Jakob Wennberg ebbdf96b74 feat(reconciliation): manual adapter for the whole balance sheet (Reko bilagor, PR 1) (#1854)
* feat(reconciliation): manual adapter so the whole balance sheet is reconcilable and signable (Reko bilagor, PR 1)

Every class 1-2 account the bank and skattekonto adapters do not own now
appears on the Avstämning page under "Övriga balanskonton" with IB, movement
and UB through the balansdag, a system specification where one exists
(1510 kundreskontra, 2440 leverantörsreskontra, 2920/2940 semesterlöneskuld)
and, for every other account, the balance the signer states from their
underlag at sign-off. Same three doors as before: dashboard routes, v1 API
and the MCP tools take manual:<BAS> keys and an external_balance.

The ledger side is computed per fiscal period via generateTrialBalance,
never as an all-history sum: year-end re-books every balance account in an
opening_balance verifikat, so an all-history sum counts a closed year twice.

A stated external_balance is refused (EXTERNAL_BALANCE_NOT_ALLOWED) wherever
the system already has an outside truth, so it can never hide a difference.

No migration: account_reconciliations already accepts manual:NNNN keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* chore(api-skill): regenerate banking reference for the sign-off external_balance field

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:23:14 +02:00
Jakob Wennberg 9ebb2e518f feat(reconciliation): absorb the last bank-view tools and retire /reports/bank-reconciliation (#1871)
The old Bankavstämning report page was the only place a user could still tag
a bank row as ingående balans or move it to another bank account, so the new
/reconciliation page kept linking out to it and the reconciliation lived in
two places. Both row tools now live on the account overview (hover-revealed,
same endpoints), the ?autorun=1 deep link from the transactions inbox runs the
matcher on the new page, and the report slug redirects: old links, ⌘K, the
bokslut readiness wizard and the ignore toast all land on /reconciliation.

BankReconciliationView and its FocusedReport branches (own range preset,
help popover, autoRun plumbing) are removed; the source-grepping parity test
for its quick-book path goes with it.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:03:17 +02:00
Jakob Wennberg d88df74b85 feat(reconciliation): residual booking + junction-aware bridge (#1862)
When the worksheet selection (N bank rows vs one verifikat) misses by a
few kronor, 'Bokför mellanskillnaden som Bankavgift / Räntekostnad /
Ränteintäkt / Öresavrundning och koppla' books the remainder on
6570 / 8410 / 8310 / 3740 against the bank account, links the rows to the
main verifikat and anchors the residual verifikat through
transaction_voucher_links. Bank accounts only (Skatteverket posts ränta
and avgifter as rows of their own), capped at 5 000 kr, direction-checked
against the kind; links are made first and undone if the booking is
refused. Dashboard + v1 doors (transactions:write, Idempotency-Key,
dry run), API skill regenerated.

The bridge now treats transaction_voucher_links as links on both sides:
migration 20260824190000 re-creates get_unlinked_gl_lines and
get_account_gl_lines_for_matching to count junction-linked verifikat as
matched (pg-real test), and the TS engine + items do the same for the
transactions. This also stops bulk-booked samlingsverifikat from
polluting the open buckets. 'Koppla bort' drops the junction rows too.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 08:30:54 +02:00
Mattsson 174f315d9c fix(documents): anchor underlag at deferred SI booking, sanctioned duplicate detach (#1860)
* fix(documents): anchor underlag at deferred SI booking, sanctioned duplicate detach

Support case 2026-08-24: a verifikat booked from a leverantorsfaktura/utlagg
stayed under 'Saknar underlag' with the PDF attached, and a twice-uploaded
underlag could only be replaced, never removed.

- POST /api/supplier-invoices/[id]/book now calls
  anchorSupplierInvoiceDocument() after the CAS link: the deferred (#967)
  flow was the last booking surface that never anchored the invoice's
  retained source document, so every missing-underlag surface kept flagging
  the registration verifikat until payment.
- Repair migration 20260824150000 re-runs the 20260727180000 sweep for rows
  created since (idempotent, open unlocked periods only).
- New detach_underlag_duplicate RPC (migration 20260824151000): the one
  sanctioned path to detach a redundant duplicate underlag from a posted
  verifikat. Guarded: writer role, open unlocked period, company lock date,
  at least one other anchored underlag must remain (BFL 5 kap 7 par), pinned
  docs (transactions/supplier_invoices.document_id) stay replace-only.
  Audit-logged first, transaction-local gnubok.allow_delete carve-out. The
  file is never deleted: it returns to the unlinked pool.
- POST /api/documents/[id]/detach + 'Koppla bort dubblett' in the verifikat
  attachments blocked-dialog when the entry keeps 2+ direct docs (sv+en).
- Tests: book-route anchor assertions, detach route unit tests, pg-real
  suite for the RPC incl. the direct-UPDATE-stays-blocked invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(documents): harden detach_underlag_duplicate per skeptic findings

- Require sha256 identity: detach only when a remaining anchored sibling
  carries the same immutable sha256_hash, so only byte-identical duplicates
  ever leave a verifikat (two different handlingar both stay behind the WORM
  guards). UI gates the button on the same condition.
- Enforce the documented posted-status guard (reversed/cancelled verifikat
  refuse detach).
- Set company_id on the RPC's audit_log row: the SELECT policy filters on
  company_id, so the provenance row was invisible to every reader (same
  defect 20260528120600 fixed for delete_last_voucher).
- Swedish 403 message on the tenant guard (CodeRabbit).
- pg tests: closed-period case now seeds open and closes via UPDATE (the
  period-lock trigger blocks seeding into a closed period), duplicate pairs
  share a hash, added non-duplicate and reversed-entry refusals, audit
  assertion pins the RPC's own row (description + company_id + actor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:05:00 +02:00
Jakob Wennberg 3edbf0a2e3 fix(agent): chat console keeps its thread across turns and reloads (#1859)
Three user-reported failures in the assistant panel, one root cause each:

1. "The chat asks what I'm referring to" when continuing a thread. The
   single-call console (general.help, AskConsole -> /api/agent/ask) was
   stateless since the 08-20 model-agnostic cutover: conversationId was only
   the tool actor id, so every turn was answered blind, reload or not.
   The provider-agnostic GenerateTextRequest gains an optional `history`
   (real message turns before the prompt, in both the Anthropic-family and
   the OpenAI-compatible adapter; absent/empty leaves the request
   byte-identical to the single-turn call). The route loads the thread's
   earlier turns server-side (loadChatHistory: text only, hidden and tool
   rows dropped, alternation repaired, newest 16 rows / 10k chars) before
   writing the new question, and hands them to the model.

2. A full page reload (the deploy prompt's "Ladda om") closed the docked
   panel and dropped the thread from view. The panel now remembers its open
   thread per tab in sessionStorage (lib/agent-panel/session-restore) and
   the provider reopens it on mount; the sheet loads it exactly like a pick
   from "Tidigare konversationer". Close and "Ny konversation" forget it; a
   thread that no longer opens is dropped instead of retried on every reload.

3. "Can't type any more" once the update banner shows. DeployReloadPrompt's
   full-width wrapper sits at z-[60] after the panel in DOM order and
   swallowed clicks on the panel's composer; only the card takes input now.


Claude-Session: https://claude.ai/code/session_01VjoXN3xdNZrHZeYA6qMi3g

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:37:30 +02:00
Mattsson f75ea2384d feat(calendar): enable calendar sync for Viktiga datum (#1853)
Turns on the calendar extension (built Feb 2026, stripped in the 2026-03-02 production readiness deploy, never re-enabled): ICS feed settings, calendar workspace, subscribe button on the Viktiga datum page.

Hardening before first real use: feed serve route now requires the creator to still be a company member (offboarding stops the feed); stable pagination (due_date + id, dedupe) on feed queries; fetches inside the logged try block; invoice events limited to sent/paid/partially_paid/overdue; event UIDs rebranded to accounted.se while zero feeds exist; APP_URL fallback fails closed in production; mobile stacking for the deadlines header; settings note that Google Calendar needs default notifications on subscribed calendars; calendar workspace aligned with the design system.

Skeptic reviewed (3 refutations, all fixed) plus one compliance swarm finding (fixed). No migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:32:44 +02:00
Jakob Wennberg 4fb449a474 fix(reconciliation): bank tile says 'Rörelse på banken' with the in/ut/antal split (#1858)
'Banktransaktioner i perioden (netto)' made a bookkeeper's abstraction do
the explaining; Jakob read it as gross activity. The tile now reads
'Rörelse på banken i perioden' and its sub-line shows the gross split
behind the net (in · ut · antal transaktioner), so 'netto' needs no
label. The bank status carries bank_transaction_inflow / _outflow /
_count for it; the bridge line uses the same wording.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:24:26 +02:00
Jakob Wennberg 34ad1b1936 feat(reconciliation): manual N:1 matching: two-pane worksheet + group links (#1851)
'Matcha manuellt' as designed: outside rows on the left (multi-select),
verifikat without an outside row on the right (single-select), the
selection's arithmetic in the footer, and one Koppla that is enabled only
when the difference is 0. Mode lives in the URL (?mode=match).

Engine: a pair is now one OR MANY outside rows against one verifikat.
Bank groups link per transaction (manualLink allows N:1 by design, so
partial success is reported per row). Skattekonto groups go through the
new linkSkattekontoRows: the verifikat's 1630 side must settle the sum,
one guarded UPDATE links the whole group, and a partial hit is rolled
back as LINK_RACE. 1:M stays UNSUPPORTED_PAIR_SHAPE until the residual
link table (6c). v1 pitfalls + API skill regenerated.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:24:07 +02:00
Mattsson 4d6dd68df4 fix(mcp): surface BFL 5 kap 6 § underlag requirements in voucher and transaction tooling (#1844)
* fix(mcp): surface BFL 5 kap 6 § underlag requirements in voucher and transaction tooling

Addresses a user report that the MCP surface treats document anchoring as
optional convenience while the law treats it as mandatory:

- create_voucher: description and inbox_item_id reframed as the compliant
  path for received handlingar; staging without a document now carries an
  advisory compliance_warning in the preview and a WARNING in the message
  (never blocks: IB/migration vouchers legitimately lack a kvitto).
- High-risk approval guidance now tells the agent to surface any preview
  compliance_warning alongside the 5 kap 5 § irreversibility.
- Approval-flow copy is scope-aware: staging responses and
  list_pending_operations explain that keys without
  pending_operations:approve (SoD) approve at /pending instead of hunting
  for a tool their catalog hides.
- categorize/create/bulk_book transactions: state that a transaction models
  a cash-account movement and point cashless events (privat utlagg) at
  create_voucher instead of silently fabricating a bank line.
- link_document_to_voucher: signposts the pre-posting inbox path.
- /pending VoucherPreview shows an attn line when document_attached=false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): entity-neutral utlagg hint, coherent no-document envelope, real attn styling

Addresses the skeptic refutations on da811a58f:

- categorize_transaction: drop the 6540/2893 example; 2893 is AB-only
  (EF books egen insattning 2013/2018 per category-mapping.ts) and a
  static description cannot know the entity type.
- create_voucher: when inbox_item_id is supplied but the item has no
  stored document (document_id NULL, ON DELETE SET NULL), the will-text
  no longer claims an OCR attach and the compliance warning gets an
  inbox variant instead of a dead-end 'restage with inbox_item_id'.
  Pinned by a new test.
- /pending VoucherPreview: use the AttnLine component (the bare 'attn'
  class does not exist) and conditional wording so IB/internal vouchers
  are not falsely flagged under BFL 5 kap 6 \u00a7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): exempt IB vouchers from the underlag warning; UI mirrors the staged warning

Swedish accounting review round 2: the warning keyed off document_attached
alone, so migrated IB entries (and the /pending card for any old no-document
row) were falsely flagged under BFL 5 kap 6 \u00a7. Staging now skips the
complianceNote when is_opening_balance=true, and VoucherPreview gates on the
staged compliance_warning instead of overloading document_attached, so the
server owns the policy in one place. Pinned by a new IB test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): trim two descriptions to fit the tools/list payload ceiling after merging main

PR #1846 spent the 59,950 headroom; the merged state crossed by 7 tokens.
Dropped the redundant filter enumeration from list_pending_operations (the
schema documents the filters) and the dims-bags aside from
bulk_book_transactions (the lines schema documents the bags).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:17:55 +02:00
Jakob Wennberg cef8206f83 feat(salary): skattekonto payment file as ISO 20022 pain.001 (#1845)
The tax payment file (skatt + arbetsgivaravgifter to Skatteverket BG
5050-1055) was Bankgirot LB only; banks that take pain.001 for salary,
like SEB via file communication agreements, refuse the LB .txt. The
route now accepts ?format=pain001 and generates the payment through the
supplier-payment pain.001 generator, whose Swedish giro dialect
(BG payee + SCOR OCR, Validex-validated) is exactly this payment shape.

The TaxPaymentPanel gets the same format selector as the salary
PaymentFilePanel, seeded from company_settings.preferred_payment_format,
with the missing-sender warning per format (bankgiro for LB, IBAN for
pain.001). A migration widens the tax_payment_file_format CHECK to
admit 'pain001'; bg_lb stays the default for old clients.


Claude-Session: https://claude.ai/code/session_01DUB7L8DbVP2icn8ZpoPkoE

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 15:29:21 +02:00
Jakob Wennberg 1b54bcd4bd feat(reconciliation): inline automatic matcher + Oförklarat tooltip (#1843)
* feat(reconciliation): run the automatic matcher inline on the Avstämning page

'Matcha automatiskt' no longer sends the user to the bank report: it
runs the dry-run on the page and renders the candidate pairs in the
page's own paired-row grammar (voucher, date, confidence chip), applied
per row or all strong ones at once (floor 0.85, re-enforced server-side
via the run endpoint's intersection guard, same as the old view). The
Oförklarat tile gets a '?' tooltip saying what the number means: 0 =
everything explained even if rows remain to book; anything else = an
inconsistency, look before trusting. First bite of absorbing the old
bank view (manual N:M + residual booking and the redirect follow in 6b).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): add the col_proposal string the matcher preview references

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>
2026-08-24 15:18:56 +02:00