Files
accounted/next.config.ts
T
Mattsson fbe4e18730 feat(mcp): book on custom accounts via account_override; fix kontoplan settings link (#1608)
* feat(mcp): book on custom accounts via account_override; fix kontoplan settings link

gnubok_categorize_transaction only spoke a 19-category enum mapping to 21
hardcoded BAS accounts, so company-custom accounts (e.g. VMB) were
unreachable from the agent surface even when active in the chart.

- add account_override to gnubok_categorize_transaction with v1 REST
  semantics via a shared helper (lib/bookkeeping/account-override.ts):
  business-side replacement, class-2 auto-VAT drop with the 2610-2649
  moms-line exception, plus a same-account degenerate guard; validated at
  staging and re-validated at commit
- align the gnubok_create_voucher staging gate with the engine's seeding
  semantics: BAS 2026 accounts merely absent from the chart pass (the
  engine backfills them at commit) and the preview lists
  will_activate_accounts with BAS-name fallback; non-BAS unknown and
  inactive accounts still rejected
- stop suggest_categories silently dropping mapping rules whose account
  is outside the fixed category maps; they surface with the rule's own
  account and an explanatory match_reason
- correct the create_account next-step hint (categorize could never use
  the new account before; now true via account_override)
- point the settings "Kontoplan (BAS)" link at /chart-of-accounts and
  redirect the orphaned /bookkeeping?tab=accounts URL (tab removed in
  #850; the deep link never worked after the #854 merge collision)

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

* fix(mcp): address review findings on account_override

- commit executor rejects a present-but-malformed stored account_override
  loudly instead of degrading to the category default (CodeRabbit major;
  the approver approved a preview showing the override account); with
  commitPendingOperation regression tests
- accountToCategory returns null for unknown income accounts so custom
  income accounts get the same diagnostic as expenses (CodeRabbit minor),
  with income + reason-accumulation tests (CodeRabbit nit)
- pin the class-2 VAT-drop balance invariant with a test through
  buildTransactionEntryLines (Swedish compliance review: gross booking,
  never an unbalanced net + missing VAT leg)
- account_override description asks the agent to state the actual
  affärshändelse in notes when overriding (BFL 5 kap description concern)
- eventBus.clear() in the two new test suites (CodeRabbit minor)

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

* fix(mcp): never guess a moms leg onto an account_override without explicit VAT intent

Round-2 Swedish compliance finding: the class-2 VAT drop did not cover
margin-scheme (VMB) accounts in class 3/4, which are the override's
flagship use case, so a forgotten vat_treatment attached the category
default standard_25 and booked an ingående-moms deduction on a
transaction where input VAT is not deductible (ML 2023:200).

applyAccountOverride now takes explicit VAT intent (vat_treatment or
vat_amount present) and books GROSS with no auto-VAT line without it:
forgetting the flag under-deducts (lawful), never over-deducts. Both
call sites (MCP staging preview, commit core) derive the flag the same
way; the tool description states the enforced behavior. Deliberate
divergence from v1 REST recorded in DECISIONS.md.

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

* chore: move stray decision-log entry to the root DECISIONS.md

The round-2 entry was appended from the wrong working directory and
landed as lib/bookkeeping/__tests__/DECISIONS.md.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 01:24:48 +02:00

256 lines
10 KiB
TypeScript

import path from "node:path";
import { fileURLToPath } from "node:url";
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
import { LEGACY_HOST_REDIRECT_EXCLUSIONS } from "./lib/domains/legacy-redirect";
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
const isDev = process.env.NODE_ENV === "development";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
// WebSocket origin for Supabase Realtime. Hosted projects are covered by the
// wss://*.supabase.co wildcard below, but a SELF-HOSTED Supabase URL is not:
// Realtime opens wss://<supabase-host>/realtime/v1/websocket, and WebKit
// throws synchronously on a CSP-blocked `new WebSocket()`, unmounting the
// dashboard into the error boundary (issue #893). The Docker image bakes the
// __NEXT_PUBLIC_SUPABASE_WS_URL__ sentinel at build time and
// docker-entrypoint.sh substitutes the real value at runtime (a build-time
// https-to-wss replace would only rewrite the sentinel); the fallback derives
// wss:/ws: from the https/http URL for non-Docker builds where the real URL
// is present at build time. Empty supabaseUrl stays empty, mirroring how
// ${supabaseUrl} is interpolated below (extra whitespace is valid in CSP).
const supabaseWsUrl =
process.env.NEXT_PUBLIC_SUPABASE_WS_URL ??
supabaseUrl.replace(/^http(s?):/, "ws$1:");
const cspDirectives = [
"default-src 'self'",
// No analytics hosts here on purpose. PostHog replaced Recapt and is
// routed through the same-origin `/rl` rewrite below, so ingestion is
// covered by `connect-src 'self'` and its lazy-loaded replay/survey
// bundles by `script-src 'self'`. Adding `*.posthog.com` back would
// re-widen the policy for no benefit and undo the ad-blocker resistance.
`connect-src 'self' ${supabaseUrl} ${supabaseWsUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com`,
`style-src 'self' 'unsafe-inline' https://*.enablebanking.com`,
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com`,
"img-src 'self' data: blob: https:",
"font-src 'self'",
"worker-src 'self' blob:",
// object-src must explicitly allow blob:: Chrome's built-in PDF viewer
// renders inline PDFs via an internal <embed>, which falls under
// object-src. Without this, blob:-URL invoice previews (created via
// URL.createObjectURL on /api/invoices/preview-pdf responses) show
// "Det här innehållet har blockerats" in Chrome. Firefox uses PDF.js and
// Edge uses its own viewer, so neither hits this. See crbug.com/271452.
"object-src 'self' blob:",
`frame-src 'self' blob: ${supabaseUrl}`,
"frame-ancestors 'none'",
].join("; ");
const nextConfig: NextConfig = {
output: 'standalone',
// Build id inlined into the client bundle so a running tab can tell when a
// newer deploy is live (see components/system/DeployReloadPrompt). On Vercel
// this is the commit SHA; empty elsewhere (dev / self-hosted), which disables
// the check. The /api/version route reads the same var at runtime to compare.
env: {
NEXT_PUBLIC_BUILD_ID: process.env.VERCEL_GIT_COMMIT_SHA ?? '',
},
// Multiple lockfiles exist above this project (e.g. a parent yarn.lock),
// which makes Turbopack infer the wrong workspace root. Pin it explicitly.
turbopack: {
root: projectRoot,
},
// PostHog sends trailing-slash API requests; without this Next 308s them
// and the events are lost. Required by the reverse proxy below.
skipTrailingSlashRedirect: true,
experimental: {
optimizePackageImports: ['recharts', 'date-fns', 'framer-motion'],
},
// PostHog reverse proxy. Keeping analytics same-origin buys three things:
// the strict CSP below needs NO posthog hosts (`connect-src 'self'` already
// covers ingestion, `script-src 'self'` the lazy-loaded replay/survey
// bundles), tracking blockers have no third-party host to match, and the
// Recapt host allowlist is replaced by nothing at all.
//
// `/rl` is deliberately meaningless: PostHog's own guidance is that obvious
// prefixes (/analytics, /tracking, /telemetry, /posthog, and increasingly
// /ingest) are on blocker filter lists. It must stay in sync with `api_host`
// in instrumentation-client.ts AND with the matcher exclusion in proxy.ts,
// or middleware redirects the ingestion POSTs to /login.
//
// Both /static/* and /array/* must point at the ASSETS origin, not the
// ingestion origin: array/ serves the config bundle and is easy to miss.
async rewrites() {
return [
{
source: '/rl/static/:path*',
destination: 'https://eu-assets.i.posthog.com/static/:path*',
},
{
source: '/rl/array/:path*',
destination: 'https://eu-assets.i.posthog.com/array/:path*',
},
{
source: '/rl/:path*',
destination: 'https://eu.i.posthog.com/:path*',
},
]
},
async redirects() {
const appUrlForRedirect = process.env.NEXT_PUBLIC_APP_URL?.trim().replace(/\/$/, '')
return [
{
source: '/nyckeltal',
destination: '/kpi',
permanent: true,
},
// The kontoplan lived as a tab on /bookkeeping until 2026-07-01 (#850);
// old bookmarks and stale links still carry ?tab=accounts.
{
source: '/bookkeeping',
has: [{ type: 'query', key: 'tab', value: 'accounts' }],
destination: '/chart-of-accounts',
permanent: false,
},
// Docs canonicalised to docs.gnubok.se. Every `docs_url` field on the
// v1 error envelope still points at this host; the 308 forwards both
// humans and agents to the docs subdomain without us needing to
// mass-update structured-errors.
{
source: '/docs/api',
destination: 'https://docs.gnubok.se/',
permanent: true,
},
{
source: '/docs/api/:path*',
destination: 'https://docs.gnubok.se/:path*',
permanent: true,
},
{
source: '/llms-full.txt',
destination: 'https://docs.gnubok.se/llms-full.txt',
permanent: true,
},
// Dual-domain cutover (2026-07): the user-facing app moves to
// app.accounted.se; app.gnubok.se stays alive for machine traffic
// (MCP connectors, API keys, the Skatteverket OAuth callback,
// webhooks, crons). Only browser page traffic is forwarded: /api and
// /.well-known must keep answering on the legacy host, and /_next is
// excluded so already-open tabs keep loading assets until their next
// navigation. The redirect arms itself only once NEXT_PUBLIC_APP_URL
// points somewhere other than the legacy host, so merging this is
// inert and the actual cutover is the env flip + redeploy. Kept
// non-permanent until the cutover has soaked.
//
// auth/ and reset-password are excluded so email links that carry a
// PKCE code (password reset, signup confirmation) sent before the
// cutover still complete on the legacy host, where their code
// verifier / recovery-session cookies live (#1092). login and MFA
// pages are deliberately NOT excluded: serving a usable login page
// on the legacy host would establish sessions there and bounce
// users in a redirect loop.
...(appUrlForRedirect &&
appUrlForRedirect.startsWith('https://') &&
!appUrlForRedirect.includes('app.gnubok.se')
? [
{
source: `/:path(${LEGACY_HOST_REDIRECT_EXCLUSIONS}.*)`,
has: [{ type: 'host' as const, value: 'app.gnubok.se' }],
destination: `${appUrlForRedirect}/:path`,
permanent: false,
},
]
: []),
]
},
async headers() {
// The catch-all excludes /api/documents/:id/inline so the strict
// X-Frame-Options: DENY + frame-ancestors 'none' don't conflict with
// the embeddable override below: Next.js applies every matching
// header rule, and duplicate X-Frame-Options/CSP values trigger
// "Det här innehållet har blockerats" in Chromium browsers.
return [
{
source: "/((?!api/documents/[^/]+/inline$).*)",
headers: [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=()",
},
{
key: "Content-Security-Policy",
value: cspDirectives,
},
],
},
// Document inline-preview proxy must be embeddable in same-origin
// iframes (used by the verifikat document preview Sheet). Excluded
// from the catch-all above so these values aren't shadowed by the
// stricter defaults.
//
// CSP is intentionally minimal: only `frame-ancestors 'self'`
// prevents cross-origin clickjacking on the user's documents.
// Adding `object-src 'none'` (or `default-src 'none'`) here breaks
// Chrome's built-in PDF viewer: Chrome renders inline PDFs through
// an internal <embed>, which the directive forbids, surfacing as
// "Det här innehållet har blockerats" in the document preview Sheet.
// Firefox uses PDF.js and Edge uses its own viewer, so neither hits
// this. See crbug.com/271452. X-Content-Type-Options: nosniff plus
// the explicit Content-Type from the route handler already prevent
// MIME-confusion abuse.
{
source: "/api/documents/:id/inline",
headers: [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=()",
},
{
key: "Content-Security-Policy",
value: "frame-ancestors 'self'",
},
],
},
];
},
};
export default withNextIntl(nextConfig);