46c0b72ab0dc555d40cde0b9322224712c055ee9
446 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
46c0b72ab0 |
feat(auth): surface duplicate-account traps around BankID login (#1234)
* feat(auth): surface duplicate-account traps around BankID login Three escape hatches for the stale-duplicate-account trap (#1231, the Chillen support case): a user whose BankID resolves to an abandoned account got an empty app with no hint that their real bookkeeping lives in another account. - check-org-number: new exists_elsewhere signal (service role, reduced to one boolean) + a warn chip in the onboarding journey when the org number already exists in an account the user is not a member of. - Hem: one AttnLine under the greeting when the whole account has zero journal entries but a same-orgnr company elsewhere has real bookkeeping, with a sign-out action. Common case costs one indexed existence probe. - scripts/support/unlink-bankid.ts: dry-run-by-default support action that unlinks a BankID identity (delete + app_metadata clear + append-only SECURITY_EVENT audit_log row). Replaces the raw SQL used to resolve the original ticket. Closes #1231 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): harden unlink script and paginate hint queries per review - other-account-hint: fetchAllRows() on both company listings (PostgREST 1000-row cap; byrå users can hold many memberships); the journal probes stay limit(1) existence checks. - unlink-bankid: audit_log row is written BEFORE the delete so a partial failure can never delete without a trace; context queries fail closed instead of rendering an unknown account as empty; stdout no longer prints the personnummer hash or ciphertext (the unsalted hash is brute-forceable over the personnummer space); record_id now carries the identity row id and the snapshot includes id + linked_at. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fbd4b992f5 |
Add/db and speed (#1243)
* fix(privacy): make privacy policy page dark mode friendly Replace the hardcoded light gradient background with bg-background and add dark:prose-invert to the prose blocks so body text is readable on dark cards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cloud-backup): sync archives to Dropbox alongside Google Drive Introduce a CloudStorageProvider interface so performSync builds the archive set once and talks to storage only through it. Google Drive keeps its existing behaviour; Dropbox is a second implementation, so the compliance-relevant half (fingerprints, per-year layout, size fallback, progressive persistence) cannot drift between targets. Dropbox uses App folder access, matching the drive.file scope's "only what the app created" guarantee. Uploads are single-shot under 8 MB and chunked upload sessions above, every write verified against Dropbox's content_hash. Call arguments are ASCII-escaped per UTF-16 code unit so Swedish file names survive the Dropbox-API-Arg header. Each provider owns its extension_data keys, schedule, failure counter and alert throttle, so a dead Dropbox token cannot pause a healthy Drive backup. The google_drive_* keys and the /oauth/callback path are untouched: both are wire format for already-connected companies. isConfigured() gates /connect only. A deployment that loses its OAuth credentials must not trap users with a connection they cannot remove or a schedule they cannot switch off. Requires DROPBOX_APP_KEY and DROPBOX_APP_SECRET; the provider row renders disabled without them. No migration: state is extension_data JSON throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: remove merge-conflict markers committed in DECISIONS.md The merge that brought main into this branch staged DECISIONS.md while it still carried conflict markers, so cdc3a513 shipped an unresolved hunk (compliance swarm ISO 27001 A.8.32). DECISIONS.md is an append-only log, so both sides are kept: main's systemdokumentation entry followed by this branch's Dropbox entries. No decision was dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1a7152a7af |
feat(settings): skyline masthead on Abonnemang + AI works-with marks on API tab (#1241)
The Abonnemang tab gets a quiet decorative masthead: the marketing site's halftone Stockholm skyline as a wide banner strip on the frame tint, waterline pinned to the strip's bottom edge (same physics as the onboarding backdrop). Shown in every billing state; purely decorative. The API tab's "Anslut MCP-klient" group gets a works-with strip using the site's monochrome halftone Claude and OpenAI marks (copied into public/illustrations and registered in the shared manifest), with a bilingual caption. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
52ec3ce497 |
feat(support): open PostHog tickets from the existing support dialog (#1239)
Enables PostHog Support through the Direct API (posthog.conversations), restoring the second channel Recapt used to provide, but as a real ticket linked to the person and their session replay instead of a black hole. The in-app WIDGET stays off on purpose. It is a third-party floating chat bubble, which is exactly what Recapt was: it would sit next to the Assistenten FAB (which already has a hide_assistant_fab preference because users wanted it gone), cannot follow the locked design system, and its copy is not ours to keep Swedish. The conversations API gives the same tickets from components/ui/support-link.tsx, which is already on-design, Swedish and reachable from 8 surfaces. A ticket is explicitly NOT treated as delivery. submitFeedback returns ok only when the Resend email actually went out, even if the ticket opened. Recapt's precise failure mode was reporting success on its own channel while /api/support/contact was dead, and nobody is watching PostHog at 02:00. Tests pin that: ticket-only is ok:false. Identity verification uses posthog.setIdentity(distinctId, hash) at runtime rather than the identity_distinct_id/identity_hash init options PostHog's settings page documents. init runs from instrumentation-client.ts app-wide, before the user is known and including logged-out pages, and PostHog fixes init values for the session. setIdentity is a real method on the SDK (verified typed in posthog-js 1.407.3), so the hash applies from AnalyticsIdentify once the dashboard layout knows who the user is. Without the key it is skipped and tickets fall back to browser-scoped with email recovery, which is the normal state off hosted. POSTHOG_SECRET_API_KEY is server-only, no NEXT_PUBLIC_ prefix: it signs identity hashes AND authenticates external API requests, so unlike the phc_ project token it is a real credential. Only the derived per-user HMAC crosses to the browser. Compliance: support free text is declared as its own data category (user.content.support) in .compliance/ropa.yaml and named on the privacy page. Analytics events still carry no message body (the breadcrumb sends only the subject); a ticket carries what the user wrote, because that is the point. Keeping the purposes separate is what stops the privacy page drifting the way the Recapt row did. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1dce9227a4 |
feat(assistant): make the thumbs actually report something (#1236)
* feat(assistant): make the thumbs actually report something The thumbs up/down under an assistant answer shipped wired to nothing. They lit up, the vote died in component state, and the code said so in a comment nobody reading the UI could see. An affordance that looks like it reports something and does not is worse than no affordance: it spends the user's goodwill once, and silently. They now post to a new /api/agent/feedback, which emits the SAME agent.feedback event the gnubok_feedback MCP tool emits, with actorType 'user'. The product team already queries event_log for that type, so chat votes land in the backlog they read rather than in a second place someone has to remember to look at. event_log takes the payload as jsonb and already treats agent.* as telemetry, so there is no migration. The conversation id is caller-supplied, so it gets the same ownership check /api/agent/invoke got: without it a member could file feedback against a colleague's thread and the backlog would carry conversations the reporter never saw. Mutation-checked, three tests fail when the guard is removed. The pressed state is set only after the server accepts the vote, so the button never claims a report that never arrived, and a vote does not toggle off: it is append-only telemetry, and offering an undo we cannot honour would be a control that lies. Changing your mind sends the other sentiment instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): drop the unused free-text field from the feedback route compliance-swarm flagged the `comment` field as an undocumented PII exposure: free text embedded verbatim into the agent.feedback payload and written to event_log under telemetry retention, with no data-classification decision, next to a PostHog policy that treats the same class of data differently. The finding is right, and the field was worse than it looked: no caller ever sent one. The UI posts sentiment and a turn index. So this is dead API surface whose only effect was to accept whatever a user might type, in an accounting product, into a 180-day log: client names, personnummer, case details. Removed rather than documented. A comment box is a reasonable thing to want, but it needs its own classification and redaction decision made with the UI in front of it, not inherited from an unused parameter. The test now asserts the property instead of the field's absence: a caller that posts a comment anyway must not get it stored anywhere in the payload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2da96c0be2 |
fix(agent): stop sending every page view to a third-party avatar CDN (#1226)
* fix(agent): stop sending every page view to a third-party avatar CDN Eight avatar SVGs were loaded from api.dicebear.com on every render. In an accounting product that meant every authenticated page view told a third party who was looking at it, from a domain we do not control, on the path of a logged-in surface. A firewalled or self-hosted install showed no faces at all. The SVGs are now generated once and served from public/agent-avatars. Each entry records the seed it came from, so the set can be regenerated reproducibly, and the command to do it is in the file. The licence question that made this look like a founder decision resolved itself on inspection: Notionists is by Zoish under CC0 1.0, public domain, no attribution required. Confirmed on dicebear.com/licenses and, more usefully, in each downloaded file's own RDF metadata, so the terms travel with the asset rather than living in a commit message. Tests pin the properties that matter rather than the file list: no entry may be a remote URL, every entry must have a file behind it, and no shipped SVG may carry an <image href>, a url(https://…), an xlink:href or a <script>, since self-hosting a file that then phones home would reintroduce exactly the request this removes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(agent): assert the property, not a list of elements, for avatar externals The external-reference check enumerated <image href>, url(https://…) and xlink:href, which left <use href>, <feImage href> and scheme-relative //host through: exactly the requests the guard claims to prevent, via elements it happened not to list. That is how this sort of allowlist rots. It now strips the parts that legitimately carry URLs and are never fetched (the RDF metadata block, xmlns declarations) and then asserts that NOTHING in what remains points off-origin. Verified by injecting each of the four bypasses into a real asset and confirming the test fails on all of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
248d98bd7e |
feat(analytics): remove Recapt, PostHog is now the only analytics (#1238)
* feat(analytics): remove Recapt, PostHog is now the only analytics
Recapt shuts down in days. Everything it did is covered by the PostHog
integration in the previous commit, so the SDK, its five modules and its
CSP hosts come out.
Deleted: RecaptLoader, RecaptHideWidget, RecaptIdentify, lib/recapt.ts,
types/recapt.d.ts. Unmounted from app/layout.tsx (the <script> in <head>
and the widget-hider) and from app/(dashboard)/layout.tsx. Both logout
handlers already call resetAnalyticsIdentity() and now only that.
The CSP gets strictly narrower: connect-src loses api.recapt.app and
cdn.recapt.app, script-src loses cdn.recapt.app, and nothing is added in
their place, because PostHog runs through the same-origin /rl rewrite.
Verified against the built routes-manifest.
Behaviour change worth calling out: lib/support/submit-feedback.ts is now
single-channel. Recapt used to accept the message through its own SDK, so
a failing /api/support/contact still reported success to the user. Email
is now the only delivery path and its failure is visible. That is the
right outcome, silently "succeeding" while the message reached nobody was
worse, and the Resend path is solid. A non-blocking
posthog.capture('support_feedback_submitted') keeps the useful half of
the old dual-channel behaviour by putting the submission on the user's
timeline next to the session replay; it carries no message body, since
free text is user content and would be PII in an event property. The six
Recapt-specific test cases are replaced with the email-only contract plus
coverage of the breadcrumb, the self-hosted skip, and a throwing SDK not
breaking delivery.
Compliance, which Recapt never had: the privacy page sub-processor row is
replaced (not just deleted) with an accurate PostHog row, and .compliance/
ropa.yaml gains a product.analytics activity. The old row also claimed
Recapt loaded "endast for inloggade anvandare", which was never true,
RecaptLoader sat in the root <head> on every page including logged-out
ones. The new row describes what actually happens.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(analytics): purge Recapt storage left on users' devices
Removing the Recapt <script> stops it writing anything new, but every
browser that already loaded the app keeps what it persisted. Observed on
production after #1237: localStorage still holds
`__recapt_record_engine`, and after this PR nothing would ever remove it,
because the helper that used to sweep on logout (lib/recapt.ts
clearRecaptIdentity) is deleted along with the SDK.
Inert data, but it is third-party storage from a processor the privacy
page now says we no longer use, and the whole point of the PostHog
config is that nothing is stored on the device. So clear it.
Matching is by substring rather than prefix on purpose: the old sweep
tested key.startsWith('recapt'), which never actually matched the real
key, since `__recapt_record_engine` starts with underscores. A test pins
that. The app's own keys (Accounted:chat-sidebar-collapsed,
gnubok.inbox.onboarding.dismissed) contain neither marker.
Runs unconditionally from instrumentation-client.ts, before the
analytics gate, so a browser gets cleaned even on a build where PostHog
is switched off. Iterates backwards because removeItem() re-indexes the
store and a forward loop would skip entries; both covered by tests, along
with private-mode throws and the server no-op.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
c62d00bcb3 |
feat(invoices): preview invoices and underlag in the browser instead of downloading (#1228)
Reviewing an invoice or a verifikat bilaga meant saving a file and opening it from the Downloads folder (user request, christian@odinaero.se 2026-07-25). - GET /api/invoices/[id]/pdf accepts ?disposition=inline and serves the PDF for in-browser review; anything else keeps the download behaviour every existing caller relies on. The filename still travels in the header, so the browser viewer's own save action produces the same name as the download button, and nosniff pins the content type. - The invoice detail page gets a "Förhandsgranska" action next to "Ladda ner PDF". It resolves the document through the same resolveInvoicePdfSource path as the download, so preview cannot become the shortcut that presents a re-render as the invoice the customer received: the archived delivery wins, a re-render is shown with its caveat, and an unreadable delivery history still asks instead of guessing. The archive dialog now remembers whether the user asked to view or to save, and its fallback does that. - DocumentViewButton (supplier-invoice underlag, staged agent previews) points at the existing /api/documents/:id/inline proxy, so bilagor render in the browser. Navigation now happens straight from the click, so the signed-URL fetch and its popup-blocker workaround are gone. - The three re-render caveat strings and the two archive-dialog descriptions lose their "you downloaded" wording so they stay true for both actions; five new keys in sv + en. Tests: route cases for the default, inline and unknown disposition values; invoiceRerenderUrl cases for both modes and id encoding. npm test 11364 passed, lint 0 errors. Button row screenshotted against the design system (pill outline, Eye icon) via a temporary sandbox route. Closes #1190 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d4f82cafc4 |
feat(analytics): add PostHog (EU) behind a same-origin proxy (#1237)
Recapt shuts down in four days, taking product analytics and session replay with it. This adds PostHog Cloud EU alongside it; the Recapt removal follows separately so events can be confirmed landing first. Wiring choices that are not the tutorial defaults: - Same-origin reverse proxy (/rl -> eu.i.posthog.com) instead of adding PostHog hosts to the CSP. connect-src 'self' and script-src 'self' already cover it, tracking blockers have no third-party host to match, and the Recapt allowlist entries in next.config.ts get replaced by nothing at all when they go. Needs skipTrailingSlashRedirect, since PostHog sends trailing-slash API requests; verified that trailing-slash URLs on normal routes still resolve 200 rather than 404. - /rl is excluded from the proxy.ts matcher. Middleware runs BEFORE next.config rewrites, so without this updateSession() treats an ingestion POST as an unknown protected path and 307s it to /login. Verified with a control: /zz/flags/ -> 307 /login, /rl/flags/ -> 200 from PostHog. This fails silently otherwise, because asset loads keep working through the rewrite while no events arrive. - persistence: 'memory' so nothing is written to the device and no cookie-consent banner is required. Everything post-login is unaffected: AnalyticsIdentify re-identifies on each dashboard load. - session_recording.maskTextSelector: '*'. PostHog masks inputs but not text by default, and this app renders org numbers (which for an enskild firma ARE the owner's personnummer), customer names and balances as ordinary text. Replays show where a user gets stuck, never what their books say. buildGroupProperties() also refuses to send org_number at all, with a test pinning it. - Error tracking registers through the existing lib/observability sink rather than bypassing it, so every error-level createLogger() line is captured already redacted. instrumentation.ts onRequestError covers what escapes uncaught. Analytics is hosted-only: isAnalyticsEnabled() short-circuits on NEXT_PUBLIC_SELF_HOSTED and no Docker sentinel is added, so self-hosted runs with zero third-party runtime code. Recapt got that outcome only by accident, via a missing sentinel; here it is explicit and tested. vitest.config.ts aliases 'server-only' to a stub: it is a build-time guard whose real entry point always throws, which broke 48 test files the moment a server-only module entered the graph. request-context.ts was already carrying the same latent trap. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4702a63cff |
fix(assistant): announce answers to screen readers, one label map, links that keep the thread (#1224)
* fix(assistant): announce answers to screen readers, one label map, links that keep the thread PR7 polish, three items from dev_docs/assistant_redesign_plan.md section 7. The chat had no live region at all. A screen-reader user got no signal that the assistant had answered: the reply simply appeared, for people who could see it. Announcement fires on turn boundaries rather than over the streaming text, because a live region on token deltas re-announces on every delta and makes the surface unusable; the finished answer is read once, capped, with a pointer to the message for the rest. Two intent-label maps had drifted. The panel opened on the bokslut wizard titled "Fråga Anna" while the same thread in the history list read "Hjälp med bokslut", and the list's fallback returned the intent id itself, putting "bokslut.step" in front of the user as the name of their own conversation. One map now, and an unknown intent can no longer fall through to its id. Links inside an answer were plain anchors, so following one did a full document load: the app rebooted and took the conversation with it, which is the opposite of what docking the panel was for. Internal links route client-side. External ones open in a new tab with rel="noopener noreferrer", since the href came out of a model that reads customer documents and target="_blank" without it hands the opened page a handle back into an authenticated session. Reduced motion needed nothing: globals.css already collapses every animation under prefers-reduced-motion, so per-class variants would be redundant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): review triage: scope the announcement to its own turn Six findings, all real. The announcement searched the whole thread, so a turn that produced no text of its own (tool-only, or an error) found the PREVIOUS answer and read it out as though it were new: a screen-reader user would hear a stale answer to a question that had just been asked. It now receives only the current turn's messages, bounded by an index captured when streaming starts. It also read an interrupted answer as a finished one. Stop leaves the partial text with a visible marker, so announcing it as the answer told a screen-reader user the opposite of what everyone else could see. messagesRef was assigned during render. React may replay a render, so the announcement could read a snapshot the user never saw; the write moved into an effect declared before the one that reads it. The 400-character cap applied to the preview only, so the appended continuation suffix pushed the real announcement past the limit the constant promised. The cap now covers the whole string, and the test asserts against the constant rather than a looser number the suffix could sneak past. INTENT_LABELS was a plain object literal, so intentLabel('toString') resolved Object.prototype.toString, passed the truthiness check and reached React as a conversation title. Null-prototype now. intent_id comes from the database. Markdown link titles were dropped: [text](url "title") carries a title that react-markdown passes through and the renderer ignored. Both new guards were mutation-checked: removing either makes its test fail. The turn-boundary index itself is component wiring, which this node-only unit project cannot exercise; announceableAnswer is tested against the slice it is given. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bcbe9b0903 |
feat(assistant): say what the conversation is anchored to (#1222)
* feat(assistant): say what the conversation is anchored to agent_conversations.context_ref has been written since the first intents shipped and read by nothing. The panel ignored it, so a thread resumed three days later showed the messages with no indication of which invoice or which bokslut it concerned, even though the row knew. /chat did worse: it printed the ref raw, so the subtitle under someone's own conversation read "invoice:5f3a-9c21-...", a database identifier shown to an accountant. Both surfaces now render the same chip, which names the thing and links to it. This matters more since the panel docks: sitting beside the page, "what is this about" is a question the surface should answer rather than the user's memory. The mapping is a data map in route-mapping.ts, not a switch in a component (plan seam 8.5), so a flow run's ref renders in both surfaces with no change to either. A ref it cannot read renders nothing rather than a broken chip. Two refs deliberately have no link. There is no /transactions/[id] route, so a transaction chip points at the list. The document inbox is an extension mounted under /e/[sector], and core must not hardcode a path that exists only when the extension is enabled, so that one is named without being linked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): review triage: make the colon test observable, drop an overclaim The colon-splitting test asserted on a kpi ref, and kpi discards its id, so it passed even with a parser that dropped everything after the second colon. Moved to invoice:abc:2026, where the id reaches the href. Verified by switching indexOf to lastIndexOf and confirming the test fails. ContextChip's comment said a flow run's ref renders with no change to either surface. It does not: an unknown kind maps to null and renders nothing until the map gains an entry. The seam is that adding one is a single entry in one file, which is what the comment now says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
60cc51fe21 |
feat(assistant): dock the panel into the frame, give the trigger a status channel (#1220)
* feat(assistant): dock the panel into the frame and give the trigger a status channel Two things the panel could not do. It covered the page it was talking about. Opening it on /invoices laid a 480px curtain over the invoice, so verifying an answer meant closing the thing that gave it. The page panel now gives up that width plus the frame's own gutter, and the two float side by side. Docking applies at the compact width only: expanded is a deliberate focus mode, where there is no page left to read anyway, so it goes back to overlaying. Driven by a --agent-dock-w custom property because the frame layout is a server component; globals.css seeds the default so the first paint is not a jump, and below md nothing changes. And a minimized session was silent. The agent could be three tool calls into a booking, or finished ten minutes ago, and the pill said "Fortsätt med Anna" either way, so the only way to find out was to reopen it. There is now one status channel: the trigger spins with the current step while work runs and shows an unread dot when a turn landed behind a hidden panel. The channel is a reducer in a React-free module rather than a pair of booleans, because a durable background run has to publish to the same one later. Its 'detached' state (working somewhere the user cannot see) is built and rendered now even though nothing dispatches it in v1, so adding runs is a publisher and not a redesign. Turn boundaries derive from the streaming flag rather than being published per call site: a turn can end by completing, erroring, aborting or being stopped, and missing one would leave the trigger claiming the agent is still working forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: record the Sonnet 5 ceiling, dock and status-channel decisions Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f3eacb436d |
Fix/articles (#1216)
* fix(security): gate replace_sie_import behind owner/admin membership The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no company_members lookup, no auth.uid() reference and no unauthorized raise, while setting gnubok.allow_delete to disarm the BFL immutability and retention triggers. Any caller holding a company_id and an import id could hard delete another tenant's verifikationer. Confirmed live in production. Applies the same fail closed owner/admin guard that undo_sie_import already carries (migration 20260624120000), resolving the actor from COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then revokes EXECUTE from PUBLIC and anon. search_path and the raised statement_timeout are restated, since CREATE OR REPLACE drops settings that are not repeated. userId is a required parameter on replaceSIEImport: the service client has a NULL auth.uid(), so a caller without an explicit actor now fails to compile rather than hitting the closed gate at runtime. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): validate arcim OAuth callback state server side The callback route is skipAuth and decoded the state parameter as plain base64url JSON, trusting consentId and provider from it. A one time code was minted at flow start and never read. An unauthenticated attacker who learned a consent id could run an OAuth flow on their own provider account and post the callback with a forged state, landing their tokens on another tenant's consent, so the victim's next migration imported the attacker's ledger. State is now an opaque randomBytes(32) pointer to a provider_otc row, consumed by a single atomic UPDATE guarded on used_at IS NULL and expires_at, so a replay loses the row lock race and updates nothing. provider is read from provider_consents rather than trusted from the client. provider_otc already existed for exactly this purpose and was never wired up. Also scopes getConsent to an owning company, closing a cross tenant status oracle where the preview and migrate paths echoed a consent's status before the scoped check ran. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): scope documents storage to company_id (phase A) The documents bucket policies matched on auth.uid(), and upload keys were documents/{userId}/..., so company membership was never consulted. Removing a member revoked nothing: their session still authenticated and they kept direct Storage read access to every receipt, supplier invoice and bank statement they had uploaded. The same bug was fixed for sie-files in 20260416120000; this bucket was left behind. Phase A is additive. Company scoped policies are added alongside the uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and reads accept either layout so nothing breaks mid migration. Phase C, which drops the old policies, is gated on the backfill reporting zero remaining legacy prefix objects. The policy compares the company segment as text rather than casting to uuid the way sie-files does: this bucket holds keys whose second segment is not a uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix qual runs before the cast, so a planner reordering would raise 22P02 and fail the whole query instead of filtering the row out. deleteDocument now removes both candidate keys. Removing only the stored pointer would leave a readable orphan copy of a document the user asked to erase. The backfill script is included but has never been run. It defaults to dry run, refuses .env.local by name, and verifies each copy is readable and SHA-256 identical before repointing the row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): enforce events:read scope and membership on /api/events This was the only one of the three validateApiKey call sites with no downstream guard: v1 and the MCP server both check scope and re-verify company membership, this route did neither. An events:read scope existed and was documented as gating the endpoint but was never called, so a legacy key falling back to DEFAULT_SCOPES read the full log. The bound company id went straight from the api_keys row into a service role query, so a key whose user had been removed from the company kept reading. Adds the scope check before any database access, re-verifies company_members with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead of ignoring it, applies minimisePayload so the pull surface can never return a wider payload than the push surface, and replaces the three flat error strings with the canonical envelope. Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is gated on mutations in with-api-v1, so a read gets the same treatment as every other v1 read endpoint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(bookkeeping): sweep remaining journal_entries!inner embeds A previous refactor removed this pattern from lib/reports and introduced fetchEntryLines, but the class was never swept. Seventeen sites remained and had become the top application consumer of production database time: measured across the resulting query shapes, 32,694 calls and 25,848 seconds of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at 7,962ms against the 8s statement_timeout, which surfaced to users as 500s on the booking path. PostgREST compiles an embed with filters on the embedded side into a correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops Postgres reordering the join, so each query walked the whole journal_entry_lines table across all tenants. Driving from the entries side instead turns that into two indexed round trips. Converted sites keep their existing shape: the helper reattaches the parent entry under the same key the embed produced. Several conversions also remove a latent silent truncation where an unpaginated query was capped at PostgREST's 1000 row ceiling. Two deliberate exceptions. The free text ilike legs of the MCP display query stay on the embed, because each is capped at legLimit and that cap drives the truncation contract the tool reports, while the helper is unbounded. The accounts route moves to the existing get_account_usage_counts RPC instead, since its embed was a head count and the helper returns rows. commitEntry's write path is untouched: the change there is confined to the read query of the pre-commit dimension rule check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): anchor v1 list cursors on created_at Page two returned page one, forever, while still advertising a fresh next_cursor. The three routes sorted by and encoded a Postgres date column, which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor timestamp as full ISO-8601 and returned null, so the keyset filter was never applied and has_more never went false. An integrator syncing verifikat looped on the newest rows indefinitely. The transactions route already solved this and its comment names the trap; the fix was never ported. All three now order and encode on created_at with an id tie break, matching the transactions keyset predicate exactly. ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change sort semantics on the route that currently works. Default ordering therefore moves from business date to insert order. Every business date is still on the row, and the invoices list gains date_from and date_to filters so a date range is still reachable; the other two already had them. The tests use an in-memory PostgREST that actually evaluates the filters, because the repo's pass-through mock cannot catch this class of bug: the bug is that the filter is never sent. They walk to exhaustion with a hard iteration cap, so an unterminated walk fails instead of hanging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): separate dry run from commit in the idempotency hash The request hash was built from url.pathname, which excludes the query string, so a dry run and its commit hashed identically. Following the flow documented in dry-run.ts, re-issuing the request with the same Idempotency-Key returned the cached preview with Idempotent-Replayed set and wrote nothing, while reporting 200. An agent or integrator saw success for a write that never happened. dry_run is folded into the hash only when true, not as an unconditional boolean. Including it as false would change the hash of every ordinary write, and with a 24h idempotency TTL any key in flight across the deploy would fail the request_hash comparison and 409 on a legitimate retry. Both hash call sites now go through one shared helper so they cannot drift into a permanent cache miss, and dry run responses are no longer stored at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: install the Bedrock SDK out of tree in the compliance review The Swedish accounting compliance gate had failed ten consecutive runs and so was posting nothing. With --no-package-lock npm discarded the lockfile and re-resolved the whole tree from package.json, floating @hookform/resolvers to 5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0. Installing into the parent of the checkout resolves only that one package, so an unrelated peer conflict can never take the gate down again. Node still finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH would not have worked, as it is CommonJS only. --legacy-peer-deps was rejected because it masks future genuine peer conflicts and still reifies the full tree. The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that package.json and check:guards enforce after the streaming outage. That drift went unnoticed because the pin guard only inspects package.json and the lockfile, never workflow files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * build(docker): generate crontabs from vercel.json vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were byte identical to each other. Self hosted deployments therefore never sent recurring invoices, never dispatched webhooks and never cleaned up idempotency keys. tax-deadlines also ran once a year on 2 January instead of daily, and documents/verify weekly instead of daily. Extension crons are included rather than excluded. The Dockerfile copies the whole tree before building, so every extension cron route is compiled into the image regardless of the enabled preset, and each returns 200 when its extension is unconfigured, so curl -sf logs no failure. Two such entries were already present in the crontab for extensions absent from the preset, which settles the intent. documents/verify is treated as drift rather than a self hosted concession: the weekly cadence was present in the hosted crontab too, and the run is capped at 200 documents walking a nulls-first queue, so weekly drains the integrity queue seven times slower on a check that exists for BFL retention. webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day on self hosted. A gentler tick would silently stretch the first retry, since the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line place to change that. A parity test asserts the path sets match minus a documented exclusion list, and ratchets three cron routes that are currently scheduled nowhere so they are named rather than silently rotting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(observability): add a provider agnostic error sink There is no error tracking in this codebase: logs go to console and Vercel retention and nowhere else, nothing alerts on the 16 cron jobs, and seven code comments across lib, app, components and extensions asserted that Sentry captures errors when Sentry is not a dependency. The two most recent bug fixes on this repo were both discovered by customer email. This adds the sink, not a vendor. No dependency is taken: the interface has a no-op default and a registration point, so behaviour is unchanged until an adapter is registered. Releases are tagged from the build id already inlined by next.config.ts. Redaction moved out of lib/logger.ts into a leaf module that both the logger and the sink import, so there is one denylist and no path from application data to a third party can skip the personnummer regex, including direct sink calls that bypass the logger. That matters here because these logs carry personnummer and financial data. verifyCronSecret now reports its own 401s, which covers all 16 jobs without touching a route file and catches the case where CRON_SECRET is rotated without updating the scheduler and every job silently 401s forever. The threshold is one failure rather than the backup alert's three: suppressing the first occurrence is precisely how an outage stays invisible. The seven misleading comments are corrected to describe what the code actually does, including the two cases that still are not covered: the client side one, since the sink is server side, and a warn level call that is not forwarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: remediate the 2026-07-26 similar-sweep findings across all surfaces Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with one agent per finding; every behavioural fix carries a regression test proven to fail at HEAD. Full status, corrections to the sweep, refusals and open decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md. Structural roots closed: - resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking 1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING - ledger-line-amount.ts: journal_entry_lines.currency labels the document, not the amount; SQL pre-filter decoy proven and fixed - sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the exploitable salary payslip-line PATCH and KPI preferences sinks fixed - tests/schema: migration-replay phantom-column guard (13k+ refs, closed CHECK sets, onConflict targets); found 28 real defects, all fixed, all four baselines now empty - three new ratchet guards: sek-labelled-amount, cross-extension-import, ungated-extension-route Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap), RC input VAT mismatch wired on web + both MCP callers, missing-underlag resource delegates to the shared RPC predicate, push-notifications consent polarity fail-closed, deadlines undo honours requested state, silent-failure and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/ Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites with isSwedishUserMessage extended. Also includes the parallel session's MCP invoice tools (update_invoice, recurring schedules, invoice deliveries) which share files with the sweep work and are verified green together. 13 new migrations are NOT applied anywhere; they apply via branch merge. 20260726120000 backfills 1247 supplier-invoice rows. pg tests for new DDL are written but unrun (no local Postgres). Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0 errors, check:guards passing, MCP payload 57475/57500. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): rename replace_sie_import migration off main's 20260726090000 version origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping our replace_sie_import migration on the same version would abort the Supabase apply with a schema_migrations_pkey duplicate at merge time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): remediate pre-publish deep-review findings across all slices A 13-agent review of the full branch diff surfaced 1 critical, 5 high and ~45 further findings; this commit resolves them in one pass: - replace_sie_import / undo_sie_import: p_user_id honored only for service_role callers; any other caller is pinned to auth.uid() (impersonation gate bypass), authz raise errcode 42501 mapped to a Swedish 403 in the route, new caller-guard migration for undo - bulk_book_transactions refuses homogeneous non-SEK batches instead of writing foreign magnitudes into SEK ledger columns - credit-note cap trigger: company-match on credited_invoice_id, no cross-tenant figures in exception text - link_voucher RPCs resolve NULL invoice currency as SEK end to end - personal-number ciphertext CHECK split into NOT VALID + VALIDATE - same-currency foreign settlements clear 1510 at booking rate and book realized diff to 3960/7960; rate-less foreign write paths refuse - receivables revaluation covers partially_paid and outstanding amounts - period lock guard paginates candidates past the PostgREST 1000 cap - documents: service-client storage removals after authz, dual-layout reads in integrity cron and archive export, backfill delete-source sweep actually deletes with hash verification and shared-key grouping - invoice matching normalizes NULL/lowercase currencies (regression), duplicate candidates stop claiming amount matches they never ran - match-invoice aborts on any booking failure (no paid-without-verifikat) - refresh-exchange-rate reverts on concurrent booking (TOCTOU window) - KPI preferences upsert arbiter aligned to the company-scoped constraint - personnummer_last4 stripped from all salary responses incl. MCP tools - worked-hours batch restores destroyed rows on conflict and error paths - MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit on tag_journal_lines overflow, auto_send schedules stage as high risk - observability sink redacts emails/IBANs/API keys and keeps redacted stacks in prod; assorted small guards (safe-return-to /@, dry_run=True, cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings call removed) Full dispositions, deferred items and hand-verified accounting numbers are documented in the PR body and DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(personnummer): implement masking and encryption for personal numbers with tests * fix(review): address CI and compliance-bot findings for PR #1215 pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role GUC, so both service-role simulations (runAsServiceRole and the invoice-delivery test's local helper) never satisfied auth.role() = 'service_role' and every legitimate p_user_id path failed closed; the shared helper now sets both GUC shapes plus SET LOCAL ROLE with a fail-loud sanity check, and the delivery test reuses it. The link-voucher migration had recreated both RPCs from pre-rewrite file text, reintroducing the NULL-unsafe membership pattern the null-safe-tenant-guards ratchet bans; both guards now use public.caller_is_company_member() with all currency changes preserved. Compliance bots: the customers export now emits the standard masked form instead of raw AES-256-GCM ciphertext in the Org-/personnummer column, and maskCustomerRow returns a non-round-trippable placeholder on decrypt failure instead of 500ing the list. MCP parity: gnubok_lock_period's staging pre-check now runs the exact countUnbookedInPeriod the commit path enforces (exported from period-service; local mirror deleted), and gnubok_agi_status resolves AGI state run-scoped so a correction run no longer renders as already filed. Declined with evidence: PR-Agent's opening-balances null-zeroing concern (all mergeable columns are NOT NULL with defaults per 20260713101000). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): address codex review findings on PR #1215 - restore 20260726140000 to its preview-recorded content and restate the NULL-safe tenant guard under 20260727130000: a recorded migration version never re-runs, so the in-place edit could not reach the preview branch - replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap warning texts and update the pinned test expectations - drop the em dash in the fiscal-periods route comment - strip trailing whitespace in import-existing.test.ts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(reports): raise timeout on real PDF render tests renderToBuffer does real @react-pdf layout work and exceeds the 5s default when the full suite saturates the CPU; tests pass in isolation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): remediate the 2026-07-27 compliance and security review findings - ROT/RUT deduction base is arbetskostnaden INKLUSIVE moms (HUSFL 2009:194 6-9 par.): computeDeduction takes the line vat_rate, all five call sites pass it, and tests pin Skatteverkets worked example (18 000 kr excl = 22 500 incl, ROT 6 750). - Momsdeklaration: new SALES_OUTPUT_VAT_SHORTFALL warning catches output VAT short of the reported sales base (one-directional, never filing-blocking). - SIE import: #RAR records validated for every year index (dates, ordering, 18-month BFL cap as warn-and-keep). - build-invoice-write: SEK invoices populate the *_sek twin columns (rate 1) so both creation paths produce the same row shape. - CI: daily Trivy SCA scan of the npm lockfile (replaces removed Dependabot); compliance review fails loudly on empty review.md. - arcim migration FX logging routed through the redacting structured logger. - docs/security/: authorization policy for the SIE bulk-delete RPC pair and the observability redaction contract. - Rewrote the swedish-payroll ob-overtime reference (was a byte-identical copy of sick-pay.md); skills:generate emitted the atom-body seed migration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f24b26a139 |
fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)
* fix(security): gate replace_sie_import behind owner/admin membership The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no company_members lookup, no auth.uid() reference and no unauthorized raise, while setting gnubok.allow_delete to disarm the BFL immutability and retention triggers. Any caller holding a company_id and an import id could hard delete another tenant's verifikationer. Confirmed live in production. Applies the same fail closed owner/admin guard that undo_sie_import already carries (migration 20260624120000), resolving the actor from COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then revokes EXECUTE from PUBLIC and anon. search_path and the raised statement_timeout are restated, since CREATE OR REPLACE drops settings that are not repeated. userId is a required parameter on replaceSIEImport: the service client has a NULL auth.uid(), so a caller without an explicit actor now fails to compile rather than hitting the closed gate at runtime. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): validate arcim OAuth callback state server side The callback route is skipAuth and decoded the state parameter as plain base64url JSON, trusting consentId and provider from it. A one time code was minted at flow start and never read. An unauthenticated attacker who learned a consent id could run an OAuth flow on their own provider account and post the callback with a forged state, landing their tokens on another tenant's consent, so the victim's next migration imported the attacker's ledger. State is now an opaque randomBytes(32) pointer to a provider_otc row, consumed by a single atomic UPDATE guarded on used_at IS NULL and expires_at, so a replay loses the row lock race and updates nothing. provider is read from provider_consents rather than trusted from the client. provider_otc already existed for exactly this purpose and was never wired up. Also scopes getConsent to an owning company, closing a cross tenant status oracle where the preview and migrate paths echoed a consent's status before the scoped check ran. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): scope documents storage to company_id (phase A) The documents bucket policies matched on auth.uid(), and upload keys were documents/{userId}/..., so company membership was never consulted. Removing a member revoked nothing: their session still authenticated and they kept direct Storage read access to every receipt, supplier invoice and bank statement they had uploaded. The same bug was fixed for sie-files in 20260416120000; this bucket was left behind. Phase A is additive. Company scoped policies are added alongside the uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and reads accept either layout so nothing breaks mid migration. Phase C, which drops the old policies, is gated on the backfill reporting zero remaining legacy prefix objects. The policy compares the company segment as text rather than casting to uuid the way sie-files does: this bucket holds keys whose second segment is not a uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix qual runs before the cast, so a planner reordering would raise 22P02 and fail the whole query instead of filtering the row out. deleteDocument now removes both candidate keys. Removing only the stored pointer would leave a readable orphan copy of a document the user asked to erase. The backfill script is included but has never been run. It defaults to dry run, refuses .env.local by name, and verifies each copy is readable and SHA-256 identical before repointing the row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): enforce events:read scope and membership on /api/events This was the only one of the three validateApiKey call sites with no downstream guard: v1 and the MCP server both check scope and re-verify company membership, this route did neither. An events:read scope existed and was documented as gating the endpoint but was never called, so a legacy key falling back to DEFAULT_SCOPES read the full log. The bound company id went straight from the api_keys row into a service role query, so a key whose user had been removed from the company kept reading. Adds the scope check before any database access, re-verifies company_members with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead of ignoring it, applies minimisePayload so the pull surface can never return a wider payload than the push surface, and replaces the three flat error strings with the canonical envelope. Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is gated on mutations in with-api-v1, so a read gets the same treatment as every other v1 read endpoint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(bookkeeping): sweep remaining journal_entries!inner embeds A previous refactor removed this pattern from lib/reports and introduced fetchEntryLines, but the class was never swept. Seventeen sites remained and had become the top application consumer of production database time: measured across the resulting query shapes, 32,694 calls and 25,848 seconds of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at 7,962ms against the 8s statement_timeout, which surfaced to users as 500s on the booking path. PostgREST compiles an embed with filters on the embedded side into a correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops Postgres reordering the join, so each query walked the whole journal_entry_lines table across all tenants. Driving from the entries side instead turns that into two indexed round trips. Converted sites keep their existing shape: the helper reattaches the parent entry under the same key the embed produced. Several conversions also remove a latent silent truncation where an unpaginated query was capped at PostgREST's 1000 row ceiling. Two deliberate exceptions. The free text ilike legs of the MCP display query stay on the embed, because each is capped at legLimit and that cap drives the truncation contract the tool reports, while the helper is unbounded. The accounts route moves to the existing get_account_usage_counts RPC instead, since its embed was a head count and the helper returns rows. commitEntry's write path is untouched: the change there is confined to the read query of the pre-commit dimension rule check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): anchor v1 list cursors on created_at Page two returned page one, forever, while still advertising a fresh next_cursor. The three routes sorted by and encoded a Postgres date column, which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor timestamp as full ISO-8601 and returned null, so the keyset filter was never applied and has_more never went false. An integrator syncing verifikat looped on the newest rows indefinitely. The transactions route already solved this and its comment names the trap; the fix was never ported. All three now order and encode on created_at with an id tie break, matching the transactions keyset predicate exactly. ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change sort semantics on the route that currently works. Default ordering therefore moves from business date to insert order. Every business date is still on the row, and the invoices list gains date_from and date_to filters so a date range is still reachable; the other two already had them. The tests use an in-memory PostgREST that actually evaluates the filters, because the repo's pass-through mock cannot catch this class of bug: the bug is that the filter is never sent. They walk to exhaustion with a hard iteration cap, so an unterminated walk fails instead of hanging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): separate dry run from commit in the idempotency hash The request hash was built from url.pathname, which excludes the query string, so a dry run and its commit hashed identically. Following the flow documented in dry-run.ts, re-issuing the request with the same Idempotency-Key returned the cached preview with Idempotent-Replayed set and wrote nothing, while reporting 200. An agent or integrator saw success for a write that never happened. dry_run is folded into the hash only when true, not as an unconditional boolean. Including it as false would change the hash of every ordinary write, and with a 24h idempotency TTL any key in flight across the deploy would fail the request_hash comparison and 409 on a legitimate retry. Both hash call sites now go through one shared helper so they cannot drift into a permanent cache miss, and dry run responses are no longer stored at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: install the Bedrock SDK out of tree in the compliance review The Swedish accounting compliance gate had failed ten consecutive runs and so was posting nothing. With --no-package-lock npm discarded the lockfile and re-resolved the whole tree from package.json, floating @hookform/resolvers to 5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0. Installing into the parent of the checkout resolves only that one package, so an unrelated peer conflict can never take the gate down again. Node still finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH would not have worked, as it is CommonJS only. --legacy-peer-deps was rejected because it masks future genuine peer conflicts and still reifies the full tree. The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that package.json and check:guards enforce after the streaming outage. That drift went unnoticed because the pin guard only inspects package.json and the lockfile, never workflow files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * build(docker): generate crontabs from vercel.json vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were byte identical to each other. Self hosted deployments therefore never sent recurring invoices, never dispatched webhooks and never cleaned up idempotency keys. tax-deadlines also ran once a year on 2 January instead of daily, and documents/verify weekly instead of daily. Extension crons are included rather than excluded. The Dockerfile copies the whole tree before building, so every extension cron route is compiled into the image regardless of the enabled preset, and each returns 200 when its extension is unconfigured, so curl -sf logs no failure. Two such entries were already present in the crontab for extensions absent from the preset, which settles the intent. documents/verify is treated as drift rather than a self hosted concession: the weekly cadence was present in the hosted crontab too, and the run is capped at 200 documents walking a nulls-first queue, so weekly drains the integrity queue seven times slower on a check that exists for BFL retention. webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day on self hosted. A gentler tick would silently stretch the first retry, since the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line place to change that. A parity test asserts the path sets match minus a documented exclusion list, and ratchets three cron routes that are currently scheduled nowhere so they are named rather than silently rotting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(observability): add a provider agnostic error sink There is no error tracking in this codebase: logs go to console and Vercel retention and nowhere else, nothing alerts on the 16 cron jobs, and seven code comments across lib, app, components and extensions asserted that Sentry captures errors when Sentry is not a dependency. The two most recent bug fixes on this repo were both discovered by customer email. This adds the sink, not a vendor. No dependency is taken: the interface has a no-op default and a registration point, so behaviour is unchanged until an adapter is registered. Releases are tagged from the build id already inlined by next.config.ts. Redaction moved out of lib/logger.ts into a leaf module that both the logger and the sink import, so there is one denylist and no path from application data to a third party can skip the personnummer regex, including direct sink calls that bypass the logger. That matters here because these logs carry personnummer and financial data. verifyCronSecret now reports its own 401s, which covers all 16 jobs without touching a route file and catches the case where CRON_SECRET is rotated without updating the scheduler and every job silently 401s forever. The threshold is one failure rather than the backup alert's three: suppressing the first occurrence is precisely how an outage stays invisible. The seven misleading comments are corrected to describe what the code actually does, including the two cases that still are not covered: the client side one, since the sink is server side, and a warn level call that is not forwarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: remediate the 2026-07-26 similar-sweep findings across all surfaces Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with one agent per finding; every behavioural fix carries a regression test proven to fail at HEAD. Full status, corrections to the sweep, refusals and open decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md. Structural roots closed: - resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking 1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING - ledger-line-amount.ts: journal_entry_lines.currency labels the document, not the amount; SQL pre-filter decoy proven and fixed - sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the exploitable salary payslip-line PATCH and KPI preferences sinks fixed - tests/schema: migration-replay phantom-column guard (13k+ refs, closed CHECK sets, onConflict targets); found 28 real defects, all fixed, all four baselines now empty - three new ratchet guards: sek-labelled-amount, cross-extension-import, ungated-extension-route Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap), RC input VAT mismatch wired on web + both MCP callers, missing-underlag resource delegates to the shared RPC predicate, push-notifications consent polarity fail-closed, deadlines undo honours requested state, silent-failure and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/ Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites with isSwedishUserMessage extended. Also includes the parallel session's MCP invoice tools (update_invoice, recurring schedules, invoice deliveries) which share files with the sweep work and are verified green together. 13 new migrations are NOT applied anywhere; they apply via branch merge. 20260726120000 backfills 1247 supplier-invoice rows. pg tests for new DDL are written but unrun (no local Postgres). Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0 errors, check:guards passing, MCP payload 57475/57500. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): rename replace_sie_import migration off main's 20260726090000 version origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping our replace_sie_import migration on the same version would abort the Supabase apply with a schema_migrations_pkey duplicate at merge time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): remediate pre-publish deep-review findings across all slices A 13-agent review of the full branch diff surfaced 1 critical, 5 high and ~45 further findings; this commit resolves them in one pass: - replace_sie_import / undo_sie_import: p_user_id honored only for service_role callers; any other caller is pinned to auth.uid() (impersonation gate bypass), authz raise errcode 42501 mapped to a Swedish 403 in the route, new caller-guard migration for undo - bulk_book_transactions refuses homogeneous non-SEK batches instead of writing foreign magnitudes into SEK ledger columns - credit-note cap trigger: company-match on credited_invoice_id, no cross-tenant figures in exception text - link_voucher RPCs resolve NULL invoice currency as SEK end to end - personal-number ciphertext CHECK split into NOT VALID + VALIDATE - same-currency foreign settlements clear 1510 at booking rate and book realized diff to 3960/7960; rate-less foreign write paths refuse - receivables revaluation covers partially_paid and outstanding amounts - period lock guard paginates candidates past the PostgREST 1000 cap - documents: service-client storage removals after authz, dual-layout reads in integrity cron and archive export, backfill delete-source sweep actually deletes with hash verification and shared-key grouping - invoice matching normalizes NULL/lowercase currencies (regression), duplicate candidates stop claiming amount matches they never ran - match-invoice aborts on any booking failure (no paid-without-verifikat) - refresh-exchange-rate reverts on concurrent booking (TOCTOU window) - KPI preferences upsert arbiter aligned to the company-scoped constraint - personnummer_last4 stripped from all salary responses incl. MCP tools - worked-hours batch restores destroyed rows on conflict and error paths - MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit on tag_journal_lines overflow, auto_send schedules stage as high risk - observability sink redacts emails/IBANs/API keys and keeps redacted stacks in prod; assorted small guards (safe-return-to /@, dry_run=True, cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings call removed) Full dispositions, deferred items and hand-verified accounting numbers are documented in the PR body and DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(personnummer): implement masking and encryption for personal numbers with tests * fix(review): address CI and compliance-bot findings for PR #1215 pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role GUC, so both service-role simulations (runAsServiceRole and the invoice-delivery test's local helper) never satisfied auth.role() = 'service_role' and every legitimate p_user_id path failed closed; the shared helper now sets both GUC shapes plus SET LOCAL ROLE with a fail-loud sanity check, and the delivery test reuses it. The link-voucher migration had recreated both RPCs from pre-rewrite file text, reintroducing the NULL-unsafe membership pattern the null-safe-tenant-guards ratchet bans; both guards now use public.caller_is_company_member() with all currency changes preserved. Compliance bots: the customers export now emits the standard masked form instead of raw AES-256-GCM ciphertext in the Org-/personnummer column, and maskCustomerRow returns a non-round-trippable placeholder on decrypt failure instead of 500ing the list. MCP parity: gnubok_lock_period's staging pre-check now runs the exact countUnbookedInPeriod the commit path enforces (exported from period-service; local mirror deleted), and gnubok_agi_status resolves AGI state run-scoped so a correction run no longer renders as already filed. Declined with evidence: PR-Agent's opening-balances null-zeroing concern (all mergeable columns are NOT NULL with defaults per 20260713101000). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): address codex review findings on PR #1215 - restore 20260726140000 to its preview-recorded content and restate the NULL-safe tenant guard under 20260727130000: a recorded migration version never re-runs, so the in-place edit could not reach the preview branch - replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap warning texts and update the pinned test expectations - drop the em dash in the fiscal-periods route comment - strip trailing whitespace in import-existing.test.ts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(reports): raise timeout on real PDF render tests renderToBuffer does real @react-pdf layout work and exceeds the 5s default when the full suite saturates the CPU; tests pass in isolation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a43a8b03cf |
refactor(assistant): one source of truth for the conversation list (#1214)
* refactor(assistant): one source of truth for the conversation list PR5 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7): unified history. The two surfaces that list conversations had drifted apart in BEHAVIOUR, not just chrome. The in-sheet list rolled a failed rename back and said so; the /chat sidebar fired pin, archive and rename blind, with no res.ok check, no rollback and no message. A failed archive there removed a conversation from the list while it still existed on the server, and a failed rename displayed a title the server never saved, both until the next reload, with an unhandled promise rejection on a network error. State, search, grouping and all three mutations now live in one hook that both surfaces consume, so they cannot diverge again: every write is optimistic, reverts to the value captured before the write on failure, and reports it. The sheet gains pin and archive, which it never had. Archive resolves whether the row is really gone, so the sidebar only navigates away from a conversation that was actually archived. Chrome deliberately stays per-surface: a 320px sidebar that collapses to a rail and a sheet panel are different shapes, and merging the markup belongs with the shell work in PR6, where both containers change anyway. Verified: 9554 unit tests pass (5 new pinning the rollback semantics, including reverting to the original pin value rather than toggling and restoring a null title), lint and tsc clean on the touched files, guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): test the real mutation code, and make rollback mutation-aware Review follow-ups on the unified conversation list. Both findings were right. The tests duplicated the state transforms and called fetch directly, so they never executed the hook: deleting the rollback entirely would have left them green. That is test theater. The transforms and the write coordinator now live in conversation-mutations.ts, React-free, and the tests exercise those. Checked by deleting the rollback and confirming three tests fail. Rollback was not mutation-aware. A failed archive restored a render-time snapshot of the whole list, discarding any pin, rename or archive made while the request was in flight; and a failing earlier write could roll back over a newer value for the same row (a double-click on pin). Writes now claim a per-row revision and only undo while they are still the latest for that row, and a failed archive re-inserts the single row into the list AS IT STANDS, at its server-sort position, rather than replacing the list. Also drops a ref read during render that the React lint rules reject. Verified: 9560 unit tests pass (11 covering the real coordinator, including the overlapping-write case and the concurrent-edit-survives-archive-failure case), lint clean, tsc clean, guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(assistant): unstub globals so the fetch stub cannot outlive the file vi.restoreAllMocks does not undo vi.stubGlobal, and the config sets no unstubGlobals, so the stubbed fetch survived past the suite that set it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8397452440 |
feat(assistant): copy, feedback, an interrupt marker and a way back to the latest answer (#1213)
* feat(assistant): copy, feedback, an interrupt marker and a way back to the latest answer PR4 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7): message anatomy and actions. Assistant turns get a hover action row: copy, thumbs up/down and the existing regenerate, which until now was the only affordance on an answer. gnubok_feedback exists as a tool with no UI at all, so the thumbs are local-only for the moment; the point of this row is that the affordances sit where people look for them, and wiring the vote through is a follow-up that cannot break reading an answer. Stop used to abort and leave the half-written answer looking finished, which is worst exactly when it stopped mid-figure. The partial text still stays, now with a marker saying it was interrupted. A failed send cleared the composer and left a user bubble that had never reached the server, so the question vanished on the next reload and had to be retyped. The text now goes back into the composer and the unsent bubble is dropped, so the screen matches what was actually sent. startTurn reports whether the request got out; a mid-stream failure still counts as sent and keeps its content. Autoscroll already respected a user who had scrolled up, but nothing told them an answer had landed below the fold. A "Nytt svar" pill now appears in that case and takes them back. Verified: 9549 unit tests pass (7 new pinning the stop and failed-send state rules), lint and tsc clean on the touched file, guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): anchor the jump pill to the message area, not the whole panel Self-review before merge: the pill sat at a fixed offset from the bottom of the component, but the composer below it grows to 128px as the user types. A long multi-line draft plus a scrolled-up reader would have slid the pill underneath the composer, exactly when it is needed. It now positions against the message area itself, so composer height is irrelevant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4de648fb5d |
fix(assistant): keep proposals, selections and picks intact across a resume (#1212)
* fix(assistant): keep proposals, selections and picks intact across a resume PR3 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7): resume fidelity. Four ways the chat lost state that the user had every reason to think was still there. Approval cards ride on streamed staged_operation events, which are never persisted, so reopening a conversation rendered the tool trace and the answer but silently dropped the card. The proposal then sat in Granskning for its full 30-day expiry with nothing in the thread pointing at it. run-turn already stamps agent_metadata.conversation_id on every staged row, so both resume paths (the sheet's history and the /chat page) now re-attach the still-pending ones to the last assistant turn. Regenerate abandoned whatever the discarded turn had staged: the card left the screen, the operation stayed pending, and the regenerated turn usually staged a second proposal for the same booking, leaving two live proposals for one action. It now withdraws them through the same reject path the Avslå button uses, so the audit trail records why they went away. The sheet's remount key ignored intentArgs while some callers pass a CONSTANT contextRef with varying args: bulk-book always uses 'inbox:bulk' and carries the selected ids. Selecting A+B, collapsing, then selecting C+D reopened the A+B conversation while the user believed C+D were being booked. The key now includes a stable serialization of the args. Picking conversation A (slow) then B (fast) let A's late response overwrite B, leaving the user typing into a thread they did not choose. A sequence token now means only the newest pick may write state. Verified: 9540 unit tests pass (11 new), lint and tsc clean on every touched file, guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: record the resume-fidelity decisions Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): render hydrated proposals with the same preview as live ones pending_operations.operation_type stores the bare action name ('categorize_transaction'), while the streamed card carries the MCP tool name ('gnubok_categorize_transaction') and ApprovalCard's PreviewBlock dispatches on that. Hydrated cards therefore fell through to the flat generic preview instead of the journal-line one, so a resumed proposal looked materially worse than the same proposal did live: the opposite of what this PR is for. Found by checking the query against prod rather than trusting the mock, which is also how the stored value space was confirmed: categorize_transaction, create_voucher and approve_supplier_invoice are what exist in the wild, and the four operation types that have a specialized renderer all stage unprefixed. The test fixture now uses the real stored shape so the mapping is actually covered rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): await proposal withdrawals, surface staged-query errors, share the type Review follow-ups on the resume-fidelity batch. All four findings were valid. The withdrawals were fire-and-forget and raced the replacement turn, so the new turn could stage a second proposal before the old one was rejected: the exact double-staging this change exists to prevent. They are now awaited, a 409 counts as withdrawn (someone else resolved it, which is all we need), and if any withdrawal genuinely fails the turn stays on screen with an error rather than hiding a card whose operation is still pending. Both staged-operation loaders ignored their error result, so a database or policy failure rendered the conversation as successful with the proposals silently missing: again the failure this query exists to prevent, reintroduced through the error path. Both now propagate, matching the sibling message query. StoredStagedOperation now lives in @/types: it is a persisted API contract that crosses a server page and three components, not an AgentChat detail. The unserializable-args fallback used a timestamp, which collides for two objects created in the same millisecond and changes on every render tick for the same object, remounting the sheet mid-session. A WeakMap gives each object one stable id for its lifetime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f0f3050f54 |
fix(assistant): stop the chat loading in stages (#1210)
* fix(assistant): stop the chat loading in stages PR2 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7). No redesign; this is the "it loads in different stages" complaint, traced to four separate staging points and one dead link. Resumed conversations rendered a column of EMPTY bordered cards until the markdown chunk arrived, then filled in all at once and reflowed the thread. The chunk was deferred with a null fallback, which is invisible while a reply streams (nobody reads that fast) but very visible on hydrate, where every assistant bubble is already text. The chunk is now prefetched as soon as any chat surface mounts, and until it resolves the raw text renders instead of nothing, so a bubble is never blank. Clicking the assistant launcher showed NOTHING until the sheet chunk loaded: the dynamic import had no loading state at all. It now renders a skeleton in the same geometry, and the chunk is warmed on idle so the click usually hits an already-loaded module. /chat's route skeleton drew a 320px sidebar while ChatSidebar mounts collapsed as a 48px rail, so every load snapped one to the other. The skeleton now matches what actually mounts, per breakpoint. The first turn read agent_profiles twice: once in the route to build the intent's prompt template, once again in run-turn for the system prompt. The route now hands its result over. Ranked memory is deliberately NOT shared: the two queries differ (the route's selects fewer columns and orders without is_pinned, and run-turn needs ids to stamp last_accessed_at), so reusing it would silently change both the prompt and memory touch. Command palette's "Fråga Anna: ..." pointed at /chat?prompt=, but only /chat/new reads ?prompt=, so the typed question was silently dropped and the user landed on an empty state. Verified: 9526 unit tests pass, lint clean and tsc clean on every touched file, guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): make the prefetches fail-safe and bounded Review follow-ups on the staged-loading batch. A rejected markdown import left the cached promise permanently rejected, so every bubble for the rest of the session stayed on the plain-text fallback and the rejection went unhandled. The cache is now cleared on failure so a later surface retries, and the rejection is swallowed. requestIdleCallback can defer indefinitely on a page that never goes idle; the 2s fallback only applied where the API is missing. The idle request now carries a 2s timeout, and the warm import cannot produce an unhandled rejection either. Adds the first-turn test for the profile-summary handover: it asserts the value read for the prompt template is what reaches the turn, so a regression that re-introduces the second read (or drops the template) fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ee8ddb3849 |
fix(assistant): stop cross-user conversation access, bricked threads and lost sessions (#1209)
* fix(assistant): stop cross-user conversation access, bricked threads and lost sessions Hotfix batch (PR1 of the assistant UI makeover, dev_docs/assistant_redesign_plan.md section 7). No visual change; each of these is wrong today regardless of which design lands, and three are unrecoverable per incident. /api/agent/invoke never checked who owns a resumed conversation_id. RLS on agent_conversations/agent_messages is company-scoped, not user-scoped (20260517204000), so a member could post a colleague's conversation id, have their history loaded into the prompt and read it back, while their own turns were appended to that thread. The conversations list route filters on user_id for exactly this reason. Also pins company and intent: resuming a thread from another company would mix ledgers, and resuming under a different intent would swap the tool whitelist under history the model has already seen. A turn persists the assistant message carrying tool_use blocks before the tools run, and their results only after the batch finishes. Dying in between (client disconnect terminating the function, a deploy, a slow tool) left history ending on an unanswered tool_use, which the Messages API rejects on replay: every later turn 400s, and agent_messages is append-only for the BFL trail, so nothing could repair it. History is now patched on read by synthesizing is_error tool_results, leaving the stored trail untouched. check_and_increment_agent_quota is SECURITY DEFINER in public with a caller-chosen p_user_id, so any authenticated user could drain a colleague's minute/day budget and lock them out of every agent endpoint. A plain REVOKE would break the limiter (all three callers use the user's RLS client) and, as it fails open, silently remove the spend cap: the function now refuses to act for anyone but the caller, while service-role connections keep passing an explicit id. The single reject route re-read status and then wrote unguarded, so losing the race with commit's atomic pending -> committing claim stamped `rejected` over an operation that had already posted a verifikat, invisible to the committing-state recovery sweep. Guarded on status like bulk-reject already is; a lost race is now a 409. The sheet's Escape handler listened on window with no defaultPrevented or target check while the sheet is deliberately non-modal, so pressing Esc to dismiss the reject-reason Select inside an approval card, the command palette or any dialog unmounted the sheet and discarded the conversation, the streaming turn and the un-actioned proposal. It now yields to open overlays and to focus outside the sheet. Verified: 9526 unit tests pass, lint clean on touched files, guards pass, and the new pg-real test proves the quota guard against real Postgres (attacker raises 42501, victim counters stay at 0). The four unrelated pg-real failures on this machine reproduce identically with these changes stashed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): close anon path on the quota RPC, order the ownership check ahead of writes Review follow-ups on the hotfix batch. The caller guard used auth.uid() alone, which is NULL for the `anon` role just as it is for backend roles, so an unauthenticated caller holding the public anon key (it ships in the browser bundle) could still pick any p_user_id and drain that user's quota. The guard now keys on the request role: anon and authenticated may only ever spend their own quota, backend roles keep passing an explicit id. The default PUBLIC execute grant is revoked as a second layer, with execute granted only to authenticated and service_role. Covered by a new pg test for the anon path. The ownership check ran after the onboarding.intake stamp, so a request that was about to be rejected could still write intake_completed_at. It now sits directly after the capability gate, ahead of every side effect and ahead of the company and profile reads, which also makes a rejected request cheaper. The tool-result repair matched ids anywhere in the history, but the API needs results in the message IMMEDIATELY after the tool_use. A result persisted after an intervening turn (two turns racing on one conversation) left a shape that still 400s. The repair is now positional, and orphaned or late-duplicate tool_results are dropped, since an unmatched tool_result is rejected just as an unanswered tool_use is. The Escape guard matched the Radix popper wrapper, which stays mounted when a popper is force-mounted; it now requires data-state="open" so a closed popper cannot block Escape for the rest of the session. Both new route errors are Swedish, per the user-facing error rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1270b6daeb |
fix(bookkeeping): let a rättelseverifikation be stornoed; unblock aged supplier-invoice deletion (#1204)
* fix(bookkeeping): let a rättelseverifikation be stornoed; unblock aged supplier-invoice deletion A user who corrected a booking (storno + rättelse) and then discovered the affärshändelse was already booked by another verifikat had no sanctioned way out: reverseEntry refused source_type 'correction' alongside 'storno', and correctEntry rightly rejects a zeroing rättelse (BFL 5 kap 5 §). The same guard also broke uncategorize-after-rättelse, since bank transactions are relinked to the correction entry. - reverseEntry now blocks only 'storno' (storno-of-a-storno keeps the chain ambiguity problem); a correction entry is a regular live verifikat and can be stornoed, with correction_of_id keeping the chain traceable. - CANNOT_REVERSE_STORNO copy narrowed to stornos + remediation hint. - Supplier-invoice DELETE now allows unbooked, unpaid invoices in registered/approved/overdue: the daily overdue cron flipped unbooked invoices past due_date into a state where deletion was blocked forever. Orphan-safety checks (registration JE, payments, accrual schedule) are what actually protect the books. UI shows the delete button accordingly. - LinkVoucherPicker showed customer-side copy (kundfordran/1510) in supplier-invoice mode; supplier mode now explains the 2440-debit requirement, including why a direct-cost verifikat cannot be linked. Support case 2026-07-26 (marcus@). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(supplier-invoices): review fixes: fail-closed orphan lookups, hide delete when payments loaded - The payment and accrual-schedule lookups in DELETE now fail closed: a lookup error returns 500 instead of reading as "nothing linked" and letting the delete proceed unverified. - The delete button also requires the loaded payment list to be empty, matching the server predicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: authorize 'approved' in supplier-invoice delete allow-list (compliance-swarm V2.3) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6d9846b1e7 |
feat(settings): Fönster redesign - flat rows, ? help, dirty save bar (#1193)
* feat(settings): Fönster redesign - flat rows, help behind ?, dirty save bar Founder-approved concept (2026-07-25) applied to the whole settings surface, modal and full-page variants alike: - New primitives in components/settings/SettingsRows.tsx: section header (serif title + one-line intro), eyebrow groups, hairline label/control rows, flat inputs/selects/textareas, segmented control, animated reveal for gated settings, danger zone. - Every static explanation paragraph moved behind a "?" popover (HelpPopover) at row or group level; dynamic status stays visible. - Modal chrome: company kicker over serif title, fixed 920x680 window. - SettingsFormWrapper: save is a sticky bar that appears only when the form is dirty; collapses to zero height when clean. - All 11 sections converted (Konto, Abonnemang, Företag, Bokföring, Skatt, Löner, Fakturering, Mallar, Bank incl. Enable Banking-panel, Assistenten, API) with handlers, validation, role/entitlement/sandbox gates and i18n keys preserved; checkboxes became switches, cards dissolved into groups. - Fix: Escape with an open help popover closed the whole settings modal; it now closes the popover first. - New i18n keys: settings_intro.*, group labels, wrapper_unsaved (sv+en). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): founder feedback round 1 on the Fönster redesign - Abonnemang paying state: status and manage split into two rows so the row no longer wraps awkwardly; the included-features list now shows for paying companies too. - Logos where the counterpart has one: BankID mark on the security row and on the Koppla BankID button, Skatteverket mark on the connection rows. - Buttons are unmistakably buttons: 27 text-labeled row actions went from ghost to outline pills; icon-only actions stay quiet. - The agent-knowledge view (Regler & profil: Dina regler, Momsprofil, Konventioner) converted to the flat row language; it was the last old-style surface inside settings. Descriptions moved behind "?", rules render as hairline rows, the per-row "Regel" chip demoted to muted text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): address review-bot findings on the Fönster redesign - SettingsFormWrapper marks the form dirty on switch clicks too: Radix Switch is a button and fires no input event, so switch-only changes (f-skatt, KU, ROT/RUT, OSS...) never revealed the save bar. - i18n: the migrated hardcoded strings got keys in both locales (fiscal-period start date/range/months, security set-password trio); dates in ApiKeysPanel/OAuthClientsPanel/CalendarFeedSettings now pass the active locale to formatDateLong. - A11y: member remove/revoke buttons and the invite role select got correct accessible names; BankNameCombobox accepts aria-label wired from its row; the pinned-fact icon exposes role img. - BankIdSettings: explicit Avbryt under the QR block so a cancelled BankID flow cannot strand isLinking. - VoucherSeriesManager: clear the skeleton when no company is resolved. Verified end to end in sandbox: switch-only dirty bar, PUT /api/settings 200 for text and switch saves, persistence across hard reload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d54b43f80f |
Bug/resend and invoices (#1192)
* fix(invoices): anchor the PDF logo to the top-left of its header cell The logo box is always the full 240x80pt reserved area (any larger logo is clamped to exactly that), so objectFit: 'contain' placed the image inside it with the default 50% 50% centering. A wide banner logo fills the width and lands on the left margin, but a near-square logo scaled down to the 80pt height cap is only ~117pt wide and got pushed ~60pt in from the margin, which reads as a misaligned logo and forced companies to reshape their artwork. Anchor the image top-left so every aspect ratio starts at the margin. Covered by a test that renders the real PDF and reads the image placement matrix out of the content stream, for both a wide and a near-square logo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(invoices): show the real delivery outcome in the send history "Skickad" only meant the email provider accepted the message, so a bounced invoice looked identical to one that arrived. Resend reports the outcome asynchronously; that report now lands on the delivery row and drives the history: green is reserved for a confirmed delivery, bounce/blocked reads red, delayed and spam-marked read amber, and an accepted-but-unconfirmed send is neutral instead of falsely green. The report arrives on a signed webhook and may only touch the three new provider status columns of an already sent, unredacted row: the WORM trigger proves nothing else changed, and a lower ranked or older report can never downgrade an observed failure. The provider reason text can quote the failing address, so it is masked on read and cleared by the daily PII redaction job. Timestamps also formatted in Europe/Stockholm instead of falling back to the runtime zone, which rendered a 14:05 send as 12:05 on Vercel. Delivery reports are per message, never per recipient: Resend sends one event for the whole message, so splitting a send per recipient would be the only way to get finer granularity, at the cost of CC. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(stripe): make the integration feed-only Stripe sync now only imports balance transactions into the transactions inbox, like any bank feed; nothing auto-books. The event/settlement sync (lib/sync.ts, lib/payouts.ts) stays in the repo but is no longer wired to any route or cron: the 15-min sync cron is removed from vercel.json. Payment links on invoice send are unchanged; their payments arrive as feed rows and are matched manually. - /sync runs only syncStripeBalanceTransactions; response is { success, transactions } - connecting via OAuth enables the nightly feed by default (toggle stays as opt-out) - panel: needs-review section and plumbing removed, copy rewritten to transactions-first (sv + en), toast reports fetched/imported/linked and calls out an empty result instead of silent all-zeros Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): return the article currency from the v1 article list The dashboard, importer, export and MCP article surfaces all learned to carry a non-SEK article price (#1166, #1183, #1184), but the v1 projection still omitted currency. An API or agent caller therefore read price_excl_vat with nothing marking it as EUR and would copy the number straight onto a SEK invoice line, at a nine-to-one error. Adds currency to the projection, the response shape and the example, plus a pitfall stating the price is not always SEK and that this endpoint does no FX conversion. Additive field only; no migration (articles.currency already exists). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): replace the settings modal with a routed panel sheet Settings now renders as a sheet that fills the main panel, sliding up over the page the user came from and back down on close, with the sidebar and frame left visible and usable. Behind it sits one shared master-detail surface: underline search across every section and subsection, the grouped section rail, and the active section as a direct-editing accordion. All 11 sections are decomposed into subsections, and the legacy *SettingsContent components compose the same pieces so the stacked and accordion layouts cannot drift. The sheet is the only presentation, on every entry path. The intercepting route handles in-app navigation and closes by popping the history entry, landing back on the page underneath. @settingsModal/default.tsx handles cold loads (refresh, deep link, new tab), where interception never fires; nothing is mounted underneath there, so it closes to the dashboard. Both branch on one shared predicate, isSheetSection, together with the settings layout, which must render nothing for those sections or the surface would stack twice behind the sheet and run every section's fetches twice. Closing is deliberate rather than incidental: the X, Esc, or navigating away. The dialog is non-modal so the sidebar's account popover and company switcher keep working with settings up, and an outside click no longer dismisses it. Sections land fully collapsed, and the scroll position of the page behind survives opening and closing the sheet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: enhance article management and settings UI - Add PATCH test for toggling article active state without other fields. - Remove unused MessageCircle icon from DashboardContent. - Refactor AccountingFrameworkForm to use SettingsFieldRow for better help text display. - Update CompanyInfoForm, DimensionsToggle, and various settings forms to replace description with help text. - Remove redundant headings and intros in several settings components to streamline UI. - Improve help text for various settings in English and Swedish translations. - Update structured error messages for better clarity on article deletion. * refactor(ArticleDetailPage): remove unused imports and duplicate state variable * fix(settings): own deep-linked settings routes by route list, not nav visibility Review fixes from the settings panel sheet work: * isSheetSection reads the full settings route list so a hidden-but-deep-linked section (assistant before BankID, banking in sandbox, api without MCP) is claimed by the sheet instead of rendering the legacy shell around an empty panel * keep 503 on the Resend delivery webhook when the signing secret is unset, with a test pinning the behaviour * stripe callback route test coverage Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: update salary, tax, and templates settings components - Refactored SalarySettingsContent to use a form wrapper and improved payment settings UI. - Enhanced TaxSettingsContent with new signals for EU sales, KU obligations, and ROT/RUT deductions. - Updated TemplatesSettingsContent to remove legacy comments and improve readability. - Simplified navigation items by removing unnecessary constants and directly using hrefs. - Cleaned up translation files by removing deprecated keys and adding new descriptions for clarity. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f07a34c51b |
fix(invoices): stop popup blockers from silently eating the PDF preview tab (#1191)
* fix(invoices): stop popup blockers from silently eating the PDF preview tab A window.open() after an await runs outside the click's transient user activation (~5s, less in Safari), so the preview tab was popup-blocked exactly when generation was slow (cold start + logo re-encode). The request succeeded, nothing opened, no error: the button looked locked (support: carina@cbysea.se). - lib/browser/deferred-tab.ts: open the tab synchronously in the click, navigate it when the result arrives, close it on failure (the pattern AGIPanel already used for its signing tab), with unit tests. - InvoiceEditor: preview uses the deferred tab + popup-blocked toast; revoke the blob URL instead of leaking it; guard the review dialog against an unresolved customer (silent no-op click); 5s timeout on the pre-review next-number fetch; spinner + disable while the submit handler is in flight in create mode. - Same pre-open fix in TransactionAttachmentIndicator, JournalEntryAttachments (failures now toast instead of vanishing), DocumentViewButton, and the Arcim reconnect OAuth popup (its 'trusted gesture' comment was wrong after the await). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): review triage: close blocked preview tab, precise popup hint, test convention Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
01d2a1a946 |
fix(bookkeeping): stop draft edits wiping line FX metadata and tax_code (#1187)
* fix(bookkeeping): stop draft edits wiping line FX metadata and tax_code Fixes #1174. EditDraftEntryDialog hydrated only account/amounts/text/ dimensions into the form, and updateDraftEntry replaces all lines, so editing the text on a foreign-currency draft silently reset its lines to SEK (currency default, amount_in_currency/exchange_rate nulled) and stripped tax_code entirely (FormLine had no such field). The dialog also displayed EUR drafts as SEK before any save. - FormLine carries tax_code as a pure pass-through; the submit body sends it back. - The dialog hydrates per-line currency/amount_in_currency/ exchange_rate/tax_code, and passes initialCurrency/rate/amount so the form's currency picker and FX fields show the stored values. - A hydrated FX line marks the currency-meta slot as taken so the 19xx fallback cannot double-stamp another line. Blast radius was drafts only (posted entries are immutable), reachable via the form's own currency picker and via v1/MCP lines carrying tax_code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): keep the hydrated draft FX rate on mount CodeRabbit finding on #1187, verified real: the currency effect fired on mount for a hydrated foreign draft and replaced the STORED exchange rate with today's Riksbanken rate, so a text-only edit would save a different rate. The initial hydrated currency now skips the automatic fetch; changing currency or date afterwards still refetches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5afd031306 |
fix(invoices): keep the stored ROT/RUT personnummer when editing a draft (#1186)
Fixes #1175. The stored personnummer exists only as AES-256-GCM ciphertext (+ last4), so the editor cannot rehydrate it and sent an empty string; buildInvoiceWriteData then failed ROT/RUT validation and every edit of a draft deduction invoice was blocked with "Personnummer krävs för ROT/RUT-avdrag" unless the user re-entered the customer's personnummer. buildInvoiceWriteData accepts the stored ciphertext from the update path: an empty field on an invoice that still has deduction lines means keep, a typed value replaces, and removing every deduction line clears as before. The editor hint shows the kept last4 in edit mode (new i18n key, sv+en). Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
731a57dd6e |
fix(deadlines): submit only form-managed fields from the deadline form (#1185)
* fix(deadlines): submit only form-managed fields from the deadline form Fixes #1176. The form fabricated 11 system-field values on every submit (source: 'user', status: 'upcoming', reminder_offsets, tax_* nulls, ...) and the edit path PUT the entire merged Deadline row; only the route handlers' whitelists prevented editing a system-generated tax deadline from nuking those fields. The form now has an explicit DeadlineFormValues contract (the 7 fields it renders), create and edit send exactly that, and the edit handler takes (id, values) instead of a whole Deadline. No behavior change today; removes the latent data-loss dependency on the server whitelist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deadlines): migrate calendar DeadlineForm consumers to DeadlineFormValues The calendar extension's PaymentCalendar (and its CalendarWorkspace host) still typed the submit chain as the old full-row Omit<Deadline> shape, failing the core-only typecheck. Behavior unchanged: the raw insert already omitted ids, and the DB defaults cover system fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
36a1df4f6b |
feat(import): detect and import the article register's Valuta column (#1183)
Fixes #1167. The register export gained a Valuta column in #1166 but the importer ignored it, so re-imported non-SEK articles silently became SEK, breaking the export -> edit -> re-import round-trip. - Column detector recognizes valuta/valutakod/currency (claimed before generic columns; no keyword collision with Momskod). - Parser normalizes to upper-case ISO shape, drops malformed codes with a file-level warning, and carries currency per row. - Execute route validates codes lazily against the currencies table (FK stays the backstop when the reference read fails), imports valid codes, defaults absent to SEK, and in merge mode only overwrites when the file explicitly carries a valid currency. - Edit step shows a muted currency marker next to non-SEK prices; manual column mapping offers Valuta. - Export docblock caveat removed. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
aead2bc1d1 |
fix(ui): stop mislabeling unconverted FX amounts as kr in aggregates and toasts (#1182)
Fixes #1173. invoices.total_sek stays NULL when the Riksbanken rate fetch fails at creation, and every `total_sek || total` fallback then treated a raw foreign amount as kronor: - lib/calendar/utils: new invoiceSekAmount() returns null for unconverted non-SEK invoices; period summaries and day totals skip them and PeriodSummary exposes unconvertedCount. PaymentSummaryCard shows a one-line note when invoices were excluded; CalendarDayView renders each invoice in its own currency instead. - Deadlines page: the overdue attn sum now skips unconverted FX invoices and appends "(+N i utlandsk valuta)" instead of adding EUR into a kr total. - Supplier-invoice payment toast formats the amount with the invoice's currency (key drops its hardcoded " kr" in both locales). - AR aging drill-down row labels Betalt with the invoice currency, mirroring the outstanding cell. - BankFileColumnMappingStep: comment pinning why SEK is safe there (generic-csv hardcodes it). Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bb78f8fce8 |
fix(import): per-currency totals and row currency in bank-file preview (#1178)
* fix(import): per-currency totals and row currency in bank-file preview Fixes #1170. ParsedBankTransaction carries a per-row currency (Wise emits genuinely mixed rows; camt.053 reads Ccy per entry), but the preview and confirm steps formatted every amount as kr and rendered parser-level income/expense totals that sum across currencies. Adds summarizeByCurrency() (income positive / expenses negative, ore rounding, SEK default) and renders one total line per currency on both steps; preview table rows format amount and balance with the row's own currency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): use roundOre from lib/money (antipattern ratchet) The naive Math.round(x * 100) / 100 form is blocked by check:guards (subtly wrong on exact-half values); lib/money.roundOre is canonical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4bc2093e51 |
polish(vat): title-row Exportera, fused period chip, chip classification, calm SKV status (#1181)
Founder feedback on the live momsdeklaration (2026-07-25):
- The black Exportera now sits on the title row like every other page:
standalone report pages render their own PageHeader (FocusedReport
passes the title and skips its own), and the period chips get their
own row below.
- Year + quarter/month fuse into ONE chip ("Kvartal 3 2026") listing
five years reverse-chronologically with month-span annotations;
cadence stays behind the Period chip. Yearly keeps FyPicker, which
is already a fused rakenskapsar chip.
- The RC-basis worklist's Leverantorstyp/Typ av inkop selects (old
boxy style with labels) become ContextPicker chips, in the toolbar
and in each expanded row.
- SkatteverketPanel connection status per the locked conventions: the
contradictory "Ansluten" + "Session utgangen" badge cluster becomes
muted "Ansluten" text for the normal state and one attn sentence
with an embedded "Fornya med BankID" action for the expired session.
Verified via sandbox screenshots (Playwright against dev).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
17dc5f12f6 |
fix(articles): non-SEK price support + reinstated deactivate (support: odinaero.se) (#1166)
* fix(articles): stop losing and mislabeling non-SEK article prices Support report (odinaero.se): EUR article prices did not stick and the register showed every price in kr. Three concrete defects, one cause: articles.currency existed in the DB and API but the UI dropped it. - Edit dialog omitted currency from initialData, so ArticleForm fell back to SEK and every save silently reset an EUR article to SEK. - Register list and detail page formatted prices without the article's currency, rendering EUR amounts as "kr". - "Spara som artikel" in the invoice editor posted the line price without the invoice's currency, so lines from EUR invoices became SEK articles. - The xlsx/csv register export stamped the kr-suffixed currency format on every price; prices now use a new suffix-free decimalColumn and a Valuta column carries the per-article code. Follow-ups (not in this diff): the article importer does not detect a Valuta column yet, and the MCP create/update_article staged schemas have no currency param (agent-created articles stay SEK). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(articles): reinstate deactivate/activate on the article detail page Support report (odinaero.se): no button to set an article inactive. Commit 8a9a930f turned DELETE into a hard delete and removed the deactivate action, but hard delete is refused for articles referenced by invoice lines (ARTICLE_IN_USE), leaving used articles with no retire path even though the API, the list badge and the i18n keys for deactivation all still exist. Adds an Inaktivera/Aktivera button next to Redigera that PATCHes the active flag (confirm dialog on deactivate, none on reactivate) and stays on the page so the status badge reflects the change. Reuses the orphaned deactivate_* keys; adds the three missing activate_* keys in both locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(customers): stop resetting customer language to Swedish on every edit Same defect class as the article currency reset in this branch: the customer edit dialog's initialData omits language, CustomerForm defaults it to 'sv' and submits every field, and the PATCH route applies it. Editing any detail on an English-language customer silently flipped their invoice PDFs and emails back to Swedish. Found by a repo-wide sweep for hand-picked initialData edit dialogs; customers, suppliers and articles are the only three such call sites, and suppliers passes every form field already. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
47938520d6 |
fix(bookkeeping): allow a first rakenskapsar shorter than 6 months (#1165)
The validator enforced a 6-month minimum on the FIRST fiscal period, citing BFL 3 kap. The law says the opposite: BFL 3 kap 3 par expressly allows a rakenskapsar shorter than 12 months, with no floor, when bokforingsskyldigheten begins (Bolagsverket: the first year may be "hur kort som helst", max 18 months). The floor only applied to isFirstPeriod, exactly the case the law exempts, and blocked an autumn-registered AB from shortening its first year to Dec 31 to file an early arsredovisning. Drop the minimum, keep the 18-month cap and the day-boundary rules, and remove the now-dead Swedish error mapping. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
98886e68d8 |
fix(vat): keep the RC-basis worklist visible until every voucher is fixed (#1164)
Correcting a single voucher cleared the momsdeklaration's RC_BASIS_MISSING error and the whole per-voucher worklist with it: the check tested mere presence of ruta 20-24 basis, the stepper re-derived its landing step and yanked the user to Granska mid-work, and the remounted checks card never refetched gaps once the aggregate check stopped firing. The declaration then claimed "klart" while the remaining vouchers still under-reported rutor 20-24 (FK004). - Make RC_BASIS_MISSING/RC_OUTPUT_MISSING proportional: compare reported basis against the basis the per-rate output boxes imply (moms/sats), with a 0.5% + 1 kr tolerance for per-voucher ore rounding. - Fetch the rc-basis-gaps worklist once per period, ungated from the aggregate check, so remaining rows survive remounts. - Latch the automatic stepper landing once per period so a refetch after a korrigering cannot navigate the user off Kontrollera. - Resolve rc-basis-gaps against the rakenskapsar (fiscal_period_id) for helarsmoms, matching the declaration totals; a calendar span hid gap vouchers in the tail of an extended first year. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
98e1a48c2b |
feat(bokslut): the bokslut trio in the flat concept language (scenes 34-36) (#1161)
* feat(bokslut): Arsbokslut hub as Stegen with de-boxed linear steps (trio 1/3) Scene 34: the six-step wizard gets the house horizontal stepper (done checks behind the current step, click-back navigation, forward stays with each step's continue action), the period select moves into the header as the context control, and the progress-bar and picker cards die. Preflight, Preview, Execute and Result are de-boxed: sans eyebrow sections with hairlines, quiet action links, attn lines instead of boxed alerts, muted text for normal states. Step content is centered at reading width. Accruals/Dispositions keep their internals for now (interactive panels; follow-up pass). All wizard logic untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bokslut): INK2 and NE-bilaga views in the flat house language (trio 2/3) Scene 36 direction: the declaration views lose every card. Statutory sections (Tillgangar, Eget kapital och skulder, Resultatrakning, INK2S, Intakter, Kostnader) become sans eyebrow sections with hairlines, the header card becomes a flat block with the SRU download beside it, the filing instructions lose their info box, warnings render as attn lines, and the NE R11 result becomes the emphasized document-foot row. All ruta tables, SRU downloads and warnings logic untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bokslut): Arsredovisning page, studio and digital filing in the flat house language (trio 3/3) De-boxes the whole scene-35 surface: the page shell (period picker, K3 note, narrativ, flerarsoversikt, underskrifter, PDF section), the AnnualReportStudio (workflow strip, scope form, completeness checks, versions) and DigitalInlamning (iXBRL review, submission form, status history) all move from Card shells to sans-eyebrow sections with hairlines. Inner info boxes flatten to bordered text blocks; the digital-checks warning becomes an attn line. The iframe border and the Kommer snart overlay sign stay: one frames an external document, the other floats above blurred content. Statutory Swedish-only surface: no new i18n keys. * polish(bokslut): align trio controls with the house language Founder feedback: buttons and selects still read old-style next to the bookkeeping page. Sweeps all trio surfaces: - Drop every min-h-11 override on Buttons and Inputs (44px chunky controls) so the compact house pill and h-10 input apply. - Replace all native <select> elements (BooleanQuestion, currency, group size, signing method, signer role, versions, AGM outcome, roll) with the shadcn Select primitive used system-wide. - Step navigation matches the merged Moms Stegen: white outline size-sm pills labelled 'Nästa: <steg> →' and '← Tillbaka'. Booking commits (Verkställ, Bokför valda dispositioner) stay primary. - Year-end period picker becomes the ContextPicker chip (the house context-picker idiom), and the stepper row widens to max-w-4xl so step 6 'Klart' no longer clips. - font-sans on two uppercase h3 eyebrows that rendered serif via the global h1-h3 display rule. * fix(vat): one toolbar row and the FyPicker chip on every fiscal-year surface The prod momsdeklaration (helårsmoms) broke into two sparse rows: Exportera alone, then a labelled 280px FiscalYearSelector below it, plus a serif uppercase worklist heading and internal check codes in the UI. - The VAT toolbar is one flat row: periodicity picker, fiscal-year chip, black Exportera, all h-9 aligned. - FyPicker (the rounded ContextPicker chip from UI-migration PR 3) replaces FiscalYearSelector on every page-level surface it had left: the VAT toolbar, FocusedReport's header (all report detail pages) and Kassaflödesanalys. Dialog/settings forms keep the labelled select, which is a form field, not a context picker. - 'Verifikationer som saknar basbelopp' becomes a sans eyebrow with hairline; internal codes (RC_BASIS_MISSING et al) no longer render in check rows, the Swedish message already cites the SKV felkod. - font-sans on the SkatteverketPanel validation eyebrow. * polish(vat): periodicity, year and quarter/month pickers as ContextPicker chips Founder feedback: the Arsvis dropdown should also be the rounded style. The cadence choice now lives behind a settings-style 'Period' chip (Manadsvis/Kvartalsvis/Arsvis with a check on the active one), and the year and quarter/month selects become chips too, so the whole momsdeklaration toolbar is chip-shaped: Period, 2026, Kvartal 3 (jul-sep) or Rakenskapsar 2026, then the black Exportera pill. The concrete period chip already implies the cadence, so the Period chip can stay label-only. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
49e86e2e67 |
fix(transactions): share bank-sync busy state across surfaces (#1163)
* fix(transactions): share bank-sync busy state across surfaces useBankSync() kept busyId/syncingAll/connections as hook-local state, but the header "Synka bank nu" split-button row and the footer "Synka nu" button each call the hook independently: a sync started from one surface left the other enabled and spinner-less, so a second concurrent sync of the same connection could be started. Hoist the state into a module-level store (lib/transactions/ bank-sync-store.ts) consumed via useSyncExternalStore, so every instance shares busy state and the connection list: - both surfaces spin and disable while either one syncs - runFor/syncAll re-check the live snapshot before firing, so a click racing a sync from the other surface is a no-op instead of a second paid PSD2 call - the bank_connections query runs once per company instead of once per surface (first mounted instance claims the fetch; failures release the claim so a later mount retries) - a sync that hits a dead PSD2 session flips the connection to expired on every surface at once, not just the one that ran it Fixes #1162. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): discard stale connection loads after company switch publishConnections now requires the caller to still own the load claim: a fetch resolving after the active company switched (and re-claimed the slot) no longer clobbers the newer company's published list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8a7fd567bd |
fix(settings): validate share-capital pair before saving (#1137) (#1160)
* fix(settings): validate share-capital pair before saving (#1137) Entering aktiekapital without antal aktier (or vice versa) died on the DB pair constraint company_settings_share_capital_pair as a raw 500 with the generic 'Vardet uppfyller inte de tillatna kraven' toast. The pair rule (ARL 5 kap 14 $: the aktiekapital note needs both values) now surfaces as a clear 400 in the PUT route, checked against effective body-or-stored values so partial API updates are covered too. The form additionally marks each field required when its sibling is filled, so the browser blocks a one-sided submit before the request is sent. Fixes #1137 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: decision-log entry for share-capital pair validation placement Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(settings): correct the share-capital note citation to ÅRL 5 kap 34 § 5 kap 14 § is ställda säkerheter; the antal aktier/kvotvärde note is 5 kap 34 § (flagged by the Swedish compliance review bot, verified against the swedish-financial-reporting skill). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(settings): assert the pair message on the one-sided-clear rejection CodeRabbit review on #1160. Its second suggestion (assert the update payload on the partial-update test) is skipped: createQueuedMockSupabase proxies away builder args, so payloads are not recordable, same as every other test in this suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8a9162b948 |
polish(ui): loading feedback on sync/refresh buttons system-wide (#1156)
The "Synka bank nu" row in the transactions Importera split button fired syncAll() with zero visual feedback. SplitButton now takes busy/busyLabel per option: the primary face and the menu row swap to a spinning Loader2, show the busy label and go inert until the action resolves. useBankSync holds isBusy across the whole syncAll loop so the spinner does not flicker between per-connection syncs. Sweep of the rest of the system for async buttons missing the same feedback (convention: disabled + Loader2 animate-spin + label swap): - bokslut DigitalInlamning "Uppdatera status" (Bolagsverket poll): had no feedback at all; now disabled + spinner + "Uppdaterar ..." while polling - Stripe settings "Synka nu": had disabled + label swap but a static icon - Skatteverket "Verifiera": had disabled + label swap but no spinner - AgentMemoryPanel "Dolj"/"Aterstall" row actions: static icons on async patch; now swap to spinner for the busy row Checked and intentionally unchanged: Arcim migration "Synka igen" and "Ateranslut" (the whole step flips to a spinner view synchronously on click), skattekonto "Forsok igen" (page flips to loading view), AgentChat "Generera om" (streaming indicator is the feedback). Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bb551d1d59 |
polish(ui): founder feedback batch - inset sidebar hairline, calmer dialogs, Discord mark, chat skeleton (#1158)
- Sidebar: the hairline above the user block is inset to the content edges instead of running edge-to-edge (concept language). - User menu: the Discord community link renders the actual Discord mark (inlined simple-icons path, CC0) instead of lucide MessagesSquare. - Dialogs: open/close animation toned down, zoom 98 instead of 95 and 150ms instead of 200ms. - Settings modal: switching tabs no longer remounts the intercepted route (and replayed the whole open animation on every click). The rail now swaps sections via history.replaceState, which Next syncs into usePathname(), so the dialog stays mounted and tab switches are instant. Verified via Playwright: dialog DOM node survives three tab switches, URL tracks the section, Esc still closes back. - /chat loading: the shared dashboard skeleton stretched edge-to-edge in chat's full-bleed wrapper (chat's own layout is what suspends, so a chat/loading.tsx cannot catch it). The shared fallback is now route- aware and renders a two-pane chat silhouette for /chat. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
15300aa8e2 |
fix(invites): accept invite on BankID signup, recover missed invites on onboarding surfaces (#1157)
An invited user who registered via BankID was funneled into creating a company instead of joining the one they were invited to: the register page's BankID path never processed the gnubok-invite-token cookie (unlike the login, MFA-verify, and auth-callback paths). Observed in production 2026-07-24. - register: BankID signup now accepts the pending invite before routing to /select-company, mirroring the login page's BankID path. - lib/company/pending-invites: acceptPendingInviteByToken retries a missed acceptance from the cookie (pending + unexpired + email match, same rules as POST /api/team/accept); hasPendingInviteForEmail detects a stranded invitee whose cookie is gone. - /onboarding and /select-company retry acceptance from the cookie and redirect to the dashboard on success, making the auth callback's long-promised fallback real; with no cookie but a pending invitation, both surfaces show a 'join via the link in the invitation email' hint instead of silently asking the invitee to create a company. - No new accept path without the token: the hint deliberately points back to the mailed link, so mailbox possession stays required and no company name is leaked to unverified emails. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cb7b31819b |
feat(vat): Momsdeklaration as Stegen - stepper, flat steps, house toolbar (#1154)
* feat(vat): Momsdeklaration as Stegen (horizontal stepper, one step at a time) The founder-picked concept variant for Moms: the four pipeline sections (kontrollera, granska, bokfor, lamna in) become a clickable horizontal stepper with honest per-step status subs (fel/varningar from the pre-flight checks, att betala/aterfa from ruta 49, bokford/utkast lifted from the settlement proposal via a new optional onStatus callback on VatBookingCard) and one step's content rendered at a time with quiet Nasta-links. Errors land on step 1, otherwise Granska. SkatteverketPanel moves inside steg 4 next to the manual filing card when a declaration exists; the no-data states keep it standalone. A period switch resets the step choice. All checks, booking, drilldowns, exports and the SKV submit flow are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * polish(vat): de-box the Stegen step contents to the concept language Founder review: the stepper was new but the step contents kept the old card chrome. Now flat on the panel throughout: the period picker is a quiet toolbar row (no card, no labels), Granska renders as a centered document column with sans eyebrow group heads and ruta 49 as an emphasized document foot, the pre-flight checks are hairline rows with quiet Korrigera links instead of boxed alerts and outline buttons, VatBookingCard and VatManualFilingCard lose their cards (notes become flat muted/attn lines), and SkatteverketPanel's card shells become flat sections with sans uppercase eyebrows. All logic, flows and dialogs untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * polish(vat): black Exportera, no page-level Fraga Anna, clear Nasta buttons Founder feedback on Stegen: the toolbar keeps only Exportera and it wears the primary pill (ReportExportMenu gains an optional variant prop, outline stays the default everywhere else); the AgentSparkleButton leaves the page (the global assistant bubble remains); the Nasta step links become white outline pill buttons so the forward path reads as clearly as the export action. All other buttons on the page already follow the house variants (primary/outline/ghost/quiet links). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d0fb72dc63 |
feat(kpi): Nyckeltal as Instrumentbrädan — instrument panes, monthly bars, cost list (#1148)
* feat(kpi): Nyckeltal as Berattelsen (serif month hero + metric rail + quiet cost rows) The founder-picked concept variant: the month's result as a serif hero with a +/- delta sentence against the previous month, a single sage net area chart (income/expenses ride in the hover tooltip), and a hairline metric rail on the right still driven by the user's KPI preferences (Anpassa, formula tooltips, all seven definitions supported). The cost story renders as quiet bar rows: expense classes 4xxx-7xxx and top five suppliers. Replaces the four-tile + three-Recharts-card layout; KPIHeroCards, KPITrendChart, KPIExpenseMixChart and KPITopSuppliersChart are deleted. FyPicker replaces FiscalYearSelector; help behind ?. No API changes: everything derives from the existing KPIReport. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(kpi): switch Nyckeltal to Instrumentbradan (founder pick v2) Berattelsen replaced by the instrument-pane grid on founder review: monthly result bars as plain SVG (muted months, latest in sage or terracotta when negative, compact endpoint label, per-bar tooltips) plus one bordered pane per visible preference KPI, with the receivables pane carrying a two-segment not-due/overdue strip. The cost story rows below are unchanged. Recharts leaves this page entirely (KPIResultChart deleted). Anpassa, formula tooltips and all seven KPI definitions still supported. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(kpi): concept-true cost list and cash runway note Founder review against the concept: the report now carries topExpenseAccounts (top five BAS 4-7 accounts for the period, computed from the trial-balance rows the route already holds) and the page renders them as the full-width Storsta kostnaderna rows with account numbers, exactly like the concept. The Kassa pane derives its 'Tacker cirka N dagars utgifter' note from the period's daily burn so far. Class-composition and supplier columns leave the UI (data stays on the API). Route test extended for the new field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6911f657e9 |
feat(reports): catalog as one dry table with band groups and Senast öppnad (#1147)
The founder-picked Tabellen variant from the rest-of-nav 2 concept: the report library becomes a single dry table where band rows carry the accounting taxonomy, each report is one clickable line with its description in muted ink, and a Senast oppnad column replaces the recents shelf (RecentReportsShelf deleted). useRecentReports now stores slug+timestamp pairs (legacy plain-slug entries parse as undated). FiscalYearSelector swaps to the house FyPicker chip, help moves behind the ? popover, catalog footnote as pgnote. Entity gating, dimension gating, route-owning reports and the persisted FY choice all unchanged. 9341 tests pass, lint clean, guards pass. sv+en keys added. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9dfa6c6708 |
feat(home): first-run block as a numbered four-step thread with partner marks (#1149)
* feat(home): first-run block as a numbered three-step thread with partner marks The founder-picked stepped shape for 'Hur vill du komma igang?': 1 Fa in din bokforing (primary Flytta bokforingen + Fortnox/Visma/Bokio marks + '+ SIE'; Starta fran borjan as an inline alternative that just checks the step off), 2 Koppla banken (Enable Banking mark only), 3 Bygg din bokforingsassistent (Beta chip, no vendor logo). Dots walk number -> filled active -> sage check; the persisted state machine is unchanged, but choosing a path no longer auto-completes the setup: the block retires when all three steps are done (or via Dolj). DashboardContent's build-assistant hero now waits until the checklist is gone so the assistant is not pitched twice. initial_setup i18n rewritten for the stepped copy (sv+en), unused selected-state keys removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(home): Skatteverket as step three, assistant last, ticked steps collapse Founder feedback on #1149: the thread is now four steps: 1 Fa in din bokforing, 2 Koppla banken, 3 Anslut Skatteverket (with the SKV mark, BankID authorize link; skipped entirely in builds without the skatteverket extension), 4 Bygg din bokforingsassistent. A completed step drops its description and actions and keeps only the checked muted title, so the fresh-start pitch never lingers after the books are in. The heading counts honestly ({count} steg) and completion now requires all four steps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * polish(home): one-line checklist header Founder feedback: the title and sub-line said the same thing twice; only '4 steg sa ar bokforingen igang' remains (Dolj stays beside it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
213d611e54 |
feat(onboarding): journey PR D — wizard removal, /companies/new mode='add', searchable BankID picker (#1150)
* feat(onboarding): journey PR D — wizard removal, /companies/new mode='add', searchable BankID picker Closes the onboarding migration (plan PR D). The journey is now the only onboarding; the flag conditional is gone. - /companies/new: server page rendering OnboardingJourney mode='add' (quiet escape link back to the app). Improvement over the old page: the add-company path now persists the TIC lookup snapshot too. - Delete WelcomeOnboarding + Step1-4 + the three dead variants (Step2SectorSelection, Step3ExtensionSuggestions, Step4PreliminaryTax). onboarding-illustrations stays (backdrop uses it). - BankIdCompanyPicker restyled to the journey's searchable list (founder decision: list at ANY count): filter with single-match Enter, roster rows with name/form/roll/orgnr, member companies under their own section opening directly. Contract unchanged: picks still route to /onboarding?org_number= and this page still makes zero TIC calls. - i18n: prune 108 wizard-only onboarding keys and the whole companies_new namespace (no consumers left); add journey_cancel_add + three picker keys. sv and en in lockstep. Verified: full vitest suite 9339 passed, eslint 0 errors, guards pass, production build compiles with /onboarding, /companies/new and /select-company routes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(onboarding): address PR review — drop unused hasExistingCompanies plumbing, prune stale select_company keys Restores error_no_access/error_switch_failed (used via ternary inside t(), which the pruner's regex missed); full pruned-key set re-verified as bare strings against the whole codebase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
53e343ee92 |
Bug/invalid imports (#1146)
* feat: add Accounted MCP namespace * fix(bookkeeping): stop flagging verifikat whose underlag lives on a referenced supplier invoice The missing-underlag surfaces only accepted a document directly linked to the entry, so payment verifikat for supplier invoices (doc on the registration entry per design) and entries whose doc was pinned to the bank transaction before matching were falsely flagged; opening the entry showed the referenced doc and cleared the warning client-side, and it came back on reload. - verifikat_without_documents + transactions_without_documents now treat an entry as covered when a supplier invoice referencing it (registration or payment FK, or a supplier_invoice_payments row) carries a document anchored to a journal entry (BFL 5 kap 7 paragraf hänvisning till underlag; anchoring required because the WORM deletion guards key on document_attachments.journal_entry_id) - match-supplier-invoice routes (dashboard + v1) propagate the transaction's pinned document onto the payment verifikat, mirroring the categorize route; migration backfills rows already written (open unlocked periods, company-guarded, never steals a linked doc) - /api/documents/counts, the transactions-page badges, the bulk "Inget underlag krävs" count and the push-notification scheduler share the same reference-aware predicate, so every surface agrees with the RPC - counts route validates journal_entry_ids as UUIDs (they are interpolated into a PostgREST or-filter) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): align table columns flush with page edges Collapse the checkbox gutter column to zero width and hang the hover-revealed checkbox/expand chevron in the page margins, drop the outer padding so DATUM sits flush left and STATUS flush right, and tuck the overflow-menu dots under the middle of the STATUS header. Applied to both the inbox and history tables so they stay identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(arsredovisning): tie anlaggningstillgangar note to booked depreciation The ARL 5:8 roll-forward note recomputed depreciation from its own day-based linear formula (365.25/12 month length, non-inclusive day count, linear only), drifting ~20 kr per year per asset from the ledger-driven resultat- and balansrakning and misstating non-linear methods entirely. Note figures now come from posted depreciation_schedules rows (the same source disposeAsset reverses), falling back to the engine's computeAnnualDepreciation when nothing is posted; pre-onboarding opening balances iterate prior years through the engine. Adds a note-vs-trial-balance tie-out warning (accounts 1000-1299, over 1 kr) surfaced before download. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(stripe): move connect and sync surface from settings to import page Stripe's transaction feed is a continuous import source in the same category as the PSD2 bank connection, so its connect/sync surface now lives on the import page as a source card (mode=stripe), gated "kommer snart" on hosted like before; self-hosted keeps the full panel. - Import page: Stripe card after Koppla bank, renders the existing StripeSettingsPanel via the settings-panel registry - OAuth callback and panel cleanup return to /import?mode=stripe - Settings > Betalningar retired: nav item removed, route redirects, PaymentsSettingsContent deleted, legacy ?tab=payments mapped - New import.stripe_* strings in sv+en; dead settings_nav.payments removed Crons and sync logic unchanged; payment-link settings stay in the invoicing section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(underlag): paginate missing-underlag cron and harden doc-surface queries Resolve PR review findings on bug/invalid-imports: - notification-scheduler: fetchAllRows on all 5 global reads; past 1000 rows the capped reads produced false "saknade underlag" notifications - bulk-missing: LOOKUP_CHUNK 300->150 so the twice-embedded .or() id list stays under the PostgREST URL limit - bulk-missing + transactions page: UUID-guard the .or()-interpolated id lists, matching documents/counts - match-supplier-invoice (dashboard + v1): log documentId/journalEntryId on the non-fatal doc-link warning - well-known/oauth-protected-resource: document the tool_namespace allow-list - messages/en: reword stripe_description - DECISIONS.md: record the asset ibAck tie-out and Tailwind !important calls Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tic): convert registrationDate from Unix seconds to millisecond epoch in lookup and profile tests --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
51ca574ca4 |
feat(onboarding): journey PR C — flow component + /onboarding swap behind flag (#1143)
* refactor(onboarding): extract first-year defaults + shared TIC lookup client (journey PR A)
First of four PRs replacing the onboarding wizard with the journey flow
(dev_docs/onboarding_migration_plan.md, local). No UI change.
- Move deriveFirstYearDefaults + parseStartMonthDay out of
WelcomeOnboarding into lib/company/first-year-defaults.ts and unit-test
them (11-vs-13-months boundary, UTC month seeding, malformed input).
- Add the missing computeFiscalPeriod unit tests (calendar year, brutet
ar, first year short/extended, EF calendar-year rule, period names,
BFL 3 kap. 6-18 month window errors).
- New shared fetchCompanyLookup() client: the single client path to the
Lens-backed /lookup, typed outcomes (found / not_found / disabled /
error / aborted), never throws. Fixes the 403/404 conflation: the
dispatcher's 404 ("Extension not found") and feature-flag 503
(EXTENSION_DISABLED) now degrade silently instead of rendering as
"company not found"; only the TIC handler's own 404 does.
- Step2CompanyDetails consumes the helper; identical UX otherwise.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(onboarding): journey state machine reducer with full branch coverage (journey PR B, 1/3)
Pure reducer for the journey onboarding: owns every transition and every
CompanySettings write; the component layer only renders steps, runs the
single TIC lookup, and calls the server action.
Encodes the plan's invariants: entry-snapshot history (Back rolls answers
AND stations), lookupRan gates fact-vs-question per field (BankID prefill
without lookup degrades to questions), vat_registered is never defaulted
without lookup data or an explicit answer, entity change wipes downstream,
org_number_invalid bounces to the Företaget station, station jumps rewind
to a station's first step.
34 unit tests: AB/EF found, not-found manual, ceased, BankID prefill
(found + degraded + disabled), first year, brutet år, moms nej, Back from
every step, station jumps, server-error bounces.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(onboarding): journey visual primitives + sandbox gallery (journey PR B, 2/3)
Ports the founder-approved concept (artifact c82c9358) to React:
- JourneyOrb: 320-particle canvas sphere with comet travel, check morph
and the monogram finale (glyph sampled live from --font-display). Own
component per plan, NOT thinking-orbs. rAF pauses on document.hidden;
reduced motion renders static frames.
- JourneyTrack: five stations with inked answers; completed stations are
keyboard-accessible jump-back buttons; answers mirrored to an aria-live
region.
- Question primitives: Question (ink title + "?" popover, Esc closes),
ChipRow (fly-to-orb ghost), YearBand (springy fiscal-year preview),
JourneyDatePicker (year -> month by name -> day), AddressFields
(Enter-chained, skippable).
- journey.css: concept stylesheet namespaced under .jny on app tokens,
incl. the no-scroll composition (100dvh + optical-centering balance
spacer) and the dawn layer.
- /sandbox/journey: internal primitive gallery (auth-free sandbox path),
demo data only: this page makes ZERO TIC calls.
i18n note: primitives are copy-agnostic (strings via props); the real
flow's sv/en keys land with their consumer in PR C.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: log journey reducer location decision
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(onboarding): annotate ENTITY_PICKED settings as Partial<CompanySettings>
The wipeDownstream return narrows against the inferred initializer type;
tsc strict rejects the reassignment without the explicit annotation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(onboarding): journey flow component behind NEXT_PUBLIC_ONBOARDING_JOURNEY (journey PR C)
OnboardingJourney wires the PR B reducer and primitives into the real
flow and swaps /onboarding behind the flag (wizard remains the default).
Data + error handling parity with the wizard, structurally enforced:
- identical settings payload to createCompanyFromOnboarding (incl.
ticLookup snapshot, derived vat_number, first-year fields through
computeFiscalPeriod), same /api/log error logging, same
org_number_invalid bounce (now to the Foretaget station), period
validation before submit, generic failure -> retry on the method step.
- lookup degradation: disabled surface silent, transient error shows the
advisory line; either way every fact the lookup could not provide is
asked as a question (address, F-skatt, fiscal year, VAT).
- advisory dup check rides the org submit (internal endpoint, never
blocks), rendered as a quiet fact-line note.
TIC budget: exactly ONE fetchCompanyLookup per confirmed orgnr: Enter on
the manual path, or the auto-submitted BankID deep link (which replaces
the wizard's preverified suppression per the plan addendum). No
debounce-per-keystroke; the journey strictly reduces Lens volume.
Finale per the approved concept: narrated real server steps while the
action runs, check morph, company-initial monogram, Foretagsprofil card,
conditional notes, dawn progression; sv+en strings (128 keys each).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: trigger preview with NEXT_PUBLIC_ONBOARDING_JOURNEY=true (branch-scoped)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
b771c1f923 |
feat(onboarding): journey PR B — reducer, orb, track, question primitives (behind /sandbox demo) (#1145)
* refactor(onboarding): extract first-year defaults + shared TIC lookup client (journey PR A)
First of four PRs replacing the onboarding wizard with the journey flow
(dev_docs/onboarding_migration_plan.md, local). No UI change.
- Move deriveFirstYearDefaults + parseStartMonthDay out of
WelcomeOnboarding into lib/company/first-year-defaults.ts and unit-test
them (11-vs-13-months boundary, UTC month seeding, malformed input).
- Add the missing computeFiscalPeriod unit tests (calendar year, brutet
ar, first year short/extended, EF calendar-year rule, period names,
BFL 3 kap. 6-18 month window errors).
- New shared fetchCompanyLookup() client: the single client path to the
Lens-backed /lookup, typed outcomes (found / not_found / disabled /
error / aborted), never throws. Fixes the 403/404 conflation: the
dispatcher's 404 ("Extension not found") and feature-flag 503
(EXTENSION_DISABLED) now degrade silently instead of rendering as
"company not found"; only the TIC handler's own 404 does.
- Step2CompanyDetails consumes the helper; identical UX otherwise.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(onboarding): journey state machine reducer with full branch coverage (journey PR B, 1/3)
Pure reducer for the journey onboarding: owns every transition and every
CompanySettings write; the component layer only renders steps, runs the
single TIC lookup, and calls the server action.
Encodes the plan's invariants: entry-snapshot history (Back rolls answers
AND stations), lookupRan gates fact-vs-question per field (BankID prefill
without lookup degrades to questions), vat_registered is never defaulted
without lookup data or an explicit answer, entity change wipes downstream,
org_number_invalid bounces to the Företaget station, station jumps rewind
to a station's first step.
34 unit tests: AB/EF found, not-found manual, ceased, BankID prefill
(found + degraded + disabled), first year, brutet år, moms nej, Back from
every step, station jumps, server-error bounces.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(onboarding): journey visual primitives + sandbox gallery (journey PR B, 2/3)
Ports the founder-approved concept (artifact c82c9358) to React:
- JourneyOrb: 320-particle canvas sphere with comet travel, check morph
and the monogram finale (glyph sampled live from --font-display). Own
component per plan, NOT thinking-orbs. rAF pauses on document.hidden;
reduced motion renders static frames.
- JourneyTrack: five stations with inked answers; completed stations are
keyboard-accessible jump-back buttons; answers mirrored to an aria-live
region.
- Question primitives: Question (ink title + "?" popover, Esc closes),
ChipRow (fly-to-orb ghost), YearBand (springy fiscal-year preview),
JourneyDatePicker (year -> month by name -> day), AddressFields
(Enter-chained, skippable).
- journey.css: concept stylesheet namespaced under .jny on app tokens,
incl. the no-scroll composition (100dvh + optical-centering balance
spacer) and the dawn layer.
- /sandbox/journey: internal primitive gallery (auth-free sandbox path),
demo data only: this page makes ZERO TIC calls.
i18n note: primitives are copy-agnostic (strings via props); the real
flow's sv/en keys land with their consumer in PR C.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: log journey reducer location decision
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(onboarding): annotate ENTITY_PICKED settings as Partial<CompanySettings>
The wipeDownstream return narrows against the inferred initializer type;
tsc strict rejects the reassignment without the explicit annotation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
f9ef8913ae |
refactor(onboarding): extract first-year defaults + shared TIC lookup client (journey PR A) (#1141)
First of four PRs replacing the onboarding wizard with the journey flow
(dev_docs/onboarding_migration_plan.md, local). No UI change.
- Move deriveFirstYearDefaults + parseStartMonthDay out of
WelcomeOnboarding into lib/company/first-year-defaults.ts and unit-test
them (11-vs-13-months boundary, UTC month seeding, malformed input).
- Add the missing computeFiscalPeriod unit tests (calendar year, brutet
ar, first year short/extended, EF calendar-year rule, period names,
BFL 3 kap. 6-18 month window errors).
- New shared fetchCompanyLookup() client: the single client path to the
Lens-backed /lookup, typed outcomes (found / not_found / disabled /
error / aborted), never throws. Fixes the 403/404 conflation: the
dispatcher's 404 ("Extension not found") and feature-flag 503
(EXTENSION_DISABLED) now degrade silently instead of rendering as
"company not found"; only the TIC handler's own 404 does.
- Step2CompanyDetails consumes the helper; identical UX otherwise.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
2bec5acedb |
feat(ui): rest-of-nav 1 — Viktiga datum, Skattekonto, Periodiseringar, Import to the concept language (#1140)
* feat(ui): concept scenes 17/24/32/33 for Viktiga datum, Skattekonto, Periodiseringar and Import (rest-of-nav 1) Viktiga datum: thread rows with type-icon circles behind a type seg (Alla/Skatt/Fakturering/Egna), Narmast countdown pane, Ny deadline lifted to the page header, both banners replaced by one AttnLine, mark-done via ConfirmDialog. DeadlineCard/DeadlineFilters die; DeadlineRow is the row. Skattekonto: card-less saldo hero with OCR + quiet copy, shortfall AttnLine computed from the next drain date with a betalningsuppgifter dialog (bankgiro 5050-1055 + OCR), one dry-table with Kommande/Forfallna/ Genomforda band rows, chips only on unbooked genomforda rows, quiet hover actions. Tabs and per-row badge noise are gone; the concept's Saldo column is dropped because SKV stores no per-row running balance. Periodiseringar: banner becomes an AttnLine with inline Bokfor forfallna (now confirm-first), house seg with Aktiva count, dry-table with muted normal states and animated RowFoldout for installments, Los upp nu as a quiet hover link through the shared ConfirmDialog. Import: tabs collapse into one two-column row list (Importera | Exportera) in the concept row language; SIE export moves into a small dialog and Molnsynkronisering folds the CloudBackupCard open in place. The /import?view=export#sie-export and /import#cloud-backup deep links keep working. Sandbox notice is an AttnLine. All four pages get stagger-enter, a help popover behind ? and sv+en keys for every new string. 9189 tests green, lint clean, guards pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(ui): align dashboard loading skeletons with the migrated page silhouettes Folds in the parallel WIP from this checkout at the founder's request: every loading.tsx under (dashboard) now mirrors its migrated page row-for-row (24px title block, pill actions, borderless table heads, single-line rows), and the shared (dashboard)/loading.tsx takes Hem's greeting + Att gora silhouette. Also lands the pending DECISIONS.md lines (onboarding swap plan note + rest-of-nav 1 deviations). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * polish(ui): authority logos, stat-tile skattekonto hero, quieter type on rest-of-nav 1 Viktiga datum: statutory deadlines wear the receiving authority's mark (Skatteverket for tax dates, Bolagsverket for arsredovisning/arsstamma) as a small badge on white; other deadlines keep the neutral type icons. Row dates go muted, titles drop font-medium, the Narmast countdown steps down to the house text-4xl display scale. Skattekonto: the 32px serif hero becomes two compact metric tiles in the KPIHeroCards idiom (saldo with the Skatteverket mark + OCR meta, nasta dragning with date and event count), matching how numbers read on the migrated pages. Periodiseringar: chevron column dropped; rows expand on click exactly like the verifikat list. Import: provider logo chips return on Hamta fran annat system (live- version parity) and Koppla bank carries the Enable Banking mark. Adds skatteverket(_color), bolagsverket, enable-banking plus claude/ anthropic marks (for future use) under public/logos/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * polish(ui): keep Importera and Exportera as separate tabs on the import page Founder feedback: the merged two-column landing goes back to the familiar split. The house seg switches between the Importera rows and the Exportera rows (SIE 4 dialog + Molnsynkronisering fold), ?view=export selects the export tab again and the hash deep links flip to it before opening their surface. Row language and logo chips stay. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): readable Enable Banking mark and full-width skattekonto The enable-banking.webp is the full stacked logo in white-on-transparent: invisible on the light chip and mush at 16px. The chip now uses a cropped 368px icon square (enable-banking-icon.png) with the marketing site's grayscale+brightness treatment in light mode and a white lift in dark. Skattekonto loses its max-w-3xl cap so the table stretches the content column exactly like Bokforing and Transaktioner; the saldo tiles take KPI-card width (lg:grid-cols-4). Import's tab columns stretch too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): address review-bot findings on rest-of-nav 1 CodeRabbit triage, all three confirmed real: the Bokfor forfallna attn action is hidden for read-only users instead of rendering a no-op link; a failed deadline edit rethrows so the form stays open with the user's input; authority marks are reserved for statutory (system-generated) deadlines: a manual tax-category deadline keeps the neutral icon. Compliance swarm's two high findings verified clean, no change needed: /api/bookkeeping/accruals/:id/dissolve and /api/deadlines/:id (+ /complete) all run through withRouteContext with company_id scoping and 404 on miss. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d840257c0c |
Add/stripe connect transactions (#1139)
* fix(mcp-oauth): allow ChatGPT connector callbacks and resume OAuth after login Add chatgpt.com/connector/oauth/* (per-instance) and the legacy chatgpt.com/connector_platform_oauth_redirect to the built-in OAuth redirect allowlist so ChatGPT MCP connectors can register and authorize. Fix the login page dropping the ?next= destination: an OAuth-initiated visit that required login previously ended on the dashboard and the connection flow silently died. Login now resumes to the sanitized next path (hard navigation, since the consent page is route-handler HTML), carries it through the MFA step-up as returnTo, and /mfa/verify hard-navigates for /api/ destinations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): dedup incoming feed rows against booked hand-entered twins Users who bookkeep via MCP/chat first and connect their bank afterwards got the same movement twice: the synced row's external_id lives in a different namespace, the free-form manual title never text-bridges the bank's raw string, and the cross-channel mirror deliberately excluded manual/mcp rows. Extend the mirror with a booked-hand-entered track: an incoming feed row is skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count- symmetrically. Gates beyond the feed-vs-feed mirror: stored row must be booked (staged rows never consume an import), currencies must not contradict (bucket key is date+ore only), the cash-account guard applies to the count exactly as to consumption, and symmetry uses the Layer-1-unmatched incoming count so an already-stored row cannot inflate it. Consumption stamps the batch cash_account_id onto an account-unbound hand row, so one hand row can never consume feed rows on other accounts in later syncs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): inline verifikat rattelse (strike lines + text/date edit) Second sanctioned correction track under BFL 5 kap 5/9 pp, Fortnox-style: strike lines inside a posted verifikat with replacements in the same voucher, and correct description/entry_date without an andringsverifikat. Envelope: posted entries, open unlocked periods, company lock date, same-period date moves, structural/FX/doc-linked lines excluded, and a reconciliation guard preserving per-account net on bank/reskontra sides of externally linked entries. Every rattelse writes an immutable who/when row (journal_entry_rattelse_log, WORM, archived as rakenskapsinformation) and struck originals render struck-through in the verifikat; list rows and the detail header carry a Rattad marker. CLAUDE.md hard rule 1 and the swedish-accounting-compliance skill are amended to state the two-track rule. Staging carries the DDL; prod gets it on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: live saldo in booking form, prior-year window comparison, hideable assistant FAB - Manual journal entry: saldo column now shows before -> after computed from the typed debit/credit amounts (direction feedback while booking) - Resultatrapport: a narrowed date range now compares against the same window shifted one year back (#862), merged across fiscal periods for brutet rakenskapsar; P&L rows report window activity instead of rolled-forward YTD closing - Assistant FAB: per-user hide toggle (user_preferences.hide_assistant_fab, settings > assistant), sidebar entry unaffected; collapsed sessions keep their reopen handle Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(stripe): sync balance transactions as a bank feed on 1686 Import the connected Stripe balance into the transactions inbox, opt-in per connection (transaction_sync_enabled on stripe_connections): - Balance transactions map to feed rows with the two-row gross+fee split and frozen external_id formats (stripe_{acct}_{txn} / _fee), dated on created, bound to a provisioned "Stripe-saldo" cash account on 1686 so booking settles against the clearing account by construction. - Double-booking protection: settled payment-link charges import pre-linked to their settlement entry; payout rows import pre-linked to the payout entry; processPayoutPaidEvent claims the payout's fee rows at booking time (linkPayoutFeedRows, idempotent from both directions). - Cursor last_balance_txn_synced_at with 24h overlap; first run backfills 90 days floored at the day after the company lock date. - Nightly cron /api/extensions/stripe/transactions/cron (03:30), transaction-sync toggle route, "Synka nu" covers both feeds, settings panel toggle with last-synced/backfill note, sv+en strings. - Migration 20260723200000 (applied to staging). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): offer match-to-voucher on unbooked history rows Unbooked transactions with is_business already set (e.g. left behind when a voucher was removed without a full uncategorize) land in the history list instead of the inbox, where the match-against-existing-voucher action did not exist, leaving them with no path back to voucher matching. Add the same menu item to the history list for unbooked rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(transactions): enhance ownership checks and error handling in journal entry routes --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |