fbe4e18730
* 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>
256 lines
10 KiB
TypeScript
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);
|