diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index a10779af..b30c2dc0 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -364,15 +364,96 @@ processing_activities: - error_message_truncated_500_chars - no_tool_args_or_results_persisted + - id: mcp.company_customer_tools + name: MCP-verktyg för företagsinställningar och kunder + purpose: >- + Låta en uttryckligen behörig användare läsa nödvändiga företags- och + kunduppgifter samt förbereda ändringar för användarens godkännande. + Verktygssökningen gnubok_search_tools returnerar endast verktygsmetadata + och JSON-schema, aldrig företags- eller kundposter. Läsresultat begränsas + till det aktiva företaget och till verktygets dokumenterade fält. + lawful_basis: art_6_1_b + special_category_basis: null + controller: gnubok-tenant + processor: supabase + data_subjects: + - business_owner + - company_member + - customer + data_categories: + - user.name + - user.contact + - user.financial.bank_account + recipients: + - name: Supabase + country: EU + role: processor + international_transfers: + applicable: false + mechanism: null + note: EU-only processor. + retention: + duration: pending_operation_lifecycle + basis: contract_and_user_approval + stored_in: + - pending_operations + security_measures: + - api_key_scope_companies_read_or_write + - api_key_scope_customers_read_or_write + - active_company_filter_on_every_query + - rls_company_scoped + - search_returns_tool_metadata_only + - writes_staged_for_explicit_user_approval + - plaintext_personnummer_blocked_from_staging + + - id: arsredovisning.signature_evidence + name: Underskriftsbevis för årsredovisning + purpose: >- + Bevara vem som undertecknade en låst årsredovisningsversion, i vilken + roll, med vilken metod och på vilket datum. Bevisreferensen är endast en + ogenomskinlig identifierare med prefix archive:, document: eller receipt:. + Den får inte innehålla namn, e-post, personnummer eller fritext. + lawful_basis: art_6_1_c + special_category_basis: null + controller: gnubok-tenant + processor: supabase + data_subjects: + - company_signatory + data_categories: + - user.name + - user.professional_role + - user.activity_timestamp + recipients: + - name: Supabase + country: EU + role: processor + international_transfers: + applicable: false + mechanism: null + note: EU-only processor. + retention: + duration: 7y + basis: bfl_7_kap_and_arl_signing_evidence + stored_in: + - arsredovisning_signature_requests + - annual_report_versions + security_measures: + - rls_company_scoped + - service_role_only_signature_transitions + - version_company_period_and_state_validation + - signed_rows_immutable + - structured_opaque_evidence_reference + - future_and_pre_finalization_dates_rejected + - id: arsredovisning.bolagsverket.submit name: Digital inlämning av årsredovisning till Bolagsverket purpose: >- Lämna in årsredovisningen (iXBRL) digitalt till Bolagsverkets eget utrymme (ÅRL 8 kap.). Avsändarens och undertecknarens personnummer krävs av Bolagsverkets API (skapa-inlamningtoken, lamna-in) och - används transient i anropen; i databasen sparas endast - företagssaltade SHA-256-hashar. Fastställelseintyget signeras med - e-legitimation hos Bolagsverket, aldrig i appen. + används endast transient i anropen. Varken personnummer eller hash av + personnummer sparas i inlämningsposten. Fastställelseintyget signeras + med e-legitimation hos Bolagsverket, aldrig i appen. lawful_basis: art_6_1_c # legal obligation (ÅRL filing duty) special_category_basis: null controller: gnubok-tenant @@ -380,7 +461,7 @@ processing_activities: data_subjects: - business_owner # avsändare/undertecknare (styrelseledamot, VD) data_categories: - - user.government_id # personnummer (transient; at rest only salted SHA-256 hash) + - user.government_id # personnummer, endast transient till Bolagsverket - user.name # undertecknarens namn - user.contact # undertecknarens/kvittens e-post - user.financial # årsredovisningens finansiella innehåll @@ -396,13 +477,13 @@ processing_activities: duration: 7y basis: bfl_7_kap # filed .xhtml archived as räkenskapsinformation stored_in: - - arsredovisning_submissions # status, idnummer, checksums, PNR hashes only + - arsredovisning_submissions # status, idnummer and checksums, no PNR values or hashes - document_attachments # the exact filed iXBRL bytes (WORM) security_measures: - mtls_client_certificate_to_bolagsverket - mtls_key_env_only_injected_from_secret_manager # never in settings/DB; see .env.example custody note - pinned_endpoint_allowlist_per_environment # HOSTS map in client.ts; not env-configurable - - personnummer_never_persisted_plaintext_salted_sha256_only + - personnummer_and_hash_never_persisted - personnummer_never_logged - webhook_secret_constant_time_validation - rls_company_scoped diff --git a/.env.example b/.env.example index 21ed066c..478989f3 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,9 @@ CRON_SECRET=generate-a-random-secret # BOLAGSVERKET_CLIENT_CERT= # BOLAGSVERKET_CLIENT_KEY= # BOLAGSVERKET_CA= +# Safety gate: enable only after agreement, certificate, test-bank fixtures, +# acceptance testing, and production runbook approval are complete. +# BOLAGSVERKET_FILING_ENABLED=false +# NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED=false +# BOLAGSVERKET_ARELLE_VALIDATOR_URL= +# BOLAGSVERKET_ARELLE_VALIDATOR_TOKEN= diff --git a/.reuse/dep5 b/.reuse/dep5 new file mode 100644 index 00000000..c45842a3 --- /dev/null +++ b/.reuse/dep5 @@ -0,0 +1,13 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: Adobe Source Fonts +Source: https://github.com/adobe-fonts + +Files: public/fonts/invoice/SourceSans3-*.ttf + public/fonts/invoice/LICENSE-Source-Sans-3.md +Copyright: 2010-2024 Adobe +License: OFL-1.1 + +Files: public/fonts/invoice/SourceSerif4-*.ttf + public/fonts/invoice/LICENSE-Source-Serif-4.md +Copyright: 2014-2023 Adobe +License: OFL-1.1 diff --git a/AGENTS.md b/AGENTS.md index 9c8c3f73..7120b1f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,11 +21,15 @@ The accounting rules are Swedish law, enforced by DB triggers. Code that violate General prohibitions: - **Never modify an existing migration**: schemas already shipped; create a new migration. Never touch the enforcement triggers (migration 017); they are legally required. +- **Apply migrations only to the `erpbase` Supabase project's `staging` branch.** Never apply migrations to a local database or a locally hosted Supabase instance. +- **Never write to the `erpbase` Supabase production database without Emil's explicit approval for the specific write.** Production reads are allowed, including fetching data for a requested account, but no INSERT, UPDATE, DELETE, DDL, migration, mutating RPC, repair, seed, or other state-changing operation may run until Emil has clearly said okay. Do not infer approval from a request to investigate, diagnose, fix code, or fetch data. +- **Never write directly to the `main` branch without Emil's explicit approval.** Do not commit, push, merge, or otherwise update `main`; use a feature branch unless Emil clearly approves the specific main-branch write. - **Never leave a remote DB ahead of the repo.** If you `apply_migration` (or run any DDL) against prod, staging, or a preview branch, write the byte-identical SQL into `supabase/migrations/` under the exact applied version in the same change. An applied version with no committed file is an orphan: Supabase branching aborts the next merge to `main` with "Remote migration versions not found in local migrations directory" and blocks every pending migration behind it. The PR preview passes anyway (preview branches fork from prod's history, which already has the orphan), so this only surfaces at merge. - **Core code must never import from `@/extensions/`.** CI builds core with zero extensions enabled; a direct import breaks that build. Extensions cannot use dynamic imports (the registry generates static imports via `setup:extensions`). - **Don't add dependencies without asking.** This is an AGPL-3.0 project; license compatibility matters, and the dependency surface is audited. - **Don't "finish" the gnubok → Accounted rename.** Wire-format identifiers keep the old name on purpose: `gnubok-company-id` cookie, `gnubok_sk_`/`gnubok_inv_` prefixes, `gnubok-mcp` npm package. Renaming them breaks live sessions, API keys, and invites. - **Treat `.env.local` as pointing at the production database.** Never run seed/cleanup/repair scripts against it without explicit confirmation. +- **Never open, start, or run Docker locally.** Do not run Docker commands or commands that start Docker-managed services. - **Keep the diff scoped to the request.** No drive-by refactors of untouched code. - **Never use em dashes (—) or en dashes (–)** in code, comments, commit messages, or docs. Use a colon, comma, semicolon, or plain hyphen instead, whichever fits the sentence. Exception: a dash character that is the literal subject being parsed, matched, or documented (e.g. mojibake byte-mapping tables, a date-range separator regex) stays as-is; don't launder those into a colon. - Never create a NUL/nul file: `\Accounted\NUL`. diff --git a/DECISIONS.md b/DECISIONS.md index c30370e3..8a820a5c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -244,5 +244,28 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-20] Onboarding backdrop reuses marketing-site halftone webp assets copied into public/illustrations/ (not hotlinked, not regenerated): keeps app self-contained and signup->app visually continuous; decorative art uses plain (physics sizes by %, next/image adds nothing for 1-35KB webp). [2026-07-20] Removed Dependabot entirely (.github/dependabot.yml deleted, open PRs #1083/#1082/#1012 closed) on Emil's request: weekly grouped bumps were noise and the #884 bedrock-sdk incident showed the risk profile. Dependency bumps are now manual/deliberate; the bedrock-sdk 0.29.1 exact pin stays enforced by scripts/checks/no-new-antipatterns.mjs. [2026-07-20] Bulk reject (/pending) reuses the exact bulk-approve selection set: high-risk and locked-period ops stay one-by-one for reject too, keeping one selection model instead of per-action eligibility. Server-side bulk-reject has NO high-risk skip (rejecting posts nothing), so the API stays permissive; the UI is the gate. +[2026-07-21] Keep Skatteverket audit inserts service-role-only and fix the writer instead of adding an authenticated INSERT policy: regulator audit rows must not be forgeable by a user session. +[2026-07-21] Treat an empty year-end period as a structured conflict instead of creating a zero-value voucher: the journal engine correctly forbids meaningless zero entries, while the API now returns an actionable domain outcome and logs it as a warning. +[2026-07-21] Store kvarskatt payment dates exactly from the tax notice instead of calculating a 90-day offset: Skatteverket makes the notice date authoritative and reassessments can have shorter payment windows. +[2026-07-21] Persist Oasis first-value setup per company and keep it optional inside the dashboard: existing companies are backfilled as completed, and Skatteverket plus assistant setup never block access. +[2026-07-21] Enforce zero raw user-facing engine and database errors with an AST guard: aliases are traced while logger-only values remain allowed. +[2026-07-21] Persist year-end tax adjustments separately from journal entries and reuse them for tax provision plus INK2: detected 6992/8423 balances remain tax-only, a posted current-year periodiseringsfond is treated as a completed choice instead of new headroom, and a mismatched posted tax requires storno/correction rather than a duplicate voucher. +[2026-07-21] Share dashboard auth and active-company resolution with request-local React cache: nested layouts and pages now reuse one verified context while cross-request data remains uncached. +[2026-07-21] Progressively render large customer, supplier, article, and invoice lists in batches of 100: complete datasets remain available for filtering, sorting, statistics, and export while initial browser work stays bounded. +[2026-07-21] Give company-settings MCP writes a dedicated companies:write scope and medium risk: bank details redirect future invoice payments, so reusing a low-risk customer or generic bookkeeping scope would hide the payment-routing permission boundary. Keep the three new specialized settings and customer tools search-only in the default MCP catalog: gnubok_search_tools still returns and authorizes them while tools/list stays inside its locked context budget. +[2026-07-21] Cloud backup audit scaling keeps API-key security and configuration changes in behandlingshistorik but suppresses request-counter-only audit rows, and excludes non-portable extension_data from the Grunddata watermark: existing history stays immutable while telemetry growth and the backup's own state can no longer recreate the timeout. +[2026-07-21] Miles staging reproduction copies exact effective ledger balances plus the active periodiseringsfond through the bookkeeping engine, not production documents or obsolete closing audit chains: this preserves the disposition calculation while minimizing personal data and leaving A44/A1/A2 absent for a clean test. +[2026-07-21] Keep Bolagsverket connected filing disabled behind explicit public and server release gates: migrations, validator service, agreement, certificate, test-bank acceptance, and remote migration reconciliation must be complete before activation. +[2026-07-21] Treat signer rosters as mutable only while unbound and immutable once attached to an annual-report version: corrected versions either clone the latest roster or bind a newly supplied roster, while database serialization advances the version only after the final signature. +[2026-07-21] Keep Bolagsverket idnummer server-side for webhook correlation and troubleshooting but omit it from dashboard and MCP responses: the v2.1 contract marks it as a technical identifier that must not be shown to users. +[2026-07-21] Store retention_expires_at as the first legally permitted deletion date, 1 January of the eighth following calendar year: BFL 7 kap. 2 § requires preservation through the end of the seventh following calendar year, so period_end plus seven years ends too early for non-calendar fiscal years. +[2026-07-21] Restrict locked annual-report creation to the server service role and preserve the profile, disclosure, and eligibility snapshots with the validation result: a caller-controlled browser RPC cannot be allowed to assert its own compliance result, and the version must remain independently auditable after the live profile changes. +[2026-07-21] Keep the current K3 renderer available only as a review draft and fail closed before version locking or paper-filing readiness: its present note builder is not a complete applicability-driven K3 disclosure matrix, so claiming general K3 compliance would be misleading. +[2026-07-21] Bundle Source Sans 3 and Source Serif 4 under OFL-1.1 and store company TTF/WOFF files in a dedicated private bucket: invoice rendering embeds fonts server-side without a new runtime dependency, while tenant-scoped paths, parse validation, size limits, and Helvetica fallback keep uploaded fonts private and reliable. +[2026-07-21] Prepared issue #1064's end-to-end fixture on Supabase staging in the existing disposable ZZ Undo-YE Rehearsal 3 AB, renamed ZZ AR Narrative E2E AB: avoided the realistic Box 7 AB and preserved posted history through storno; left FY2025 open with balanced synthetic K2 entries and no closing entry. +[2026-07-21] Did not bulk-push the missing 20260721160000 annual-report migration while diagnosing the staging 500: staging has later applied versions plus other local-only gaps, and the 44 KB schema change exceeds fixture-seeding authority until explicitly approved for targeted application. +[2026-07-21] Applied only migration 20260721160000 to Supabase staging after explicit approval: used an isolated CLI workdir hydrated from staging history because the configured Supabase MCP targets production and a repository-wide push would include unrelated migration gaps; source and applied copy had identical SHA-256. [2026-07-21] Domain cutover is dual-domain, not full migration: app.gnubok.se stays serving /api + /.well-known forever (MCP connectors, API keys, SKV callback registered in Utvecklarportalen); only page traffic redirects to app.accounted.se, gated on NEXT_PUBLIC_APP_URL so the merge is inert. SKV OAuth callback rewritten cookie-free (state + stored oauth_user_id) because sessions no longer exist on the OAuth host; discovery docs host-reflect (allowlisted) for RFC 8414/9728 self-consistency. [2026-07-21] Fortnox/SIE atomic import timeout fixed by function-scoped statement_timeout (290s) on import_sie_journal_entries, not by chunking the RPC: chunking would reintroduce the partial-import states the atomic RPC exists to eliminate (20260712150000), and the 20260629160100 delete-path precedent already uses the same bound; pg-real ratchet pins the config on all three SIE RPCs because CREATE OR REPLACE silently drops ALTER FUNCTION settings. +[2026-07-21] Keep live annual-report narrative editable after a version is locked: immutable version snapshots preserve signing and filing content, while edits must remain possible to create a corrected superseding version. +[2026-07-21] Restrict annual-report signature evidence transitions to the server service role and structured opaque references: browser RLS may manage only unbound pending roster rows, so route validation cannot be bypassed and evidence references cannot carry free-text personal data. diff --git a/LICENSES/OFL-1.1.txt b/LICENSES/OFL-1.1.txt new file mode 100644 index 00000000..815c90bf --- /dev/null +++ b/LICENSES/OFL-1.1.txt @@ -0,0 +1,84 @@ +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/(auth)/loading.tsx b/app/(auth)/loading.tsx new file mode 100644 index 00000000..3bae78a3 --- /dev/null +++ b/app/(auth)/loading.tsx @@ -0,0 +1,5 @@ +import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton' + +export default function AuthLoading() { + return +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index e1146fd9..17b43763 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,6 +1,7 @@ 'use client' import { Suspense, useState, useEffect } from 'react' +import dynamic from 'next/dynamic' import { useRouter, useSearchParams } from 'next/navigation' import { useLocale, useTranslations } from 'next-intl' import Link from 'next/link' @@ -13,18 +14,23 @@ import { Loader2, Mail, ArrowLeft, KeyRound, ExternalLink } from 'lucide-react' import { BrandWordmark } from '@/components/branding/BrandWordmark' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { isBankIdEnabled } from '@/lib/auth/bankid' -import { BankIdAuth } from '@/components/auth/BankIdAuth' import { getBranding } from '@/lib/branding/service' import { detectWebmailHint } from '@/lib/auth/webmail-search' +import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton' const branding = getBranding() import type { BankIdResult } from '@/components/auth/BankIdAuth' +const BankIdAuth = dynamic( + () => import('@/components/auth/BankIdAuth').then((module) => module.BankIdAuth), + { ssr: false }, +) + // Wrapping in Suspense is required because useSearchParams() forces // dynamic rendering in Next.js 16; static prerender bails out otherwise. export default function LoginPage() { return ( - + }> ) @@ -157,7 +163,7 @@ function LoginPageContent() { if (error) { toast({ title: tAuth('login_failed_title'), - description: error.message === 'Invalid login credentials' + description: getErrorMessage(error) === 'Invalid login credentials' ? tAuth('login_invalid_credentials') : getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', diff --git a/app/(auth)/mfa/enroll/page.tsx b/app/(auth)/mfa/enroll/page.tsx index b66dc5cf..b4c15ff4 100644 --- a/app/(auth)/mfa/enroll/page.tsx +++ b/app/(auth)/mfa/enroll/page.tsx @@ -11,6 +11,7 @@ import { Loader2, ShieldCheck, Copy, Check, ArrowLeft } from 'lucide-react' import { getBranding } from '@/lib/branding/service' import { userHasPassword } from '@/lib/auth/has-password' import { safeReturnTo } from '@/lib/auth/safe-return-to' +import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' export default function MfaEnrollPage() { return ( @@ -83,7 +84,7 @@ function MfaEnrollContent() { if (error) { toast({ title: 'Kunde inte aktivera 2FA', - description: error.message, + description: getUserErrorMessage(error), variant: 'destructive', }) setIsEnrolling(false) diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 36f3fe1f..051b32c2 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, Suspense } from 'react' +import dynamic from 'next/dynamic' import { useSearchParams, useRouter } from 'next/navigation' import Link from 'next/link' import { useLocale, useTranslations } from 'next-intl' @@ -13,20 +14,21 @@ import { Loader2, Mail, ArrowLeft, ExternalLink } from 'lucide-react' import { BrandWordmark } from '@/components/branding/BrandWordmark' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { isBankIdEnabled } from '@/lib/auth/bankid' -import { BankIdAuth } from '@/components/auth/BankIdAuth' import type { BankIdResult } from '@/components/auth/BankIdAuth' import { getBranding } from '@/lib/branding/service' import { detectWebmailHint } from '@/lib/auth/webmail-search' +import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton' const branding = getBranding() +const BankIdAuth = dynamic( + () => import('@/components/auth/BankIdAuth').then((module) => module.BankIdAuth), + { ssr: false }, +) + export default function RegisterPage() { return ( - - - - }> + }> ) diff --git a/app/(dashboard)/articles/page.tsx b/app/(dashboard)/articles/page.tsx index 56270a30..cdae3efd 100644 --- a/app/(dashboard)/articles/page.tsx +++ b/app/(dashboard)/articles/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useMemo, useCallback, useRef, Suspense } from 'react' +import dynamic from 'next/dynamic' import { useLocale, useTranslations } from 'next-intl' import { useSearchParams, useRouter, usePathname } from 'next/navigation' import { createClient } from '@/lib/supabase/client' @@ -21,7 +22,6 @@ import { import { useToast } from '@/components/ui/use-toast' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { Plus, Search, Package, Lock, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react' -import ArticleForm from '@/components/articles/ArticleForm' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' import { useSubmitWithAccountActivation, @@ -37,6 +37,19 @@ import { useCompany } from '@/contexts/CompanyContext' import { useCanWrite } from '@/lib/hooks/use-can-write' import type { Article, ArticleType, CreateArticleInput } from '@/types' +const ArticleForm = dynamic( + () => import('@/components/articles/ArticleForm'), + { + loading: () => ( +
+ + + +
+ ), + }, +) + const ARTICLE_TYPE_LABEL_KEYS: Record = { vara: 'type_vara', tjanst: 'type_tjanst', @@ -53,6 +66,7 @@ const SORTABLE_COLUMNS: ReadonlyArray = [ 'price_excl_vat', 'vat_rate', ] +const INITIAL_VISIBLE_ROWS = 100 function compareStrings(a: string, b: string): number { return a.localeCompare(b, 'sv', { sensitivity: 'base' }) @@ -64,11 +78,13 @@ function ArticlesPageInner() { const [articles, setArticles] = useState([]) const [isLoading, setIsLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') + const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS) const [isDialogOpen, setIsDialogOpen] = useState(false) const [isCreating, setIsCreating] = useState(false) const { toast } = useToast() const supabase = createClient() const t = useTranslations('articles') + const tCommon = useTranslations('common') const errorLocale = useLocale() as ErrorLocale const router = useRouter() @@ -85,6 +101,7 @@ function ArticlesPageInner() { const updateSort = useCallback( (column: SortColumn) => { + setVisibleCount(INITIAL_VISIBLE_ROWS) const params = new URLSearchParams(searchParams.toString()) let nextDir: SortDir = 'asc' if (column === sortColumn) { @@ -208,6 +225,7 @@ function ArticlesPageInner() { }) return arr }, [filteredArticles, sortColumn, sortDir]) + const visibleArticles = sortedArticles.slice(0, visibleCount) function SortableHeader({ column, @@ -281,7 +299,10 @@ function ArticlesPageInner() { setSearchTerm(e.target.value)} + onChange={(e) => { + setSearchTerm(e.target.value) + setVisibleCount(INITIAL_VISIBLE_ROWS) + }} className="pl-10" /> @@ -358,7 +379,7 @@ function ArticlesPageInner() { - {sortedArticles.map((article) => ( + {visibleArticles.map((article) => ( - {sortedArticles.map((article) => ( + {visibleArticles.map((article) => ( @@ -430,6 +451,17 @@ function ArticlesPageInner() { ))} + {visibleCount < sortedArticles.length && ( +
+ +
+ )} )} diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index abc52c58..8fb5762e 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -1,12 +1,14 @@ 'use client' import { useState, useEffect, useMemo } from 'react' +import dynamic from 'next/dynamic' import { useRouter, useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import JournalEntryList from '@/components/bookkeeping/JournalEntryList' import { type FormLine } from '@/components/bookkeeping/JournalEntryForm' -import NewJournalEntryDialog, { type CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog' +import type { CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog' +import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton' import AgentSparkleButton from '@/components/agent/AgentSparkleButton' import { useToast } from '@/components/ui/use-toast' import { Plus } from 'lucide-react' @@ -14,6 +16,11 @@ import { PageHeader } from '@/components/ui/page-header' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import type { JournalEntry, JournalEntryLine } from '@/types' +const NewJournalEntryDialog = dynamic( + () => import('@/components/bookkeeping/NewJournalEntryDialog'), + { loading: DialogLoadingSkeleton }, +) + interface NextVoucher { next: number series: string @@ -153,20 +160,22 @@ export default function BookkeepingPage() { - { - setShowNewEntry(o) - if (!o) setCopyPrefill(null) - }} - onCreated={() => { - setRefreshKey((k) => k + 1) - setShowNewEntry(false) - setCopyPrefill(null) - }} - copyPrefill={copyPrefill} - isLoading={isLoadingCopy} - /> + {showNewEntry && ( + { + setShowNewEntry(o) + if (!o) setCopyPrefill(null) + }} + onCreated={() => { + setRefreshKey((k) => k + 1) + setShowNewEntry(false) + setCopyPrefill(null) + }} + copyPrefill={copyPrefill} + isLoading={isLoadingCopy} + /> + )} ) } diff --git a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx index 7502bf33..0e8aeacc 100644 --- a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' +import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -10,21 +11,29 @@ import { Skeleton } from '@/components/ui/skeleton' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' +import { Checkbox } from '@/components/ui/checkbox' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { PageHeader } from '@/components/ui/page-header' -import { ArrowLeft, FileDown, Plus, ExternalLink, Loader2, Save, CheckCircle2 } from 'lucide-react' +import { ArrowLeft, FileDown, Plus, ExternalLink, Loader2, Save, CheckCircle2, Trash2 } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { formatCurrency } from '@/lib/utils' import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' import { DigitalInlamning, INLAMNING_COMING_SOON } from '@/components/bokslut/DigitalInlamning' +import { AnnualReportStudio } from '@/components/bokslut/AnnualReportStudio' import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types' import type { SignatureRequest } from '@/lib/bokslut/arsredovisning/signature-service' +import type { AnnualReportVersionSummary } from '@/lib/bokslut/arsredovisning/compliance-types' +import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' + +const SIGNATURE_EVIDENCE_REFERENCE_PATTERN = + /^(archive|document|receipt):[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$/ export default function ArsredovisningPage() { const router = useRouter() const searchParams = useSearchParams() const periodId = searchParams.get('period') const { toast } = useToast() + const tStudio = useTranslations('annualReportStudio') const [data, setData] = useState(null) const [signatures, setSignatures] = useState([]) @@ -37,11 +46,19 @@ export default function ArsredovisningPage() { const [description, setDescription] = useState('') const [importantEvents, setImportantEvents] = useState('') const [resultatdisposition, setResultatdisposition] = useState('') + const [proposedDividend, setProposedDividend] = useState('') const [savedDescription, setSavedDescription] = useState('') const [savedImportantEvents, setSavedImportantEvents] = useState('') const [savedResultatdisposition, setSavedResultatdisposition] = useState('') + const [savedProposedDividend, setSavedProposedDividend] = useState('') const [agmDate, setAgmDate] = useState('') const [savedAgmDate, setSavedAgmDate] = useState('') + const [agmDispositionOutcome, setAgmDispositionOutcome] = useState< + '' | 'proposal_approved' | 'alternative_decision' + >('') + const [savedAgmDispositionOutcome, setSavedAgmDispositionOutcome] = useState('') + const [agmDispositionDecision, setAgmDispositionDecision] = useState('') + const [savedAgmDispositionDecision, setSavedAgmDispositionDecision] = useState('') // Disclosure fields per ÅRL 5:13-15 § + BFNAR koncernförhållanden. // Persisted via the same POST endpoint as the förvaltningsberättelse text. const [longTermDebt, setLongTermDebt] = useState('') @@ -56,12 +73,37 @@ export default function ArsredovisningPage() { const [savedParentOrgNr, setSavedParentOrgNr] = useState('') const [parentCity, setParentCity] = useState('') const [savedParentCity, setSavedParentCity] = useState('') + const [longTermDebtConfirmed, setLongTermDebtConfirmed] = useState(false) + const [savedLongTermDebtConfirmed, setSavedLongTermDebtConfirmed] = useState(false) + const [securitiesPledgedConfirmed, setSecuritiesPledgedConfirmed] = useState(false) + const [savedSecuritiesPledgedConfirmed, setSavedSecuritiesPledgedConfirmed] = useState(false) + const [contingentLiabilitiesConfirmed, setContingentLiabilitiesConfirmed] = useState(false) + const [savedContingentLiabilitiesConfirmed, setSavedContingentLiabilitiesConfirmed] = useState(false) + const [parentCompanyConfirmed, setParentCompanyConfirmed] = useState(false) + const [savedParentCompanyConfirmed, setSavedParentCompanyConfirmed] = useState(false) const [savingNarrative, setSavingNarrative] = useState(false) - const [savedAt, setSavedAt] = useState(null) + const [narrativeRevision, setNarrativeRevision] = useState(null) // Add-signer form const [signerName, setSignerName] = useState('') const [signerRole, setSignerRole] = useState('Styrelseledamot') + const [versions, setVersions] = useState([]) + const [selectedSignatureVersionId, setSelectedSignatureVersionId] = useState('') + const [signingMethod, setSigningMethod] = useState< + 'paper_original' | 'advanced_e_signature' | 'bankid' + >('paper_original') + const [signatureEvidence, setSignatureEvidence] = useState('') + const [signatureDate, setSignatureDate] = useState(() => new Date().toISOString().slice(0, 10)) + + const handleVersionsChanged = useCallback((nextVersions: AnnualReportVersionSummary[]) => { + setVersions(nextVersions) + setSelectedSignatureVersionId((current) => { + if (nextVersions.some((version) => version.id === current && version.status === 'ready_for_signature')) { + return current + } + return nextVersions.find((version) => version.status === 'ready_for_signature')?.id ?? '' + }) + }, []) useEffect(() => { if (!periodId) return @@ -75,7 +117,7 @@ export default function ArsredovisningPage() { .then(([arBody, sigBody]) => { if (cancelled) return if (arBody?.error) { - setError(arBody.error.message ?? 'Kunde inte hämta årsredovisning') + setError(getUserErrorMessage(arBody.error) ?? 'Kunde inte hämta årsredovisning') return } const d = arBody.data as ArsredovisningData @@ -87,11 +129,18 @@ export default function ArsredovisningPage() { setDescription(d.forvaltningsberattelse.description) setImportantEvents(d.forvaltningsberattelse.important_events) setResultatdisposition(d.forvaltningsberattelse.resultatdisposition) + const dividend = String(d.forvaltningsberattelse.proposed_dividend || '') + setProposedDividend(dividend) setAgmDate(d.forvaltningsberattelse.agm_date ?? '') setSavedDescription(d.forvaltningsberattelse.description) setSavedImportantEvents(d.forvaltningsberattelse.important_events) setSavedResultatdisposition(d.forvaltningsberattelse.resultatdisposition) + setSavedProposedDividend(dividend) setSavedAgmDate(d.forvaltningsberattelse.agm_date ?? '') + setAgmDispositionOutcome(d.forvaltningsberattelse.agm_disposition_outcome ?? '') + setSavedAgmDispositionOutcome(d.forvaltningsberattelse.agm_disposition_outcome ?? '') + setAgmDispositionDecision(d.forvaltningsberattelse.agm_disposition_decision ?? '') + setSavedAgmDispositionDecision(d.forvaltningsberattelse.agm_disposition_decision ?? '') const ltd = d.disclosures.long_term_debt_over_five_years const ltdStr = ltd != null ? String(ltd) : '' setLongTermDebt(ltdStr) @@ -106,6 +155,14 @@ export default function ArsredovisningPage() { setSavedParentOrgNr(d.disclosures.parent_company_org_number ?? '') setParentCity(d.disclosures.parent_company_city ?? '') setSavedParentCity(d.disclosures.parent_company_city ?? '') + setLongTermDebtConfirmed(d.disclosures.confirmations.long_term_debt_over_five_years) + setSavedLongTermDebtConfirmed(d.disclosures.confirmations.long_term_debt_over_five_years) + setSecuritiesPledgedConfirmed(d.disclosures.confirmations.securities_pledged) + setSavedSecuritiesPledgedConfirmed(d.disclosures.confirmations.securities_pledged) + setContingentLiabilitiesConfirmed(d.disclosures.confirmations.contingent_liabilities) + setSavedContingentLiabilitiesConfirmed(d.disclosures.confirmations.contingent_liabilities) + setParentCompanyConfirmed(d.disclosures.confirmations.parent_company) + setSavedParentCompanyConfirmed(d.disclosures.confirmations.parent_company) setSignatures((sigBody.data ?? []) as SignatureRequest[]) }) .catch(() => { @@ -123,13 +180,20 @@ export default function ArsredovisningPage() { description !== savedDescription || importantEvents !== savedImportantEvents || resultatdisposition !== savedResultatdisposition || + proposedDividend !== savedProposedDividend || agmDate !== savedAgmDate || + agmDispositionOutcome !== savedAgmDispositionOutcome || + agmDispositionDecision !== savedAgmDispositionDecision || longTermDebt !== savedLongTermDebt || securitiesPledged !== savedSecuritiesPledged || contingentLiabilities !== savedContingentLiabilities || parentName !== savedParentName || parentOrgNr !== savedParentOrgNr || - parentCity !== savedParentCity + parentCity !== savedParentCity || + longTermDebtConfirmed !== savedLongTermDebtConfirmed || + securitiesPledgedConfirmed !== savedSecuritiesPledgedConfirmed || + contingentLiabilitiesConfirmed !== savedContingentLiabilitiesConfirmed || + parentCompanyConfirmed !== savedParentCompanyConfirmed const handleSaveNarrative = useCallback(async () => { if (!periodId) return @@ -150,6 +214,19 @@ export default function ArsredovisningPage() { } longTermDebtParsed = parsed } + let proposedDividendParsed = 0 + if (proposedDividend.trim()) { + const parsed = Number(proposedDividend.replace(/\s/g, '').replace(',', '.')) + if (!Number.isFinite(parsed) || parsed < 0) { + toast({ + title: 'Ogiltigt belopp', + description: 'Föreslagen utdelning måste vara noll eller ett positivt belopp.', + variant: 'destructive', + }) + return + } + proposedDividendParsed = Math.round(parsed * 100) / 100 + } setSavingNarrative(true) try { const res = await fetch( @@ -161,13 +238,23 @@ export default function ArsredovisningPage() { description, important_events: importantEvents, resultatdisposition, + proposed_dividend: proposedDividendParsed, agm_date: agmDate || null, + agm_disposition_outcome: agmDispositionOutcome || null, + agm_disposition_decision: + agmDispositionOutcome === 'alternative_decision' + ? agmDispositionDecision.trim() || null + : null, long_term_debt_over_five_years: longTermDebtParsed, securities_pledged: securitiesPledged.trim() || null, contingent_liabilities: contingentLiabilities.trim() || null, parent_company_name: parentName.trim() || null, parent_company_org_number: parentOrgNr.trim() || null, parent_company_city: parentCity.trim() || null, + long_term_debt_over_five_years_confirmed: longTermDebtConfirmed, + securities_pledged_confirmed: securitiesPledgedConfirmed, + contingent_liabilities_confirmed: contingentLiabilitiesConfirmed, + parent_company_confirmed: parentCompanyConfirmed, }), }, ) @@ -175,7 +262,7 @@ export default function ArsredovisningPage() { if (!res.ok) { toast({ title: 'Kunde inte spara texten', - description: body?.error?.message ?? '', + description: getUserErrorMessage(body?.error) ?? '', variant: 'destructive', }) return @@ -183,18 +270,27 @@ export default function ArsredovisningPage() { setSavedDescription(description) setSavedImportantEvents(importantEvents) setSavedResultatdisposition(resultatdisposition) + setSavedProposedDividend(proposedDividend) setSavedAgmDate(agmDate) + setSavedAgmDispositionOutcome(agmDispositionOutcome) + setSavedAgmDispositionDecision(agmDispositionDecision) setSavedLongTermDebt(longTermDebt) setSavedSecuritiesPledged(securitiesPledged) setSavedContingentLiabilities(contingentLiabilities) setSavedParentName(parentName) setSavedParentOrgNr(parentOrgNr) setSavedParentCity(parentCity) - setSavedAt(Date.now()) + setSavedLongTermDebtConfirmed(longTermDebtConfirmed) + setSavedSecuritiesPledgedConfirmed(securitiesPledgedConfirmed) + setSavedContingentLiabilitiesConfirmed(contingentLiabilitiesConfirmed) + setSavedParentCompanyConfirmed(parentCompanyConfirmed) + setNarrativeRevision( + typeof body.data?.updated_at === 'string' ? body.data.updated_at : null, + ) } catch (err) { toast({ title: 'Kunde inte spara texten', - description: err instanceof Error ? err.message : 'Okänt fel', + description: err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel', variant: 'destructive', }) } finally { @@ -205,33 +301,59 @@ export default function ArsredovisningPage() { description, importantEvents, resultatdisposition, + proposedDividend, agmDate, + agmDispositionOutcome, + agmDispositionDecision, longTermDebt, securitiesPledged, contingentLiabilities, parentName, parentOrgNr, parentCity, + longTermDebtConfirmed, + securitiesPledgedConfirmed, + contingentLiabilitiesConfirmed, + parentCompanyConfirmed, toast, ]) const handleMarkSigned = useCallback( async (signatureId: string) => { if (!periodId) return + if ( + !selectedSignatureVersionId || + !SIGNATURE_EVIDENCE_REFERENCE_PATTERN.test(signatureEvidence.trim()) || + !signatureDate + ) { + toast({ + title: 'Underskriftsbevis saknas', + description: + 'Välj en låst version och ange datum samt en strukturerad referens, till exempel archive:AR-2026-001.', + variant: 'destructive', + }) + return + } try { const res = await fetch( `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/signatures/${signatureId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: 'signed' }), + body: JSON.stringify({ + status: 'signed', + annual_report_version_id: selectedSignatureVersionId, + signing_method: signingMethod, + evidence_reference: signatureEvidence.trim(), + signed_at: new Date(`${signatureDate}T12:00:00`).toISOString(), + }), }, ) const body = await res.json() if (!res.ok) { toast({ title: 'Kunde inte markera som signerad', - description: body?.error?.message ?? '', + description: getUserErrorMessage(body?.error) ?? '', variant: 'destructive', }) return @@ -239,16 +361,31 @@ export default function ArsredovisningPage() { setSignatures((prev) => prev.map((s) => (s.id === signatureId ? (body.data as SignatureRequest) : s)), ) + const versionsResponse = await fetch( + `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/versions`, + ) + if (versionsResponse.ok) { + const versionsBody = await versionsResponse.json() + handleVersionsChanged((versionsBody.data ?? []) as AnnualReportVersionSummary[]) + } toast({ title: 'Underskrift registrerad' }) } catch (err) { toast({ title: 'Kunde inte markera som signerad', - description: err instanceof Error ? err.message : 'Okänt fel', + description: err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel', variant: 'destructive', }) } }, - [periodId, toast], + [ + handleVersionsChanged, + periodId, + selectedSignatureVersionId, + signatureDate, + signatureEvidence, + signingMethod, + toast, + ], ) const handleAddSigner = useCallback(async () => { @@ -266,7 +403,7 @@ export default function ArsredovisningPage() { if (!res.ok) { toast({ title: 'Kunde inte lägga till undertecknare', - description: body?.error?.message ?? '', + description: getUserErrorMessage(body?.error) ?? '', variant: 'destructive', }) return @@ -277,12 +414,37 @@ export default function ArsredovisningPage() { } catch (err) { toast({ title: 'Kunde inte lägga till undertecknare', - description: err instanceof Error ? err.message : 'Okänt fel', + description: err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel', variant: 'destructive', }) } }, [periodId, signerName, signerRole, toast]) + const handleRemoveSigner = useCallback( + async (signatureId: string) => { + if (!periodId) return + try { + const response = await fetch( + `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/signatures/${signatureId}`, + { method: 'DELETE' }, + ) + if (!response.ok) { + const body = await response.json() + throw new Error(getUserErrorMessage(body?.error)) + } + setSignatures((current) => current.filter((signature) => signature.id !== signatureId)) + toast({ title: tStudio('signer_removed') }) + } catch (err) { + toast({ + title: tStudio('signer_remove_error'), + description: err instanceof Error ? getUserErrorMessage(err) : undefined, + variant: 'destructive', + }) + } + }, + [periodId, tStudio, toast], + ) + if (!periodId) { return (
@@ -381,6 +543,16 @@ export default function ArsredovisningPage() { )} + + Förvaltningsberättelse: narrativ @@ -417,6 +589,20 @@ export default function ArsredovisningPage() { rows={3} />
+
+ + setProposedDividend(event.target.value)} + placeholder="0" + className="min-h-11 max-w-[220px] tabular-nums" + /> +

+ Beloppet används i resultatdispositionen i samma version av PDF och iXBRL. +

+
+
+ + +
+ {agmDispositionOutcome === 'alternative_decision' && ( +
+ + `;} +function summary(state){ + const d=TT[state]; const m=n=>state==='full'?money(n,true):money(n); + return `
+
Delsumma${m(d.sub)}
+
Moms 25 %${m(d.vat)}
+ ${d.round?`
Öresavrundning+${money(d.round,true)}
`:''} + ${d.rot?`
ROT-avdrag−${money(d.rot,true)}
`:''} +
Att betala${money(d.pay)}
+ ${d.rot?`
Total inkl. moms${money(d.total,true)}
`:''} +
Öresavrundning
+
`; +} +const actions=`
`; +const custBlock=(state)=> state==='empty' + ? `` + : `
${CUST.name}
${CUST.org} · ${CUST.addr}
`; + +/* A — Faktabladet (letterhead) */ +function vLetter(state){ + return `
+
+
+
+
+
Kvist Studio AB
Hornsgatan 1, 118 20 Stockholm · Org 559111-2233
+
Faktura
F2026-014
+
+
Faktureras till
${custBlock(state)}
+
${metaGrid(state)}
+
Rader
+ ${lineList(state)} + ${state==='full'?`
${rotClaim(state)}
`:''} +
+
Anteckningar
${notes(state)}
+
${summary(state)}
+
+
${actions} +
`; +} + +/* B — Rubrik (editorial title) */ +function vTitle(state){ + return `
+
+
Faktura F2026-014
+ ${V(state)?`
${CUST.name}
${CUST.org} · ${CUST.addr}
`:`
Ny faktura
`}
+
+
+ ${state==='empty'?`
${custBlock(state)}
`:''} +
Detaljer
${metaGrid(state)}
+
Rader
${lineList(state)}
+ ${state==='full'?`
${rotClaim(state)}
`:''} +
Anteckningar
${notes(state)}
+
+
${actions}
${summary(state)}
+
+
`; +} + +/* C — Stegduk (numbered steps) */ +function vSteps(state){ + const step=(n,label,body,hint='')=>`
${n}${label}${hint?`${hint}`:''}
${body}
`; + return `
+
Ny kundfaktura · F2026-014
+
+ ${step(1,'Kund', custBlock(state))} + ${step(2,'Rader', lineList(state))} + ${step(3,'Villkor', metaGrid(state))} + ${step(4,'Skattereduktion', state==='full'?rotClaim(state).replace('
','
'):`
Inga ROT/RUT-avdrag. Läggs till per rad under Rader.
`, state==='full'?'':'valfritt')} + ${step(5,'Anteckningar', notes(state),'valfritt')} +
${actions}
${summary(state)}
+
`; +} + +const VARIANTS=[ + { key:'faktabladet', name:'Faktabladet', render:vLetter, + desc:'Fakturablad med brevhuvud: logga/org uppe, nr till höger, kund, sedan villkoren i ett luftigt fältrutnät, rader, summa. Alla fält, dokumentkänsla.' }, + { key:'rubrik', name:'Rubrik', render:vTitle, + desc:'Kundnamnet som stor serif-rubrik, all meta i en lugn "Detaljer"-yta, rena rader. Redaktionellt och lätt att läsa. Håller allt utan att kännas fullt.' }, + { key:'stegduk', name:'Stegduk', render:vSteps, + desc:'Numrerade steg: Kund → Rader → Villkor → Skattereduktion → Anteckningar → Summa. Varje fält får sin plats, inget trängs. Tydligast.' }, +]; + +let idx=0, state='full'; +const stage=document.getElementById('stage'); +function draw(){ + const v=VARIANTS[idx]; + stage.innerHTML=v.render(state); + document.getElementById('pname').textContent=v.name; + document.getElementById('pkey').textContent=v.key.toUpperCase(); + document.getElementById('pdesc').textContent=v.desc; + document.getElementById('pcount').textContent=` ${idx+1}/${VARIANTS.length}`; + stage.parentElement.scrollTop=0; +} +function go(d){ idx=(idx+d+VARIANTS.length)%VARIANTS.length; draw(); } +document.getElementById('prev').onclick=()=>go(-1); +document.getElementById('next').onclick=()=>go(1); +document.querySelectorAll('.picker-states button').forEach(b=>{ b.onclick=()=>{ state=b.dataset.state; document.querySelectorAll('.picker-states button').forEach(x=>x.classList.toggle('on',x===b)); draw(); }; }); +document.addEventListener('keydown',e=>{ const t=e.target.tagName; if(t==='INPUT'||t==='TEXTAREA'||t==='SELECT'||e.target.isContentEditable) return; if(e.key==='ArrowLeft') go(-1); if(e.key==='ArrowRight') go(1); }); +(function(){ const p=document.getElementById('picker'),h=document.getElementById('pickerHandle'); let dx=0,dy=0,drag=false; + h.addEventListener('mousedown',e=>{drag=true;dx=e.clientX-p.offsetLeft;dy=e.clientY-p.offsetTop;p.style.right='auto';p.style.bottom='auto';h.style.cursor='grabbing';}); + document.addEventListener('mousemove',e=>{if(!drag)return;p.style.left=(e.clientX-dx)+'px';p.style.top=(e.clientY-dy)+'px';}); + document.addEventListener('mouseup',()=>{drag=false;h.style.cursor='grab';}); })(); +draw(); + + + diff --git a/prototypes/invoice-editor-redesign.prototype.html b/prototypes/invoice-editor-redesign.prototype.html new file mode 100644 index 00000000..7a924b91 --- /dev/null +++ b/prototypes/invoice-editor-redesign.prototype.html @@ -0,0 +1,281 @@ + + + + + +PROTOTYPE — R4 finish (F1–F4) + + + + + + + +

R4 finish — rubrikrad + radåtgärder

kropp låst · header med Beskrivning · lägg-till & ⋮ varieras (2×2)
+
+ +
+
Finish
+ +
+
Läge
+
Moms
+
+
← / → för att byta variant
+
+ + + + diff --git a/public/fonts/invoice/LICENSE-Source-Sans-3.md b/public/fonts/invoice/LICENSE-Source-Sans-3.md new file mode 100644 index 00000000..69fa3e45 --- /dev/null +++ b/public/fonts/invoice/LICENSE-Source-Sans-3.md @@ -0,0 +1,93 @@ +Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/invoice/LICENSE-Source-Serif-4.md b/public/fonts/invoice/LICENSE-Source-Serif-4.md new file mode 100644 index 00000000..5871e1f3 --- /dev/null +++ b/public/fonts/invoice/LICENSE-Source-Serif-4.md @@ -0,0 +1,93 @@ +Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/invoice/SourceSans3-Bold.ttf b/public/fonts/invoice/SourceSans3-Bold.ttf new file mode 100644 index 00000000..85693ccc Binary files /dev/null and b/public/fonts/invoice/SourceSans3-Bold.ttf differ diff --git a/public/fonts/invoice/SourceSans3-BoldItalic.ttf b/public/fonts/invoice/SourceSans3-BoldItalic.ttf new file mode 100644 index 00000000..1a83e3fb Binary files /dev/null and b/public/fonts/invoice/SourceSans3-BoldItalic.ttf differ diff --git a/public/fonts/invoice/SourceSans3-Italic.ttf b/public/fonts/invoice/SourceSans3-Italic.ttf new file mode 100644 index 00000000..8e0576f2 Binary files /dev/null and b/public/fonts/invoice/SourceSans3-Italic.ttf differ diff --git a/public/fonts/invoice/SourceSans3-Regular.ttf b/public/fonts/invoice/SourceSans3-Regular.ttf new file mode 100644 index 00000000..c5dd0b88 Binary files /dev/null and b/public/fonts/invoice/SourceSans3-Regular.ttf differ diff --git a/public/fonts/invoice/SourceSerif4-Bold.ttf b/public/fonts/invoice/SourceSerif4-Bold.ttf new file mode 100644 index 00000000..c4159ff2 Binary files /dev/null and b/public/fonts/invoice/SourceSerif4-Bold.ttf differ diff --git a/public/fonts/invoice/SourceSerif4-BoldItalic.ttf b/public/fonts/invoice/SourceSerif4-BoldItalic.ttf new file mode 100644 index 00000000..40f3863f Binary files /dev/null and b/public/fonts/invoice/SourceSerif4-BoldItalic.ttf differ diff --git a/public/fonts/invoice/SourceSerif4-Italic.ttf b/public/fonts/invoice/SourceSerif4-Italic.ttf new file mode 100644 index 00000000..e818087d Binary files /dev/null and b/public/fonts/invoice/SourceSerif4-Italic.ttf differ diff --git a/public/fonts/invoice/SourceSerif4-Regular.ttf b/public/fonts/invoice/SourceSerif4-Regular.ttf new file mode 100644 index 00000000..964a2bf8 Binary files /dev/null and b/public/fonts/invoice/SourceSerif4-Regular.ttf differ diff --git a/scripts/checks/eslint-baseline.json b/scripts/checks/eslint-baseline.json index fa9a6bd7..4ff899b6 100644 --- a/scripts/checks/eslint-baseline.json +++ b/scripts/checks/eslint-baseline.json @@ -1,12 +1,12 @@ { - "totalErrors": 59, + "totalErrors": 53, "perRule": { "@next/next/no-assign-module-variable": 1, "@typescript-eslint/no-explicit-any": 14, - "prefer-const": 3, - "react-hooks/preserve-manual-memoization": 6, + "prefer-const": 1, + "react-hooks/preserve-manual-memoization": 5, "react-hooks/purity": 1, - "react-hooks/set-state-in-effect": 28, + "react-hooks/set-state-in-effect": 25, "react-hooks/static-components": 6 } } diff --git a/scripts/checks/no-new-antipatterns.mjs b/scripts/checks/no-new-antipatterns.mjs index 2ffe0b3c..ed6769df 100644 --- a/scripts/checks/no-new-antipatterns.mjs +++ b/scripts/checks/no-new-antipatterns.mjs @@ -26,6 +26,9 @@ * whose package.json spec or locked version drifted from the pin. Guards * against a repeat of the @anthropic-ai/bedrock-sdk 0.32.0 prod outage * (empty Bedrock stream). No baseline: any drift is a hard failure. + * 5. raw-user-error: raw caught-error messages passed to API response fields, + * client error state, or toast fields. Engine, database, and upstream + * messages must pass through getErrorMessage() or errorResponse(). * * Usage: * node scripts/checks/no-new-antipatterns.mjs # check (CI) @@ -36,6 +39,7 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import ts from 'typescript' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') const BASELINE_PATH = path.join(ROOT, 'scripts', 'checks', 'antipatterns-baseline.json') @@ -167,11 +171,285 @@ function findPinnedDepViolations() { return out } +const USER_ERROR_FIELD_NAMES = new Set([ + 'description', + 'detail', + 'details', + 'error', + 'message', + 'reason', + 'title', +]) + +function propertyNameText(name) { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text + } + return null +} + +function propertyPath(node) { + const parts = [] + let current = node + while (ts.isPropertyAccessExpression(current)) { + parts.unshift(current.name.text) + current = current.expression + } + if (ts.isIdentifier(current)) parts.unshift(current.text) + return parts +} + +function isRawErrorMessage(node) { + if (!ts.isPropertyAccessExpression(node) || node.name.text !== 'message') return false + const parts = propertyPath(node) + if (parts.length < 2) return false + const root = parts[0] + return ( + /^(?:e|err|error|cause)$/i.test(root) || + /(?:Error|Err)$/.test(root) || + parts.slice(0, -1).some((part) => /^(?:error|first_error)$/i.test(part)) + ) +} + +function isErrorLikeIdentifier(node) { + return ts.isIdentifier(node) && ( + /^(?:e|err|error|cause)$/i.test(node.text) || /(?:Error|Err)$/.test(node.text) + ) +} + +function isRawErrorString(node) { + return ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'String' && + node.arguments.length === 1 && + isErrorLikeIdentifier(node.arguments[0]) + ) +} + +function containsRawErrorMessage(node) { + let found = false + const visit = (child) => { + if (found) return + if (isRawErrorMessage(child) || isRawErrorString(child)) { + found = true + return + } + ts.forEachChild(child, visit) + } + visit(node) + return found +} + +function enclosingCatch(node) { + let current = node.parent + while (current) { + if (ts.isCatchClause(current)) return current + if (ts.isFunctionLike(current)) return null + current = current.parent + } + return null +} + +const taintedCatchNames = new WeakMap() + +function getTaintedNames(catchClause) { + const cached = taintedCatchNames.get(catchClause) + if (cached) return cached + + const declarations = [] + const collect = (node) => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer + ) { + declarations.push(node) + } + ts.forEachChild(node, collect) + } + collect(catchClause.block) + + const names = new Set() + + const isTaintedValue = (node) => { + if (isRawErrorMessage(node) || isRawErrorString(node)) return true + if (ts.isCallExpression(node) && callName(node) === 'getErrorMessage') return false + if (ts.isConditionalExpression(node)) { + return isTaintedValue(node.whenTrue) || isTaintedValue(node.whenFalse) + } + if (ts.isBinaryExpression(node) && node.operatorToken.kind !== ts.SyntaxKind.PlusToken) { + return false + } + let tainted = false + const visit = (child) => { + if (tainted) return + if (isRawErrorMessage(child) || isRawErrorString(child)) { + tainted = true + return + } + if (ts.isIdentifier(child) && names.has(child.text)) { + tainted = true + return + } + if (ts.isCallExpression(child) && callName(child) === 'getErrorMessage') return + ts.forEachChild(child, visit) + } + ts.forEachChild(node, visit) + return tainted + } + + let changed = true + while (changed) { + changed = false + for (const declaration of declarations) { + if (names.has(declaration.name.text)) continue + const tainted = isTaintedValue(declaration.initializer) + if (tainted) { + names.add(declaration.name.text) + changed = true + } + } + } + + taintedCatchNames.set(catchClause, names) + return names +} + +function containsRawOrTaintedError(node) { + if (containsRawErrorMessage(node)) return true + const catchClause = enclosingCatch(node) + if (!catchClause) return false + const names = getTaintedNames(catchClause) + let found = false + const visit = (child) => { + if (found) return + if (ts.isIdentifier(child) && names.has(child.text)) { + found = true + return + } + if (ts.isPropertyAssignment(child)) { + visit(child.initializer) + return + } + ts.forEachChild(child, visit) + } + visit(node) + return found +} + +function callName(call) { + const expression = call.expression + if (ts.isIdentifier(expression)) return expression.text + if (ts.isPropertyAccessExpression(expression)) return expression.name.text + return '' +} + +function isLoggingCall(call) { + const expression = call.expression + if (!ts.isPropertyAccessExpression(expression)) return false + const owner = expression.expression.getText() + return ( + owner === 'console' || + /(?:^|\.)log$/.test(owner) || + /Log$/.test(owner) || + owner.endsWith('Logger') + ) +} + +function ancestorCall(node, predicate = () => true) { + let current = node.parent + while (current) { + if (ts.isCallExpression(current) && predicate(current)) return current + if (ts.isFunctionLike(current)) return null + current = current.parent + } + return null +} + +function isApiResponseCall(call) { + const name = callName(call) + if (/^(?:errorResponse|errorResponseFromCode|getErrorMessage)$/.test(name)) return false + if (/^(?:json|v1ErrorResponse|v1ErrorResponseFromCode)$/.test(name)) return true + if (ts.isPropertyAccessExpression(call.expression)) { + return call.expression.name.text === 'json' + } + return false +} + +function isClientErrorSetter(call) { + const name = callName(call) + return name === 'toast' || /^set[A-Z].*(?:Error|Message)$/.test(name) || name === 'setError' +} + +/** + * Raw caught-error messages in user-visible sinks. This is deliberately an + * AST check: line regexes cannot distinguish a logger payload from a JSON + * response, nor a Zod issue message from err.message. + */ +function findRawUserErrors() { + const files = [ + ...walk(path.join(ROOT, 'app', 'api'), ['route.ts']), + ...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']).filter((f) => !rel(f).startsWith('app/api/')), + ...walk(path.join(ROOT, 'components'), ['.ts', '.tsx']), + ] + const findings = [] + + for (const file of files) { + const sourceText = fs.readFileSync(file, 'utf8') + const source = ts.createSourceFile( + file, + sourceText, + ts.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ) + const isApi = rel(file).startsWith('app/api/') + + const add = (node) => { + const pos = source.getLineAndCharacterOfPosition(node.getStart(source)) + findings.push(`${rel(file)}:${pos.line + 1}`) + } + + const visit = (node) => { + if (ts.isPropertyAssignment(node)) { + const field = propertyNameText(node.name) + if ( + field && + USER_ERROR_FIELD_NAMES.has(field) && + !ts.isObjectLiteralExpression(node.initializer) && + containsRawOrTaintedError(node.initializer) + ) { + const loggingCall = ancestorCall(node, isLoggingCall) + const clientSink = ancestorCall(node, isClientErrorSetter) + if (!loggingCall) { + if (isApi || clientSink) add(node) + } + } + } + + if (ts.isCallExpression(node) && node.arguments.some(containsRawOrTaintedError)) { + if (!isLoggingCall(node)) { + if ((isApi && isApiResponseCall(node)) || (!isApi && isClientErrorSetter(node))) { + add(node) + } + } + } + + ts.forEachChild(node, visit) + } + visit(source) + } + + return [...new Set(findings)].sort() +} + const current = { rawRouteAuth: findRawRouteAuth(), naiveOreRound: countNaiveRound(), directJelInsert: findDirectJelInserts(), pinnedDepViolations: findPinnedDepViolations(), + rawUserErrors: findRawUserErrors(), } const isUpdate = process.argv.includes('--update') @@ -244,6 +522,19 @@ if (current.pinnedDepViolations.length) { ) } +// 1d. raw-user-error: user-facing sinks must never receive err.message. +if (current.rawUserErrors.length) { + failed = true + console.error( + `\n✗ raw-user-error: ${current.rawUserErrors.length} user-visible sink(s) expose a raw caught-error message:`, + ) + current.rawUserErrors.forEach((finding) => console.error(` ${finding}`)) + console.error( + ' → map the error through getErrorMessage(), or throw it inside withRouteContext so\n' + + ' errorResponse() produces the canonical structured envelope.', + ) +} + // 2. naive-ore-round: count may not increase. if (current.naiveOreRound > baseline.naiveOreRound.count) { failed = true @@ -268,5 +559,5 @@ if (failed) { process.exit(1) } console.log( - `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, direct-jel-insert: 0, pinned-dep: 0).`, + `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, direct-jel-insert: 0, pinned-dep: 0, raw-user-error: 0).`, ) diff --git a/scripts/checks/no-new-lint-errors.mjs b/scripts/checks/no-new-lint-errors.mjs index a387d0a3..b8485cdb 100644 --- a/scripts/checks/no-new-lint-errors.mjs +++ b/scripts/checks/no-new-lint-errors.mjs @@ -31,15 +31,17 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '. const BASELINE_PATH = path.join(ROOT, 'scripts', 'checks', 'eslint-baseline.json') function runEslint() { + const eslintBin = path.join(ROOT, 'node_modules', 'eslint', 'bin', 'eslint.js') const result = spawnSync( - 'npx', - ['eslint', '.', '--quiet', '-f', 'json'], + process.execPath, + [eslintBin, '.', '--quiet', '-f', 'json'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, ) // ESLint exits 1 when errors exist: that's expected; only treat a missing/ // unparsable report as fatal. if (!result.stdout) { console.error('no-new-lint-errors: eslint produced no JSON output') + if (result.error) console.error(result.error) console.error(result.stderr ?? '') process.exit(2) } diff --git a/supabase/migrations/20260721081951_add_tax_notices_and_initial_setup_state.sql b/supabase/migrations/20260721081951_add_tax_notices_and_initial_setup_state.sql new file mode 100644 index 00000000..bcc967b5 --- /dev/null +++ b/supabase/migrations/20260721081951_add_tax_notices_and_initial_setup_state.sql @@ -0,0 +1,107 @@ +-- Exact kvarskatt deadlines and persisted post-company onboarding state. +-- +-- Kvarskatt is not calculated from a guessed 90-day offset. Skatteverket +-- states that the exact payment due date is printed on the final tax notice, +-- and reassessment decisions can have a materially shorter payment window. +-- Store the notice date and its exact due date, then let the deadline +-- generator mirror that date without banking-day adjustment. +-- +-- Oasis onboarding state belongs to the company, not localStorage. Existing +-- onboarded companies are backfilled as completed and dismissed so only newly +-- created companies see the new optional setup surface. + +-- ============================================================================= +-- 1. Persisted initial setup state +-- ============================================================================= +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS initial_setup_path text NULL, + ADD COLUMN IF NOT EXISTS initial_setup_completed_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS initial_setup_dismissed_at timestamptz NULL; + +ALTER TABLE public.company_settings + DROP CONSTRAINT IF EXISTS company_settings_initial_setup_path_check; + +ALTER TABLE public.company_settings + ADD CONSTRAINT company_settings_initial_setup_path_check + CHECK (initial_setup_path IS NULL OR initial_setup_path IN ('migration', 'bank', 'fresh')); + +UPDATE public.company_settings +SET initial_setup_completed_at = COALESCE(initial_setup_completed_at, now()), + initial_setup_dismissed_at = COALESCE(initial_setup_dismissed_at, now()) +WHERE onboarding_complete = true + AND initial_setup_completed_at IS NULL; + +COMMENT ON COLUMN public.company_settings.initial_setup_path IS + 'Optional first-value path selected after company creation: migration, bank, or fresh.'; +COMMENT ON COLUMN public.company_settings.initial_setup_completed_at IS + 'When the selected first-value setup path was completed.'; +COMMENT ON COLUMN public.company_settings.initial_setup_dismissed_at IS + 'When the optional dashboard setup checklist was dismissed.'; + +-- ============================================================================= +-- 2. Final tax notices and reassessment decisions +-- ============================================================================= +CREATE TABLE public.tax_assessment_notices ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE RESTRICT, + decision_type text NOT NULL CHECK (decision_type IN ('final', 'reassessment')), + decision_date date NOT NULL, + payment_due_date date NOT NULL, + archived_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT tax_assessment_notices_due_after_decision + CHECK (payment_due_date >= decision_date), + CONSTRAINT tax_assessment_notices_company_period_type_key + UNIQUE (company_id, fiscal_period_id, decision_type) +); + +ALTER TABLE public.tax_assessment_notices ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "view own-company tax_assessment_notices" + ON public.tax_assessment_notices FOR SELECT + USING (company_id IN (SELECT user_company_ids())); +CREATE POLICY "insert own-company tax_assessment_notices" + ON public.tax_assessment_notices FOR INSERT + WITH CHECK ( + company_id IN (SELECT user_company_ids()) + AND user_id = auth.uid() + ); +CREATE POLICY "update own-company tax_assessment_notices" + ON public.tax_assessment_notices FOR UPDATE + USING (company_id IN (SELECT user_company_ids())) + WITH CHECK (company_id IN (SELECT user_company_ids())); +CREATE POLICY "delete own-company tax_assessment_notices" + ON public.tax_assessment_notices FOR DELETE + USING (company_id IN (SELECT user_company_ids())); + +CREATE INDEX idx_tax_assessment_notices_company + ON public.tax_assessment_notices (company_id); +CREATE INDEX idx_tax_assessment_notices_due + ON public.tax_assessment_notices (company_id, payment_due_date) + WHERE archived_at IS NULL; +CREATE INDEX idx_tax_assessment_notices_period + ON public.tax_assessment_notices (fiscal_period_id); + +CREATE TRIGGER set_updated_at_tax_assessment_notices + BEFORE UPDATE ON public.tax_assessment_notices + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER audit_tax_assessment_notices + AFTER INSERT OR UPDATE OR DELETE ON public.tax_assessment_notices + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +-- Relational identity lets edits and archives reconcile the exact generated +-- deadline without encoding notice ids into user-facing titles. +ALTER TABLE public.deadlines + ADD COLUMN IF NOT EXISTS tax_assessment_notice_id uuid NULL + REFERENCES public.tax_assessment_notices(id) ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS idx_deadlines_tax_assessment_notice + ON public.deadlines (tax_assessment_notice_id) + WHERE tax_assessment_notice_id IS NOT NULL; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260721085314_fiscal_period_tax_adjustments.sql b/supabase/migrations/20260721085314_fiscal_period_tax_adjustments.sql new file mode 100644 index 00000000..5a31d30c --- /dev/null +++ b/supabase/migrations/20260721085314_fiscal_period_tax_adjustments.sql @@ -0,0 +1,126 @@ +-- Persist tax-only adjustments used by the year-end tax provision and INK2. +-- These rows do not create journal entries. They explain the bridge from the +-- accounting result to the taxable result for one fiscal period. + +CREATE TABLE public.fiscal_period_tax_adjustments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE RESTRICT, + adjustment_type text NOT NULL CHECK ( + adjustment_type IN ('non_deductible_expense', 'non_taxable_income') + ), + source text NOT NULL CHECK (source IN ('detected', 'manual')), + source_key text NOT NULL, + description text NOT NULL, + account_number text NULL CHECK ( + account_number IS NULL OR account_number ~ '^[0-9]{4}$' + ), + amount numeric(15, 2) NOT NULL CHECK (amount >= 0), + included boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fiscal_period_tax_adjustments_source_key + UNIQUE (company_id, fiscal_period_id, source_key) +); + +ALTER TABLE public.fiscal_period_tax_adjustments ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "view own-company fiscal period tax adjustments" + ON public.fiscal_period_tax_adjustments FOR SELECT + USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "insert own-company fiscal period tax adjustments" + ON public.fiscal_period_tax_adjustments FOR INSERT + WITH CHECK ( + company_id IN (SELECT public.user_company_ids()) + AND user_id = auth.uid() + ); + +CREATE POLICY "update own-company fiscal period tax adjustments" + ON public.fiscal_period_tax_adjustments FOR UPDATE + USING (company_id IN (SELECT public.user_company_ids())) + WITH CHECK ( + company_id IN (SELECT public.user_company_ids()) + AND user_id = auth.uid() + ); + +CREATE POLICY "delete own-company fiscal period tax adjustments" + ON public.fiscal_period_tax_adjustments FOR DELETE + USING (company_id IN (SELECT public.user_company_ids())); + +CREATE INDEX idx_fiscal_period_tax_adjustments_company + ON public.fiscal_period_tax_adjustments (company_id); + +CREATE INDEX idx_fiscal_period_tax_adjustments_period + ON public.fiscal_period_tax_adjustments (fiscal_period_id); + +CREATE OR REPLACE FUNCTION public.guard_fiscal_period_tax_adjustment() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = public, pg_temp +AS $$ +DECLARE + adjustment_row public.fiscal_period_tax_adjustments%ROWTYPE; + period_row public.fiscal_periods%ROWTYPE; +BEGIN + IF TG_OP = 'UPDATE' + AND ( + OLD.company_id IS DISTINCT FROM NEW.company_id + OR OLD.fiscal_period_id IS DISTINCT FROM NEW.fiscal_period_id + ) THEN + RAISE EXCEPTION 'Tax adjustment company and fiscal period are immutable' + USING ERRCODE = '23514'; + END IF; + + IF TG_OP = 'DELETE' THEN + adjustment_row := OLD; + ELSE + adjustment_row := NEW; + END IF; + + SELECT * INTO period_row + FROM public.fiscal_periods + WHERE id = adjustment_row.fiscal_period_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Fiscal period not found for tax adjustment' + USING ERRCODE = '23503'; + END IF; + + IF period_row.company_id IS DISTINCT FROM adjustment_row.company_id THEN + RAISE EXCEPTION 'Tax adjustment company does not match fiscal period company' + USING ERRCODE = '23514'; + END IF; + + IF period_row.is_closed + OR period_row.locked_at IS NOT NULL + OR period_row.closing_entry_id IS NOT NULL THEN + RAISE EXCEPTION 'Fiscal period is locked for tax adjustments' + USING ERRCODE = '23514'; + END IF; + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER guard_fiscal_period_tax_adjustments + BEFORE INSERT OR UPDATE OR DELETE ON public.fiscal_period_tax_adjustments + FOR EACH ROW EXECUTE FUNCTION public.guard_fiscal_period_tax_adjustment(); + +CREATE TRIGGER set_updated_at_fiscal_period_tax_adjustments + BEFORE UPDATE ON public.fiscal_period_tax_adjustments + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER audit_fiscal_period_tax_adjustments + AFTER INSERT OR UPDATE OR DELETE ON public.fiscal_period_tax_adjustments + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +COMMENT ON TABLE public.fiscal_period_tax_adjustments IS + 'Tax-only adjustments bridging accounting result to taxable result for year-end tax and INK2.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260721101500_harden_tax_assessment_notices.sql b/supabase/migrations/20260721101500_harden_tax_assessment_notices.sql new file mode 100644 index 00000000..2f3e98ca --- /dev/null +++ b/supabase/migrations/20260721101500_harden_tax_assessment_notices.sql @@ -0,0 +1,104 @@ +-- Harden tax notice tenant integrity and preserve every reassessment decision. + +-- A fiscal year can receive more than one reassessment decision. The decision +-- date is part of the notice identity so repeated POST requests stay +-- idempotent without overwriting an earlier decision. +ALTER TABLE public.tax_assessment_notices + DROP CONSTRAINT tax_assessment_notices_company_period_type_key; + +ALTER TABLE public.tax_assessment_notices + ADD CONSTRAINT tax_assessment_notices_company_period_type_date_key + UNIQUE (company_id, fiscal_period_id, decision_type, decision_date); + +-- The creator is attribution, not ownership. Company records must survive a +-- user deletion, including when a generated deadline references the notice. +ALTER TABLE public.tax_assessment_notices + ALTER COLUMN user_id DROP NOT NULL; + +ALTER TABLE public.tax_assessment_notices + DROP CONSTRAINT tax_assessment_notices_user_id_fkey; + +ALTER TABLE public.tax_assessment_notices + ADD CONSTRAINT tax_assessment_notices_user_id_fkey + FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL; + +-- Company-owned foreign keys must agree on company_id. The additional unique +-- keys are safe because both referenced id columns are already primary keys. +ALTER TABLE public.fiscal_periods + ADD CONSTRAINT fiscal_periods_id_company_id_key UNIQUE (id, company_id); + +ALTER TABLE public.tax_assessment_notices + DROP CONSTRAINT tax_assessment_notices_fiscal_period_id_fkey; + +ALTER TABLE public.tax_assessment_notices + ADD CONSTRAINT tax_assessment_notices_fiscal_period_company_fkey + FOREIGN KEY (fiscal_period_id, company_id) + REFERENCES public.fiscal_periods(id, company_id) ON DELETE RESTRICT; + +ALTER TABLE public.tax_assessment_notices + ADD CONSTRAINT tax_assessment_notices_id_company_id_key UNIQUE (id, company_id); + +ALTER TABLE public.deadlines + DROP CONSTRAINT deadlines_tax_assessment_notice_id_fkey; + +ALTER TABLE public.deadlines + ADD CONSTRAINT deadlines_tax_assessment_notice_company_fkey + FOREIGN KEY (tax_assessment_notice_id, company_id) + REFERENCES public.tax_assessment_notices(id, company_id) ON DELETE RESTRICT; + +-- Keep creator attribution immutable for authenticated PostgREST callers. +CREATE OR REPLACE FUNCTION public.enforce_tax_assessment_notice_attribution() +RETURNS trigger +LANGUAGE plpgsql +SET search_path TO 'public' +AS $function$ +BEGIN + IF auth.uid() IS NOT NULL AND NEW.user_id IS DISTINCT FROM OLD.user_id THEN + RAISE EXCEPTION 'Tax assessment notice attribution is immutable' + USING ERRCODE = '42501'; + END IF; + RETURN NEW; +END; +$function$; + +CREATE TRIGGER enforce_tax_assessment_notice_attribution + BEFORE UPDATE OF user_id ON public.tax_assessment_notices + FOR EACH ROW EXECUTE FUNCTION public.enforce_tax_assessment_notice_attribution(); + +-- Reads follow the repository-wide membership model. Writes additionally +-- require the active company and a non-viewer role, matching other tenant +-- tables and preventing direct PostgREST writes from bypassing the API guard. +DROP POLICY "insert own-company tax_assessment_notices" + ON public.tax_assessment_notices; +DROP POLICY "update own-company tax_assessment_notices" + ON public.tax_assessment_notices; +DROP POLICY "delete own-company tax_assessment_notices" + ON public.tax_assessment_notices; + +CREATE POLICY tax_assessment_notices_insert + ON public.tax_assessment_notices FOR INSERT TO public + WITH CHECK ( + company_id = public.current_active_company_id() + AND public.current_user_can_write() + AND user_id = auth.uid() + ); + +CREATE POLICY tax_assessment_notices_update + ON public.tax_assessment_notices FOR UPDATE TO public + USING ( + company_id = public.current_active_company_id() + AND public.current_user_can_write() + ) + WITH CHECK ( + company_id = public.current_active_company_id() + AND public.current_user_can_write() + ); + +CREATE POLICY tax_assessment_notices_delete + ON public.tax_assessment_notices FOR DELETE TO public + USING ( + company_id = public.current_active_company_id() + AND public.current_user_can_write() + ); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260721103000_production_error_root_causes.sql b/supabase/migrations/20260721103000_production_error_root_causes.sql new file mode 100644 index 00000000..1dcff5fb --- /dev/null +++ b/supabase/migrations/20260721103000_production_error_root_causes.sql @@ -0,0 +1,127 @@ +-- Repair production error root causes found in the Vercel runtime logs. +-- +-- 1. Register the MCP approval event before processing_history references it. +-- 2. Add covering indexes for the two high-volume read paths. +-- 3. Aggregate account balances in Postgres instead of transferring every line. +-- 4. Page VAT source lines in Postgres instead of loading the full result set. + +INSERT INTO public.processing_event_types (event_type) +VALUES ('PendingOperationApproved') +ON CONFLICT (event_type) DO NOTHING; + +CREATE INDEX IF NOT EXISTS idx_audit_log_company_created_id + ON public.audit_log (company_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_journal_entries_company_posted_date_id + ON public.journal_entries (company_id, entry_date, id) + WHERE status IN ('posted', 'reversed'); + +CREATE OR REPLACE FUNCTION public.get_account_period_activity( + p_company_id uuid, + p_start date, + p_end date, + p_accounts text[], + p_exclude_journal_entry_id uuid DEFAULT NULL +) +RETURNS TABLE ( + account_number text, + debit numeric, + credit numeric +) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path TO 'public' +AS $$ + SELECT + l.account_number, + COALESCE(sum(l.debit_amount), 0)::numeric AS debit, + COALESCE(sum(l.credit_amount), 0)::numeric AS credit + FROM public.journal_entries je + JOIN public.journal_entry_lines l ON l.journal_entry_id = je.id + WHERE je.company_id = p_company_id + AND je.status IN ('posted', 'reversed') + AND je.entry_date >= p_start + AND je.entry_date <= p_end + AND l.account_number = ANY (p_accounts) + AND ( + p_exclude_journal_entry_id IS NULL + OR je.id <> p_exclude_journal_entry_id + ) + GROUP BY l.account_number + ORDER BY l.account_number; +$$; + +REVOKE ALL ON FUNCTION public.get_account_period_activity(uuid, date, date, text[], uuid) + FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.get_account_period_activity(uuid, date, date, text[], uuid) + TO authenticated, service_role; + +CREATE OR REPLACE FUNCTION public.get_vat_ruta_source_lines( + p_company_id uuid, + p_start date, + p_end date, + p_accounts text[], + p_cursor_date date DEFAULT NULL, + p_cursor_voucher_number integer DEFAULT NULL, + p_cursor_entry_id uuid DEFAULT NULL, + p_cursor_line_id uuid DEFAULT NULL, + p_limit integer DEFAULT 501 +) +RETURNS TABLE ( + line_id uuid, + journal_entry_id uuid, + voucher_number integer, + voucher_series text, + entry_date date, + description text, + debit_amount numeric, + credit_amount numeric +) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path TO 'public' +AS $$ + SELECT + l.id AS line_id, + je.id AS journal_entry_id, + je.voucher_number, + COALESCE(je.voucher_series, 'A') AS voucher_series, + je.entry_date, + COALESCE(je.description, '') AS description, + l.debit_amount, + l.credit_amount + FROM public.journal_entries je + JOIN public.journal_entry_lines l ON l.journal_entry_id = je.id + WHERE je.company_id = p_company_id + AND je.status IN ('posted', 'reversed') + AND je.entry_date >= p_start + AND je.entry_date <= p_end + AND l.account_number = ANY (p_accounts) + AND ( + p_cursor_date IS NULL + OR ( + je.entry_date, + je.voucher_number, + je.id, + l.id + ) > ( + p_cursor_date, + p_cursor_voucher_number, + COALESCE(p_cursor_entry_id, 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid), + COALESCE(p_cursor_line_id, 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid) + ) + ) + ORDER BY je.entry_date, je.voucher_number, je.id, l.id + LIMIT LEAST(GREATEST(COALESCE(p_limit, 501), 1), 501); +$$; + +REVOKE ALL ON FUNCTION public.get_vat_ruta_source_lines( + uuid, date, date, text[], date, integer, uuid, uuid, integer +) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.get_vat_ruta_source_lines( + uuid, date, date, text[], date, integer, uuid, uuid, integer +) TO authenticated, service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260721110750_pending_operations_add_update_company_settings.sql b/supabase/migrations/20260721110750_pending_operations_add_update_company_settings.sql new file mode 100644 index 00000000..f3737ec0 --- /dev/null +++ b/supabase/migrations/20260721110750_pending_operations_add_update_company_settings.sql @@ -0,0 +1,69 @@ +-- Add update_company_settings to the pending_operations operation type CHECK. +-- The operation changes payment-routing details shown on future invoices and +-- therefore stages at medium risk for explicit approval. + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + 'import_sie', + 'explain_voucher_gap', + 'uncategorize_transaction', + 'approve_supplier_invoice', + 'credit_supplier_invoice', + 'credit_invoice', + 'convert_invoice', + 'create_transaction', + 'attach_document_to_transaction', + 'create_voucher', + 'correct_entry', + 'reverse_entry', + 'create_supplier', + 'create_supplier_invoice_from_inbox', + 'post_annual_depreciation', + 'link_invoice_voucher', + 'undo_sie_import', + 'match_batch_allocate', + 'bulk_book_transactions', + 'create_salary_run', + 'generate_agi', + 'link_transaction_journal_entry', + 'link_supplier_invoice_voucher', + 'submit_vat_declaration', + 'submit_agi', + 'create_article', + 'update_article', + 'bulk_book_inbox_items', + 'create_dimension_value', + 'retag_line_dimensions', + 'link_document_to_voucher', + 'update_payslip_line', + 'register_absence', + 'create_employee', + 'update_employee', + 'set_employee_opening_balances', + 'vacation_year_close', + 'create_account', + 'update_account', + 'set_voucher_note', + 'book_salary_run', + 'delete_absence', + 'update_company_settings' + )) NOT VALID; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260721110751_validate_pending_operations_update_company_settings.sql b/supabase/migrations/20260721110751_validate_pending_operations_update_company_settings.sql new file mode 100644 index 00000000..8933532b --- /dev/null +++ b/supabase/migrations/20260721110751_validate_pending_operations_update_company_settings.sql @@ -0,0 +1,6 @@ +-- Validate the operation type CHECK re-added in 20260721110750. +-- This separate transaction avoids a full-table scan while the preceding +-- migration holds its stronger table lock. + +ALTER TABLE public.pending_operations + VALIDATE CONSTRAINT pending_operations_operation_type_check; diff --git a/supabase/migrations/20260721111331_pending_operations_add_update_customer.sql b/supabase/migrations/20260721111331_pending_operations_add_update_customer.sql new file mode 100644 index 00000000..a99055ff --- /dev/null +++ b/supabase/migrations/20260721111331_pending_operations_add_update_customer.sql @@ -0,0 +1,70 @@ +-- Add update_customer to the pending_operations operation type CHECK. +-- Customer master-data changes are staged at low risk and remain subject to +-- explicit approval unless the company has opted into trusted auto-commit. + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + 'import_sie', + 'explain_voucher_gap', + 'uncategorize_transaction', + 'approve_supplier_invoice', + 'credit_supplier_invoice', + 'credit_invoice', + 'convert_invoice', + 'create_transaction', + 'attach_document_to_transaction', + 'create_voucher', + 'correct_entry', + 'reverse_entry', + 'create_supplier', + 'create_supplier_invoice_from_inbox', + 'post_annual_depreciation', + 'link_invoice_voucher', + 'undo_sie_import', + 'match_batch_allocate', + 'bulk_book_transactions', + 'create_salary_run', + 'generate_agi', + 'link_transaction_journal_entry', + 'link_supplier_invoice_voucher', + 'submit_vat_declaration', + 'submit_agi', + 'create_article', + 'update_article', + 'bulk_book_inbox_items', + 'create_dimension_value', + 'retag_line_dimensions', + 'link_document_to_voucher', + 'update_payslip_line', + 'register_absence', + 'create_employee', + 'update_employee', + 'set_employee_opening_balances', + 'vacation_year_close', + 'create_account', + 'update_account', + 'set_voucher_note', + 'book_salary_run', + 'delete_absence', + 'update_company_settings', + 'update_customer' + )) NOT VALID; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260721111332_validate_pending_operations_update_customer.sql b/supabase/migrations/20260721111332_validate_pending_operations_update_customer.sql new file mode 100644 index 00000000..5f2775f9 --- /dev/null +++ b/supabase/migrations/20260721111332_validate_pending_operations_update_customer.sql @@ -0,0 +1,6 @@ +-- Validate the operation type CHECK re-added in 20260721111331. +-- This separate transaction avoids a full-table scan while the preceding +-- migration holds its stronger table lock. + +ALTER TABLE public.pending_operations + VALIDATE CONSTRAINT pending_operations_operation_type_check; diff --git a/supabase/migrations/20260721115701_suppress_api_key_usage_audit_noise.sql b/supabase/migrations/20260721115701_suppress_api_key_usage_audit_noise.sql new file mode 100644 index 00000000..673d190f --- /dev/null +++ b/supabase/migrations/20260721115701_suppress_api_key_usage_audit_noise.sql @@ -0,0 +1,34 @@ +-- Keep API-key security and configuration changes in behandlingshistorik, but +-- do not create a full audit row for every authenticated API or MCP request. +-- validate_and_increment_api_key updates only these four telemetry columns. + +DROP TRIGGER IF EXISTS audit_api_keys_update ON public.api_keys; +DROP TRIGGER IF EXISTS audit_api_keys ON public.api_keys; + +-- Preserve the historical trigger name for account-deletion routines that +-- temporarily disable audit_api_keys while removing a user. +CREATE TRIGGER audit_api_keys + AFTER INSERT OR DELETE ON public.api_keys + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +CREATE TRIGGER audit_api_keys_update + AFTER UPDATE ON public.api_keys + FOR EACH ROW + WHEN ( + (to_jsonb(OLD) - ARRAY[ + 'request_count', + 'rate_limit_window_start', + 'last_used_at', + 'updated_at' + ]::text[]) + IS DISTINCT FROM + (to_jsonb(NEW) - ARRAY[ + 'request_count', + 'rate_limit_window_start', + 'last_used_at', + 'updated_at' + ]::text[]) + ) + EXECUTE FUNCTION public.write_audit_log(); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260721130847_harden_fiscal_period_tax_adjustment_rls.sql b/supabase/migrations/20260721130847_harden_fiscal_period_tax_adjustment_rls.sql new file mode 100644 index 00000000..2931aa38 --- /dev/null +++ b/supabase/migrations/20260721130847_harden_fiscal_period_tax_adjustment_rls.sql @@ -0,0 +1,105 @@ +-- The initial tax-adjustment migration used membership-only write policies. +-- Align writes with the global role gate: only a non-viewer member may write, +-- and only for the caller's active company. + +ALTER TABLE public.fiscal_period_tax_adjustments + ALTER COLUMN id SET DEFAULT uuid_generate_v4(); + +-- Serialize adjustment writes with fiscal-period close/lock updates. The row +-- lock prevents a concurrent request from saving tax inputs after closing has +-- started but before the period status change becomes visible. +CREATE OR REPLACE FUNCTION public.guard_fiscal_period_tax_adjustment() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = public, pg_temp +AS $$ +DECLARE + adjustment_row public.fiscal_period_tax_adjustments%ROWTYPE; + period_row public.fiscal_periods%ROWTYPE; +BEGIN + IF TG_OP = 'UPDATE' + AND ( + OLD.company_id IS DISTINCT FROM NEW.company_id + OR OLD.fiscal_period_id IS DISTINCT FROM NEW.fiscal_period_id + ) THEN + RAISE EXCEPTION 'Tax adjustment company and fiscal period are immutable' + USING ERRCODE = '23514'; + END IF; + + IF TG_OP = 'DELETE' THEN + adjustment_row := OLD; + ELSE + adjustment_row := NEW; + END IF; + + SELECT * INTO period_row + FROM public.fiscal_periods + WHERE id = adjustment_row.fiscal_period_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Fiscal period not found for tax adjustment' + USING ERRCODE = '23503'; + END IF; + + IF period_row.company_id IS DISTINCT FROM adjustment_row.company_id THEN + RAISE EXCEPTION 'Tax adjustment company does not match fiscal period company' + USING ERRCODE = '23514'; + END IF; + + IF period_row.is_closed + OR period_row.locked_at IS NOT NULL + OR period_row.closing_entry_id IS NOT NULL THEN + RAISE EXCEPTION 'Fiscal period is locked for tax adjustments' + USING ERRCODE = '23514'; + END IF; + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$; + +DROP POLICY IF EXISTS "insert own-company fiscal period tax adjustments" + ON public.fiscal_period_tax_adjustments; +CREATE POLICY "insert own-company fiscal period tax adjustments" + ON public.fiscal_period_tax_adjustments FOR INSERT TO public + WITH CHECK ( + company_id = public.current_active_company_id() + AND public.current_user_can_write() + AND user_id = auth.uid() + ); + +DROP POLICY IF EXISTS "update own-company fiscal period tax adjustments" + ON public.fiscal_period_tax_adjustments; +CREATE POLICY "update own-company fiscal period tax adjustments" + ON public.fiscal_period_tax_adjustments FOR UPDATE TO public + USING ( + company_id = public.current_active_company_id() + AND public.current_user_can_write() + ) + WITH CHECK ( + company_id = public.current_active_company_id() + AND public.current_user_can_write() + AND user_id = auth.uid() + ); + +DROP POLICY IF EXISTS "delete own-company fiscal period tax adjustments" + ON public.fiscal_period_tax_adjustments; +CREATE POLICY "delete own-company fiscal period tax adjustments" + ON public.fiscal_period_tax_adjustments FOR DELETE TO public + USING ( + company_id = public.current_active_company_id() + AND public.current_user_can_write() + ); + +-- Only the corporate-tax disposition sets source_id to the fiscal period. +-- This database invariant closes the read-then-insert race between two POSTs. +CREATE UNIQUE INDEX uq_year_end_corporate_tax_per_period + ON public.journal_entries (company_id, source_id) + WHERE source_type = 'year_end' + AND source_id IS NOT NULL + AND status IN ('draft', 'posted'); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260721132000_correct_bfl_retention_expiry.sql b/supabase/migrations/20260721132000_correct_bfl_retention_expiry.sql new file mode 100644 index 00000000..1af96d4d --- /dev/null +++ b/supabase/migrations/20260721132000_correct_bfl_retention_expiry.sql @@ -0,0 +1,33 @@ +-- BFL 7 kap. 2 § requires accounting information to be preserved through +-- the end of the seventh calendar year after the calendar year in which the +-- fiscal year ended. retention_expires_at stores the first date on which the +-- statutory minimum retention period has elapsed. + +CREATE OR REPLACE FUNCTION public.set_bfl_retention_expiry() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = public +AS $$ +BEGIN + NEW.retention_expires_at := make_date( + extract(year FROM NEW.period_end)::integer + 8, + 1, + 1 + ); + RETURN NEW; +END; +$$; + +-- PostgreSQL fires triggers with the same timing alphabetically. The zz +-- prefix makes this legal correction run after the original migration 017 +-- trigger without modifying that shipped enforcement migration. +CREATE TRIGGER zz_set_bfl_retention_expiry + BEFORE INSERT OR UPDATE OF period_end ON public.fiscal_periods + FOR EACH ROW EXECUTE FUNCTION public.set_bfl_retention_expiry(); + +UPDATE public.fiscal_periods +SET retention_expires_at = make_date( + extract(year FROM period_end)::integer + 8, + 1, + 1 +); diff --git a/supabase/migrations/20260721160000_annual_report_versions_and_compliance.sql b/supabase/migrations/20260721160000_annual_report_versions_and_compliance.sql new file mode 100644 index 00000000..6f93184e --- /dev/null +++ b/supabase/migrations/20260721160000_annual_report_versions_and_compliance.sql @@ -0,0 +1,1114 @@ +-- Versioned annual reports, explicit K2 eligibility facts, disclosure +-- confirmations, signature evidence, and safer Bolagsverket submission state. + +CREATE TABLE public.annual_report_profiles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE CASCADE, + user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + is_public_limited_company boolean, + is_in_liquidation boolean, + securities_traded_on_regulated_market boolean, + is_parent_company boolean, + parent_group_size text CHECK (parent_group_size IN ('none', 'small', 'large')), + prepares_consolidated_accounts boolean, + has_foreign_branch boolean, + has_crypto_assets boolean, + has_share_based_payments boolean, + has_convertible_debt boolean, + building_revenue_share_pct numeric(5, 2) CHECK ( + building_revenue_share_pct IS NULL + OR building_revenue_share_pct BETWEEN 0 AND 100 + ), + has_material_deferred_tax boolean, + reporting_currency text NOT NULL DEFAULT 'SEK' CHECK (reporting_currency IN ('SEK', 'EUR')), + auditor_report_required boolean, + auditor_report_included boolean NOT NULL DEFAULT false, + dividend_prudence_confirmed boolean, + narrative_confirmed_at timestamptz, + k2_assessment_confirmed_at timestamptz, + signer_roster_confirmed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT annual_report_profiles_company_period_unique UNIQUE (company_id, fiscal_period_id), + CONSTRAINT annual_report_profiles_parent_consistency CHECK ( + is_parent_company IS NOT false + OR (parent_group_size IS NULL AND prepares_consolidated_accounts IS NOT true) + ) +); + +CREATE INDEX idx_annual_report_profiles_period + ON public.annual_report_profiles (company_id, fiscal_period_id); + +ALTER TABLE public.annual_report_profiles ENABLE ROW LEVEL SECURITY; + +CREATE POLICY annual_report_profiles_select ON public.annual_report_profiles + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY annual_report_profiles_insert ON public.annual_report_profiles + FOR INSERT WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = annual_report_profiles.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + AND (user_id IS NULL OR user_id = auth.uid()) + ); +CREATE POLICY annual_report_profiles_update ON public.annual_report_profiles + FOR UPDATE USING ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = annual_report_profiles.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = annual_report_profiles.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ); +CREATE POLICY annual_report_profiles_delete ON public.annual_report_profiles + FOR DELETE USING ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = annual_report_profiles.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ); + +CREATE TRIGGER annual_report_profiles_updated_at + BEFORE UPDATE ON public.annual_report_profiles + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER audit_annual_report_profiles + AFTER INSERT OR UPDATE OR DELETE ON public.annual_report_profiles + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +ALTER TABLE public.arsredovisning_narratives + ADD COLUMN long_term_debt_over_five_years_confirmed boolean NOT NULL DEFAULT false, + ADD COLUMN securities_pledged_confirmed boolean NOT NULL DEFAULT false, + ADD COLUMN contingent_liabilities_confirmed boolean NOT NULL DEFAULT false, + ADD COLUMN parent_company_confirmed boolean NOT NULL DEFAULT false, + ADD COLUMN agm_disposition_outcome text CHECK ( + agm_disposition_outcome IN ('proposal_approved', 'alternative_decision') + ), + ADD COLUMN agm_disposition_decision text CHECK ( + agm_disposition_decision IS NULL OR length(agm_disposition_decision) <= 2000 + ), + ADD COLUMN proposed_dividend numeric(15, 2) CHECK ( + proposed_dividend IS NULL OR proposed_dividend >= 0 + ), + ADD CONSTRAINT arsredovisning_narratives_agm_decision_consistency CHECK ( + agm_disposition_outcome IS DISTINCT FROM 'alternative_decision' + OR nullif(trim(agm_disposition_decision), '') IS NOT NULL + ); + +CREATE TABLE public.annual_report_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT, + fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE RESTRICT, + user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + version_number integer NOT NULL CHECK (version_number > 0), + schema_version text NOT NULL, + framework text NOT NULL CHECK (framework IN ('k2', 'k3')), + status text NOT NULL DEFAULT 'draft' CHECK ( + status IN ('draft', 'ready_for_signature', 'signed', 'filed', 'registered', 'superseded') + ), + report_data jsonb NOT NULL, + ixbrl_data jsonb, + content_hash text NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), + taxonomy_version text, + entry_point text, + validation_summary jsonb NOT NULL DEFAULT '{}'::jsonb, + supersedes_version_id uuid REFERENCES public.annual_report_versions(id), + finalized_at timestamptz, + finalized_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT annual_report_versions_number_unique UNIQUE (company_id, fiscal_period_id, version_number), + CONSTRAINT annual_report_versions_content_unique UNIQUE (company_id, fiscal_period_id, content_hash), + CONSTRAINT annual_report_versions_finalized_consistency CHECK ( + (status = 'draft' AND finalized_at IS NULL) + OR (status <> 'draft' AND finalized_at IS NOT NULL) + ) +); + +CREATE INDEX idx_annual_report_versions_period + ON public.annual_report_versions (company_id, fiscal_period_id, version_number DESC); +CREATE INDEX idx_annual_report_versions_status + ON public.annual_report_versions (company_id, status); + +ALTER TABLE public.annual_report_versions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY annual_report_versions_select ON public.annual_report_versions + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY annual_report_versions_insert ON public.annual_report_versions + FOR INSERT WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = annual_report_versions.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + AND (user_id IS NULL OR user_id = auth.uid()) + AND status = 'draft' + AND finalized_at IS NULL + AND finalized_by IS NULL + ); +CREATE POLICY annual_report_versions_update ON public.annual_report_versions + FOR UPDATE USING ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = annual_report_versions.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = annual_report_versions.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + AND status IN ('signed', 'filed') + ); +CREATE POLICY annual_report_versions_no_delete ON public.annual_report_versions + FOR DELETE USING (false); + +CREATE OR REPLACE FUNCTION public.enforce_annual_report_version_immutability() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.company_id IS DISTINCT FROM OLD.company_id + OR NEW.fiscal_period_id IS DISTINCT FROM OLD.fiscal_period_id + OR NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.version_number IS DISTINCT FROM OLD.version_number + OR NEW.schema_version IS DISTINCT FROM OLD.schema_version + OR NEW.framework IS DISTINCT FROM OLD.framework + OR NEW.report_data IS DISTINCT FROM OLD.report_data + OR NEW.ixbrl_data IS DISTINCT FROM OLD.ixbrl_data + OR NEW.content_hash IS DISTINCT FROM OLD.content_hash + OR NEW.taxonomy_version IS DISTINCT FROM OLD.taxonomy_version + OR NEW.entry_point IS DISTINCT FROM OLD.entry_point + OR NEW.validation_summary IS DISTINCT FROM OLD.validation_summary + OR NEW.supersedes_version_id IS DISTINCT FROM OLD.supersedes_version_id + OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN + RAISE EXCEPTION 'Annual report version content is immutable (id=%)', OLD.id + USING ERRCODE = 'check_violation'; + END IF; + + IF NEW.status IS DISTINCT FROM OLD.status AND NOT ( + (OLD.status = 'draft' AND NEW.status IN ('ready_for_signature', 'superseded')) + OR (OLD.status = 'ready_for_signature' AND NEW.status IN ('signed', 'superseded')) + OR (OLD.status = 'signed' AND NEW.status IN ('filed', 'superseded')) + OR (OLD.status = 'filed' AND NEW.status IN ('registered', 'superseded')) + ) THEN + RAISE EXCEPTION 'Invalid annual report version status transition: % to %', OLD.status, NEW.status + USING ERRCODE = 'check_violation'; + END IF; + + IF NEW.status = 'signed' AND OLD.status IS DISTINCT FROM 'signed' AND ( + NOT EXISTS ( + SELECT 1 + FROM public.arsredovisning_signature_requests request + WHERE request.annual_report_version_id = NEW.id + ) + OR EXISTS ( + SELECT 1 + FROM public.arsredovisning_signature_requests request + WHERE request.annual_report_version_id = NEW.id + AND request.status <> 'signed' + ) + ) THEN + RAISE EXCEPTION 'Annual report version cannot be signed before every locked signer has signed' + USING ERRCODE = 'check_violation'; + END IF; + + IF NEW.status = 'filed' AND OLD.status IS DISTINCT FROM 'filed' AND NOT EXISTS ( + SELECT 1 + FROM public.arsredovisning_submissions submission + WHERE submission.annual_report_version_id = NEW.id + AND submission.archive_status = 'stored' + AND submission.uploaded_at IS NOT NULL + AND submission.idnummer IS NOT NULL + ) THEN + RAISE EXCEPTION 'Annual report version cannot be filed without an archived Bolagsverket upload receipt' + USING ERRCODE = 'check_violation'; + END IF; + + IF NEW.status = 'registered' AND OLD.status IS DISTINCT FROM 'registered' AND NOT EXISTS ( + SELECT 1 + FROM public.arsredovisning_submissions submission + WHERE submission.annual_report_version_id = NEW.id + AND submission.status = 'registrerad' + ) THEN + RAISE EXCEPTION 'Annual report version cannot be registered without a registered Bolagsverket submission' + USING ERRCODE = 'check_violation'; + END IF; + + IF OLD.finalized_at IS NOT NULL AND NEW.finalized_at IS DISTINCT FROM OLD.finalized_at THEN + RAISE EXCEPTION 'Finalization metadata is immutable (id=%)', OLD.id + USING ERRCODE = 'check_violation'; + END IF; + IF OLD.finalized_by IS NOT NULL AND NEW.finalized_by IS DISTINCT FROM OLD.finalized_by THEN + RAISE EXCEPTION 'Finalization metadata is immutable (id=%)', OLD.id + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER enforce_annual_report_version_immutability + BEFORE UPDATE ON public.annual_report_versions + FOR EACH ROW EXECUTE FUNCTION public.enforce_annual_report_version_immutability(); + +CREATE OR REPLACE FUNCTION public.block_annual_report_version_deletion() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'Annual report versions are retained as immutable accounting information (id=%)', OLD.id + USING ERRCODE = 'check_violation'; +END; +$$; + +CREATE TRIGGER block_annual_report_version_deletion + BEFORE DELETE ON public.annual_report_versions + FOR EACH ROW EXECUTE FUNCTION public.block_annual_report_version_deletion(); + +CREATE TRIGGER audit_annual_report_versions + AFTER INSERT OR UPDATE OR DELETE ON public.annual_report_versions + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +CREATE TABLE public.annual_report_validation_runs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT, + fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE RESTRICT, + version_id uuid NOT NULL REFERENCES public.annual_report_versions(id) ON DELETE RESTRICT, + user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + validation_layer text NOT NULL CHECK (validation_layer IN ('local', 'arelle', 'bolagsverket')), + status text NOT NULL CHECK (status IN ('passed', 'warnings', 'failed', 'unavailable')), + validator_version text, + artifact_hash text CHECK (artifact_hash IS NULL OR artifact_hash ~ '^[a-f0-9]{64}$'), + issues jsonb NOT NULL DEFAULT '[]'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_annual_report_validation_runs_version + ON public.annual_report_validation_runs (company_id, version_id, created_at DESC); + +ALTER TABLE public.annual_report_validation_runs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY annual_report_validation_runs_select ON public.annual_report_validation_runs + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY annual_report_validation_runs_insert ON public.annual_report_validation_runs + FOR INSERT WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = annual_report_validation_runs.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + AND (user_id IS NULL OR user_id = auth.uid()) + ); +CREATE POLICY annual_report_validation_runs_no_update ON public.annual_report_validation_runs + FOR UPDATE USING (false) WITH CHECK (false); +CREATE POLICY annual_report_validation_runs_no_delete ON public.annual_report_validation_runs + FOR DELETE USING (false); + +CREATE TRIGGER audit_annual_report_validation_runs + AFTER INSERT OR UPDATE OR DELETE ON public.annual_report_validation_runs + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +CREATE OR REPLACE FUNCTION public.validate_annual_report_company_links() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + linked_version_company uuid; + linked_version_period uuid; +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM public.fiscal_periods fp + WHERE fp.id = NEW.fiscal_period_id + AND fp.company_id = NEW.company_id + ) THEN + RAISE EXCEPTION 'Fiscal period does not belong to annual report company' + USING ERRCODE = 'foreign_key_violation'; + END IF; + + IF TG_TABLE_NAME = 'annual_report_validation_runs' THEN + SELECT company_id, fiscal_period_id + INTO linked_version_company, linked_version_period + FROM public.annual_report_versions + WHERE id = NEW.version_id; + IF linked_version_company IS DISTINCT FROM NEW.company_id + OR linked_version_period IS DISTINCT FROM NEW.fiscal_period_id THEN + RAISE EXCEPTION 'Annual report validation version belongs to another company or period' + USING ERRCODE = 'foreign_key_violation'; + END IF; + ELSIF TG_TABLE_NAME = 'annual_report_versions' THEN + IF NEW.supersedes_version_id IS NOT NULL THEN + SELECT company_id, fiscal_period_id + INTO linked_version_company, linked_version_period + FROM public.annual_report_versions + WHERE id = NEW.supersedes_version_id; + IF linked_version_company IS DISTINCT FROM NEW.company_id + OR linked_version_period IS DISTINCT FROM NEW.fiscal_period_id THEN + RAISE EXCEPTION 'Superseded annual report version belongs to another company or period' + USING ERRCODE = 'foreign_key_violation'; + END IF; + END IF; + ELSIF TG_TABLE_NAME IN ('arsredovisning_signature_requests', 'arsredovisning_submissions') THEN + IF NEW.annual_report_version_id IS NOT NULL THEN + SELECT company_id, fiscal_period_id + INTO linked_version_company, linked_version_period + FROM public.annual_report_versions + WHERE id = NEW.annual_report_version_id; + IF linked_version_company IS DISTINCT FROM NEW.company_id + OR linked_version_period IS DISTINCT FROM NEW.fiscal_period_id THEN + RAISE EXCEPTION 'Linked annual report version belongs to another company or period' + USING ERRCODE = 'foreign_key_violation'; + END IF; + END IF; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER validate_annual_report_profiles_company_links + BEFORE INSERT OR UPDATE ON public.annual_report_profiles + FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links(); + +CREATE TRIGGER validate_annual_report_versions_company_links + BEFORE INSERT OR UPDATE ON public.annual_report_versions + FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links(); + +CREATE TRIGGER validate_annual_report_validation_company_links + BEFORE INSERT OR UPDATE ON public.annual_report_validation_runs + FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links(); + +CREATE OR REPLACE FUNCTION public.create_annual_report_version( + p_company_id uuid, + p_fiscal_period_id uuid, + p_schema_version text, + p_framework text, + p_status text, + p_report_data jsonb, + p_ixbrl_data jsonb, + p_content_hash text, + p_taxonomy_version text, + p_entry_point text, + p_validation_summary jsonb, + p_user_id uuid +) +RETURNS SETOF public.annual_report_versions +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = public +AS $$ +DECLARE + existing_row public.annual_report_versions%ROWTYPE; + next_version integer; + previous_version_id uuid; +BEGIN + IF auth.role() IS DISTINCT FROM 'service_role' AND p_user_id IS DISTINCT FROM auth.uid() THEN + RAISE EXCEPTION 'Annual report version user must match authenticated user' + USING ERRCODE = 'insufficient_privilege'; + END IF; + IF p_status <> 'draft' THEN + RAISE EXCEPTION 'Direct annual report version creation only permits draft status' + USING ERRCODE = 'check_violation'; + END IF; + + PERFORM pg_advisory_xact_lock(hashtextextended(p_company_id::text || ':' || p_fiscal_period_id::text, 0)); + + SELECT * INTO existing_row + FROM public.annual_report_versions + WHERE company_id = p_company_id + AND fiscal_period_id = p_fiscal_period_id + AND content_hash = p_content_hash; + IF FOUND THEN + RETURN NEXT existing_row; + RETURN; + END IF; + + SELECT version_number, id + INTO next_version, previous_version_id + FROM public.annual_report_versions + WHERE company_id = p_company_id + AND fiscal_period_id = p_fiscal_period_id + ORDER BY version_number DESC + LIMIT 1; + next_version := coalesce(next_version, 0) + 1; + + INSERT INTO public.annual_report_versions ( + company_id, + fiscal_period_id, + user_id, + version_number, + schema_version, + framework, + status, + report_data, + ixbrl_data, + content_hash, + taxonomy_version, + entry_point, + validation_summary, + supersedes_version_id, + finalized_at, + finalized_by + ) VALUES ( + p_company_id, + p_fiscal_period_id, + p_user_id, + next_version, + p_schema_version, + p_framework, + p_status, + p_report_data, + p_ixbrl_data, + p_content_hash, + p_taxonomy_version, + p_entry_point, + p_validation_summary, + previous_version_id, + NULL, + NULL + ) + RETURNING * INTO existing_row; + + RETURN NEXT existing_row; +END; +$$; + +REVOKE ALL ON FUNCTION public.create_annual_report_version( + uuid, uuid, text, text, text, jsonb, jsonb, text, text, text, jsonb, uuid +) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.create_annual_report_version( + uuid, uuid, text, text, text, jsonb, jsonb, text, text, text, jsonb, uuid +) TO authenticated, service_role; + +ALTER TABLE public.arsredovisning_signature_requests + ADD COLUMN annual_report_version_id uuid REFERENCES public.annual_report_versions(id) ON DELETE RESTRICT, + ADD COLUMN signing_method text CHECK ( + signing_method IN ('paper_original', 'advanced_e_signature', 'bankid', 'bolagsverket') + ), + ADD COLUMN evidence_reference text CHECK ( + evidence_reference IS NULL OR length(evidence_reference) <= 500 + ), + ADD COLUMN evidence_recorded_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + ADD COLUMN evidence_recorded_at timestamptz, + ADD CONSTRAINT arsredovisning_signature_evidence_consistency CHECK ( + status <> 'signed' + OR annual_report_version_id IS NULL + OR ( + signing_method IS NOT NULL + AND evidence_recorded_at IS NOT NULL + AND nullif(trim(evidence_reference), '') IS NOT NULL + ) + ); + +CREATE OR REPLACE FUNCTION public.invalidate_annual_report_signer_roster_confirmation() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + affected_company_id uuid; + affected_fiscal_period_id uuid; + should_invalidate boolean := false; +BEGIN + IF TG_OP = 'INSERT' THEN + should_invalidate := NEW.annual_report_version_id IS NULL; + affected_company_id := NEW.company_id; + affected_fiscal_period_id := NEW.fiscal_period_id; + ELSIF TG_OP = 'DELETE' THEN + should_invalidate := OLD.annual_report_version_id IS NULL; + affected_company_id := OLD.company_id; + affected_fiscal_period_id := OLD.fiscal_period_id; + ELSE + should_invalidate := OLD.annual_report_version_id IS NULL + AND NEW.annual_report_version_id IS NULL + AND ( + NEW.role IS DISTINCT FROM OLD.role + OR NEW.signer_name IS DISTINCT FROM OLD.signer_name + OR NEW.status IS DISTINCT FROM OLD.status + ); + affected_company_id := NEW.company_id; + affected_fiscal_period_id := NEW.fiscal_period_id; + END IF; + + IF should_invalidate THEN + UPDATE public.annual_report_profiles + SET signer_roster_confirmed_at = NULL + WHERE company_id = affected_company_id + AND fiscal_period_id = affected_fiscal_period_id + AND signer_roster_confirmed_at IS NOT NULL; + END IF; + RETURN NULL; +END; +$$; + +CREATE TRIGGER invalidate_annual_report_signer_roster_confirmation + AFTER INSERT OR UPDATE OR DELETE ON public.arsredovisning_signature_requests + FOR EACH ROW EXECUTE FUNCTION public.invalidate_annual_report_signer_roster_confirmation(); + +CREATE OR REPLACE FUNCTION public.prepare_annual_report_signature_slots( + p_company_id uuid, + p_fiscal_period_id uuid, + p_annual_report_version_id uuid, + p_user_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = public +AS $$ +DECLARE + bound_slot_count integer; +BEGIN + IF auth.role() IS DISTINCT FROM 'service_role' AND p_user_id IS DISTINCT FROM auth.uid() THEN + RAISE EXCEPTION 'Annual report signature user must match authenticated user' + USING ERRCODE = 'insufficient_privilege'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM public.annual_report_versions + WHERE id = p_annual_report_version_id + AND company_id = p_company_id + AND fiscal_period_id = p_fiscal_period_id + AND status = 'ready_for_signature' + ) THEN + RAISE EXCEPTION 'Annual report version is not ready for signatures' + USING ERRCODE = 'check_violation'; + END IF; + + UPDATE public.arsredovisning_signature_requests + SET annual_report_version_id = p_annual_report_version_id + WHERE company_id = p_company_id + AND fiscal_period_id = p_fiscal_period_id + AND status = 'pending' + AND annual_report_version_id IS NULL; + GET DIAGNOSTICS bound_slot_count = ROW_COUNT; + + IF bound_slot_count = 0 THEN + INSERT INTO public.arsredovisning_signature_requests ( + user_id, + company_id, + fiscal_period_id, + annual_report_version_id, + role, + signer_name, + status + ) + SELECT + p_user_id, + p_company_id, + p_fiscal_period_id, + p_annual_report_version_id, + roster.role, + roster.signer_name, + 'pending' + FROM ( + SELECT DISTINCT ON (lower(trim(request.role)), lower(trim(request.signer_name))) + request.role, + request.signer_name + FROM public.arsredovisning_signature_requests request + JOIN public.annual_report_versions prior_version + ON prior_version.id = request.annual_report_version_id + WHERE prior_version.company_id = p_company_id + AND prior_version.fiscal_period_id = p_fiscal_period_id + AND prior_version.id <> p_annual_report_version_id + AND prior_version.version_number = ( + SELECT max(candidate.version_number) + FROM public.annual_report_versions candidate + WHERE candidate.company_id = p_company_id + AND candidate.fiscal_period_id = p_fiscal_period_id + AND candidate.id <> p_annual_report_version_id + AND EXISTS ( + SELECT 1 + FROM public.arsredovisning_signature_requests candidate_request + WHERE candidate_request.annual_report_version_id = candidate.id + ) + ) + ORDER BY + lower(trim(request.role)), + lower(trim(request.signer_name)), + request.created_at DESC + ) AS roster; + END IF; +END; +$$; + +REVOKE ALL ON FUNCTION public.prepare_annual_report_signature_slots( + uuid, uuid, uuid, uuid +) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.prepare_annual_report_signature_slots( + uuid, uuid, uuid, uuid +) TO service_role; + +CREATE OR REPLACE FUNCTION public.create_annual_report_version_with_signatures( + p_company_id uuid, + p_fiscal_period_id uuid, + p_schema_version text, + p_framework text, + p_status text, + p_report_data jsonb, + p_ixbrl_data jsonb, + p_content_hash text, + p_taxonomy_version text, + p_entry_point text, + p_validation_summary jsonb, + p_user_id uuid +) +RETURNS SETOF public.annual_report_versions +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = public +AS $$ +DECLARE + version_row public.annual_report_versions%ROWTYPE; + live_roster_confirmation timestamptz; +BEGIN + IF auth.role() IS DISTINCT FROM 'service_role' THEN + RAISE EXCEPTION 'Final annual report versions may only be created by the trusted application service' + USING ERRCODE = 'insufficient_privilege'; + END IF; + IF p_status <> 'ready_for_signature' THEN + RAISE EXCEPTION 'Signature-bound annual report creation requires ready_for_signature status' + USING ERRCODE = 'check_violation'; + END IF; + + IF jsonb_typeof(p_report_data) IS DISTINCT FROM 'object' + OR p_report_data #>> '{fiscal_period,id}' IS DISTINCT FROM p_fiscal_period_id::text + OR p_report_data ->> 'accounting_framework' IS DISTINCT FROM p_framework THEN + RAISE EXCEPTION 'Annual report payload does not match its company framework or fiscal period' + USING ERRCODE = 'check_violation'; + END IF; + IF jsonb_typeof(p_validation_summary) IS DISTINCT FROM 'object' + OR p_validation_summary -> 'ok' IS DISTINCT FROM 'true'::jsonb + OR p_validation_summary -> 'error_count' IS DISTINCT FROM '0'::jsonb + OR p_validation_summary ->> 'stage' IS DISTINCT FROM 'signing' + OR jsonb_typeof(p_validation_summary -> 'profile') IS DISTINCT FROM 'object' + OR jsonb_typeof(p_validation_summary -> 'disclosures') IS DISTINCT FROM 'object' + OR jsonb_typeof(p_validation_summary -> 'eligibility') IS DISTINCT FROM 'object' + OR p_validation_summary #>> '{profile,company_id}' IS DISTINCT FROM p_company_id::text + OR p_validation_summary #>> '{profile,fiscal_period_id}' IS DISTINCT FROM p_fiscal_period_id::text THEN + RAISE EXCEPTION 'Annual report finalization requires a complete server validation snapshot' + USING ERRCODE = 'check_violation'; + END IF; + + SELECT signer_roster_confirmed_at + INTO live_roster_confirmation + FROM public.annual_report_profiles + WHERE company_id = p_company_id + AND fiscal_period_id = p_fiscal_period_id; + IF live_roster_confirmation IS NULL + OR (p_validation_summary #>> '{profile,signer_roster_confirmed_at}')::timestamptz + IS DISTINCT FROM live_roster_confirmation THEN + RAISE EXCEPTION 'Annual report signer roster must be confirmed against the current company representatives' + USING ERRCODE = 'check_violation'; + END IF; + + SELECT * INTO version_row + FROM public.create_annual_report_version( + p_company_id, + p_fiscal_period_id, + p_schema_version, + p_framework, + 'draft', + p_report_data, + p_ixbrl_data, + p_content_hash, + p_taxonomy_version, + p_entry_point, + p_validation_summary, + p_user_id + ); + + IF version_row.status = 'draft' THEN + UPDATE public.annual_report_versions + SET status = 'ready_for_signature', + finalized_at = now(), + finalized_by = p_user_id + WHERE id = version_row.id + RETURNING * INTO version_row; + + UPDATE public.annual_report_versions + SET status = 'superseded' + WHERE id = version_row.supersedes_version_id + AND status IN ('ready_for_signature', 'signed'); + END IF; + + IF version_row.status = 'ready_for_signature' THEN + PERFORM public.prepare_annual_report_signature_slots( + p_company_id, + p_fiscal_period_id, + version_row.id, + p_user_id + ); + IF NOT EXISTS ( + SELECT 1 + FROM public.arsredovisning_signature_requests + WHERE annual_report_version_id = version_row.id + ) THEN + RAISE EXCEPTION 'Annual report version requires at least one signer slot' + USING ERRCODE = 'check_violation'; + END IF; + END IF; + + RETURN NEXT version_row; +END; +$$; + +REVOKE ALL ON FUNCTION public.create_annual_report_version_with_signatures( + uuid, uuid, text, text, text, jsonb, jsonb, text, text, text, jsonb, uuid +) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.create_annual_report_version_with_signatures( + uuid, uuid, text, text, text, jsonb, jsonb, text, text, text, jsonb, uuid +) TO service_role; + +CREATE INDEX idx_arsredovisning_sigreq_version + ON public.arsredovisning_signature_requests (company_id, annual_report_version_id); +WITH duplicate_roster_slots AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY company_id, fiscal_period_id, lower(trim(role)), lower(trim(signer_name)) + ORDER BY created_at, id + ) AS duplicate_number + FROM public.arsredovisning_signature_requests + WHERE annual_report_version_id IS NULL AND status = 'pending' +) +DELETE FROM public.arsredovisning_signature_requests request +USING duplicate_roster_slots duplicate +WHERE request.id = duplicate.id + AND duplicate.duplicate_number > 1; +CREATE UNIQUE INDEX uq_arsredovisning_sigreq_unbound_roster + ON public.arsredovisning_signature_requests ( + company_id, + fiscal_period_id, + (lower(trim(role))), + (lower(trim(signer_name))) + ) + WHERE annual_report_version_id IS NULL AND status = 'pending'; + +CREATE TRIGGER validate_arsredovisning_signature_version_links + BEFORE INSERT OR UPDATE ON public.arsredovisning_signature_requests + FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links(); + +CREATE TRIGGER audit_arsredovisning_signature_requests + AFTER INSERT OR UPDATE OR DELETE ON public.arsredovisning_signature_requests + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +CREATE OR REPLACE FUNCTION public.serialize_annual_report_signature_signing() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.status = 'signed' + AND OLD.status IS DISTINCT FROM 'signed' + AND NEW.annual_report_version_id IS NOT NULL THEN + PERFORM pg_advisory_xact_lock( + hashtextextended(NEW.annual_report_version_id::text, 0) + ); + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER serialize_annual_report_signature_signing + BEFORE UPDATE ON public.arsredovisning_signature_requests + FOR EACH ROW EXECUTE FUNCTION public.serialize_annual_report_signature_signing(); + +CREATE OR REPLACE FUNCTION public.complete_annual_report_version_signing() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.status = 'signed' + AND OLD.status IS DISTINCT FROM 'signed' + AND NEW.annual_report_version_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM public.arsredovisning_signature_requests pending_signature + WHERE pending_signature.annual_report_version_id = NEW.annual_report_version_id + AND pending_signature.status <> 'signed' + ) THEN + UPDATE public.annual_report_versions + SET status = 'signed' + WHERE id = NEW.annual_report_version_id + AND company_id = NEW.company_id + AND fiscal_period_id = NEW.fiscal_period_id + AND status = 'ready_for_signature'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER complete_annual_report_version_signing + AFTER UPDATE ON public.arsredovisning_signature_requests + FOR EACH ROW EXECUTE FUNCTION public.complete_annual_report_version_signing(); + +CREATE OR REPLACE FUNCTION public.enforce_signed_signature_request_immutability() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF OLD.status = 'signed' THEN + IF to_jsonb(NEW) IS DISTINCT FROM to_jsonb(OLD) THEN + RAISE EXCEPTION 'Cannot modify a signed signature request (id=%)', OLD.id + USING ERRCODE = 'check_violation'; + END IF; + ELSIF OLD.status = 'declined' THEN + IF to_jsonb(NEW) IS DISTINCT FROM to_jsonb(OLD) THEN + RAISE EXCEPTION 'Cannot modify a declined signature request (id=%)', OLD.id + USING ERRCODE = 'check_violation'; + END IF; + END IF; + RETURN NEW; +END; +$$; + +UPDATE public.arsredovisning_submissions +SET handling_typ = 'arsredovisning_komplett' +WHERE handling_typ = 'arsredovisning'; + +ALTER TABLE public.arsredovisning_submissions + DROP CONSTRAINT IF EXISTS arsredovisning_submissions_handling_typ_check, + DROP CONSTRAINT IF EXISTS arsredovisning_submissions_status_check; + +ALTER TABLE public.arsredovisning_submissions + ADD CONSTRAINT arsredovisning_submissions_handling_typ_check CHECK ( + handling_typ IN ('arsredovisning_komplett', 'arsredovisning_kompletteras', 'revisionsberattelse') + ), + ADD CONSTRAINT arsredovisning_submissions_status_check CHECK ( + status IN ( + 'draft', 'kontrollerad', 'sending', 'uploaded', 'unknown', 'inkommen', + 'forelagd', 'komplettering', 'registrerad', 'avslutad', 'error' + ) + ), + ADD COLUMN annual_report_version_id uuid REFERENCES public.annual_report_versions(id) ON DELETE RESTRICT, + ADD COLUMN request_key text, + ADD COLUMN archive_status text NOT NULL DEFAULT 'pending' CHECK ( + archive_status IN ('pending', 'stored', 'failed') + ), + ADD COLUMN upload_started_at timestamptz, + ADD COLUMN external_receipt jsonb; + +CREATE UNIQUE INDEX uq_arsredovisning_submissions_request_key + ON public.arsredovisning_submissions (company_id, environment, request_key) + WHERE request_key IS NOT NULL; + +CREATE UNIQUE INDEX uq_arsredovisning_submissions_remote_id + ON public.arsredovisning_submissions (environment, idnummer) + WHERE idnummer IS NOT NULL; + +CREATE TRIGGER validate_arsredovisning_submission_version_links + BEFORE INSERT OR UPDATE ON public.arsredovisning_submissions + FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links(); + +CREATE OR REPLACE FUNCTION public.enforce_arsred_submission_immutability() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF OLD.uploaded_at IS NOT NULL OR OLD.status IN ('sending', 'unknown', 'uploaded', 'inkommen', 'forelagd', 'komplettering', 'registrerad', 'avslutad') THEN + IF NEW.company_id IS DISTINCT FROM OLD.company_id + OR NEW.fiscal_period_id IS DISTINCT FROM OLD.fiscal_period_id + OR NEW.annual_report_version_id IS DISTINCT FROM OLD.annual_report_version_id + OR NEW.handling_typ IS DISTINCT FROM OLD.handling_typ + OR NEW.taxonomy_version IS DISTINCT FROM OLD.taxonomy_version + OR NEW.entry_point IS DISTINCT FROM OLD.entry_point + OR NEW.environment IS DISTINCT FROM OLD.environment + OR NEW.request_key IS DISTINCT FROM OLD.request_key + OR (OLD.idnummer IS NOT NULL AND NEW.idnummer IS DISTINCT FROM OLD.idnummer) + OR (OLD.sha256_checksumma IS NOT NULL AND NEW.sha256_checksumma IS DISTINCT FROM OLD.sha256_checksumma) + OR NEW.kontrollsumma IS DISTINCT FROM OLD.kontrollsumma + OR NEW.dokument_id IS DISTINCT FROM OLD.dokument_id + OR NEW.archive_status IS DISTINCT FROM OLD.archive_status + OR NEW.undertecknare_pnr_hash IS DISTINCT FROM OLD.undertecknare_pnr_hash + OR NEW.avsandare_pnr_hash IS DISTINCT FROM OLD.avsandare_pnr_hash + OR NEW.upload_started_at IS DISTINCT FROM OLD.upload_started_at + OR (OLD.bolagsverket_url IS NOT NULL AND NEW.bolagsverket_url IS DISTINCT FROM OLD.bolagsverket_url) + OR (OLD.external_receipt IS NOT NULL AND NEW.external_receipt IS DISTINCT FROM OLD.external_receipt) + OR (OLD.uploaded_at IS NOT NULL AND NEW.uploaded_at IS DISTINCT FROM OLD.uploaded_at) THEN + RAISE EXCEPTION 'Inlämnad årsredovisning kan inte ändras (submission %)', OLD.id + USING ERRCODE = 'P0001'; + END IF; + END IF; + + IF NEW.status IS DISTINCT FROM OLD.status AND NOT ( + (OLD.status = 'draft' AND NEW.status IN ('kontrollerad', 'error')) + OR (OLD.status = 'kontrollerad' AND NEW.status IN ('kontrollerad', 'sending', 'error', 'draft')) + OR (OLD.status = 'sending' AND NEW.status IN ('uploaded', 'unknown')) + OR (OLD.status = 'unknown' AND NEW.status IN ('uploaded', 'inkommen', 'forelagd', 'komplettering', 'registrerad', 'avslutad')) + OR (OLD.status = 'uploaded' AND NEW.status IN ('inkommen', 'forelagd', 'komplettering', 'registrerad', 'avslutad', 'error')) + OR (OLD.status = 'inkommen' AND NEW.status IN ('forelagd', 'komplettering', 'registrerad', 'avslutad')) + OR (OLD.status = 'forelagd' AND NEW.status IN ('komplettering', 'registrerad', 'avslutad')) + OR (OLD.status = 'komplettering' AND NEW.status IN ('forelagd', 'registrerad', 'avslutad')) + OR (OLD.status = 'error' AND NEW.status IN ('draft', 'kontrollerad')) + ) THEN + RAISE EXCEPTION 'Ogiltig statusövergång för årsredovisningsinlämning: % till %', OLD.status, NEW.status + USING ERRCODE = 'P0001'; + END IF; + + RETURN NEW; +END; +$$; + +DROP POLICY IF EXISTS "arsredovisning_sigreq_insert" + ON public.arsredovisning_signature_requests; +DROP POLICY IF EXISTS "arsredovisning_sigreq_update" + ON public.arsredovisning_signature_requests; +DROP POLICY IF EXISTS "arsredovisning_sigreq_delete" + ON public.arsredovisning_signature_requests; + +CREATE POLICY "arsredovisning_sigreq_insert" + ON public.arsredovisning_signature_requests FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = arsredovisning_signature_requests.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ); +CREATE POLICY "arsredovisning_sigreq_update" + ON public.arsredovisning_signature_requests FOR UPDATE + USING ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = arsredovisning_signature_requests.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = arsredovisning_signature_requests.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ); +CREATE POLICY "arsredovisning_sigreq_delete" + ON public.arsredovisning_signature_requests FOR DELETE + USING ( + status = 'pending' + AND annual_report_version_id IS NULL + AND EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = arsredovisning_signature_requests.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ); + +DROP POLICY IF EXISTS "insert own-company arsredovisning submissions" + ON public.arsredovisning_submissions; +DROP POLICY IF EXISTS "update own-company arsredovisning submissions" + ON public.arsredovisning_submissions; + +CREATE POLICY "insert own-company arsredovisning submissions" + ON public.arsredovisning_submissions FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = arsredovisning_submissions.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ); +CREATE POLICY "update own-company arsredovisning submissions" + ON public.arsredovisning_submissions FOR UPDATE + USING ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = arsredovisning_submissions.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = arsredovisning_submissions.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ); + +DROP POLICY IF EXISTS "insert own-company avtal acceptances" + ON public.bolagsverket_avtal_acceptances; +CREATE POLICY "insert own-company avtal acceptances" + ON public.bolagsverket_avtal_acceptances FOR INSERT + WITH CHECK ( + user_id = auth.uid() + AND EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = bolagsverket_avtal_acceptances.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ); + +DROP POLICY IF EXISTS "insert own-company bolagsverket subscriptions" + ON public.bolagsverket_subscriptions; +DROP POLICY IF EXISTS "update own-company bolagsverket subscriptions" + ON public.bolagsverket_subscriptions; +DROP POLICY IF EXISTS "delete own-company bolagsverket subscriptions" + ON public.bolagsverket_subscriptions; + +CREATE POLICY "insert own-company bolagsverket subscriptions" + ON public.bolagsverket_subscriptions FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = bolagsverket_subscriptions.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ); +CREATE POLICY "update own-company bolagsverket subscriptions" + ON public.bolagsverket_subscriptions FOR UPDATE + USING ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = bolagsverket_subscriptions.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = bolagsverket_subscriptions.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ); +CREATE POLICY "delete own-company bolagsverket subscriptions" + ON public.bolagsverket_subscriptions FOR DELETE + USING ( + EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_members.company_id = bolagsverket_subscriptions.company_id + AND company_members.user_id = auth.uid() + AND company_members.role IN ('owner', 'admin', 'member') + ) + ); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260721161708_increase_invoice_logo_limit.sql b/supabase/migrations/20260721161708_increase_invoice_logo_limit.sql new file mode 100644 index 00000000..f728168e --- /dev/null +++ b/supabase/migrations/20260721161708_increase_invoice_logo_limit.sql @@ -0,0 +1,8 @@ +-- Allow larger company logos while keeping the upload route and storage limit +-- aligned at 10 MB. Existing files and public access remain unchanged. + +UPDATE storage.buckets +SET file_size_limit = 10485760 +WHERE id = 'logos'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260721162121_expand_invoice_font_support.sql b/supabase/migrations/20260721162121_expand_invoice_font_support.sql new file mode 100644 index 00000000..9f2140c8 --- /dev/null +++ b/supabase/migrations/20260721162121_expand_invoice_font_support.sql @@ -0,0 +1,37 @@ +-- Add bundled and company-uploaded invoice fonts. Custom font files live in a +-- dedicated private bucket. The server embeds each font into the generated PDF, +-- so customer font files never need public URLs. + +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS invoice_custom_font_path TEXT NULL, + ADD COLUMN IF NOT EXISTS invoice_custom_font_name TEXT NULL; + +ALTER TABLE public.company_settings + DROP CONSTRAINT IF EXISTS company_settings_invoice_font_check; +ALTER TABLE public.company_settings + ADD CONSTRAINT company_settings_invoice_font_check + CHECK ( + invoice_font_family IN ( + 'Helvetica', + 'Times-Roman', + 'Courier', + 'Source Sans 3', + 'Source Serif 4', + 'Custom' + ) + ); + +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'invoice-fonts', + 'invoice-fonts', + false, + 5242880, + ARRAY['font/ttf', 'font/woff'] +) +ON CONFLICT (id) DO UPDATE + SET public = EXCLUDED.public, + file_size_limit = EXCLUDED.file_size_limit, + allowed_mime_types = EXCLUDED.allowed_mime_types; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260721201747_harden_annual_report_signature_transitions.sql b/supabase/migrations/20260721201747_harden_annual_report_signature_transitions.sql new file mode 100644 index 00000000..3b7d9351 --- /dev/null +++ b/supabase/migrations/20260721201747_harden_annual_report_signature_transitions.sql @@ -0,0 +1,116 @@ +-- Keep signer roster drafting available to authenticated company members, but +-- reserve legally significant signature transitions for the trusted service +-- role after the API has verified ownership, version state, and evidence. + +DROP POLICY IF EXISTS "arsredovisning_sigreq_insert" + ON public.arsredovisning_signature_requests; + +CREATE POLICY "arsredovisning_sigreq_insert" + ON public.arsredovisning_signature_requests + FOR INSERT + TO authenticated + WITH CHECK ( + company_id IN (SELECT public.user_company_ids()) + AND user_id = (SELECT auth.uid()) + AND status = 'pending' + AND annual_report_version_id IS NULL + AND signing_method IS NULL + AND evidence_reference IS NULL + AND evidence_recorded_by IS NULL + AND evidence_recorded_at IS NULL + AND signed_at IS NULL + AND bankid_signature_data IS NULL + AND signer_personnummer_encrypted IS NULL + AND signer_personnummer_hash IS NULL + AND EXISTS ( + SELECT 1 + FROM public.fiscal_periods fp + WHERE fp.id = public.arsredovisning_signature_requests.fiscal_period_id + AND fp.company_id = public.arsredovisning_signature_requests.company_id + ) + ); + +-- Authenticated users may no longer transition or rewrite a signature row +-- directly through PostgREST. The service role bypasses RLS and is used only +-- after the API route has completed its ownership and state checks. +DROP POLICY IF EXISTS "arsredovisning_sigreq_update" + ON public.arsredovisning_signature_requests; + +DROP POLICY IF EXISTS "arsredovisning_sigreq_delete" + ON public.arsredovisning_signature_requests; + +CREATE POLICY "arsredovisning_sigreq_delete" + ON public.arsredovisning_signature_requests + FOR DELETE + TO authenticated + USING ( + company_id IN (SELECT public.user_company_ids()) + AND status = 'pending' + AND annual_report_version_id IS NULL + AND EXISTS ( + SELECT 1 + FROM public.fiscal_periods fp + WHERE fp.id = public.arsredovisning_signature_requests.fiscal_period_id + AND fp.company_id = public.arsredovisning_signature_requests.company_id + ) + ); + +-- Existing signed evidence is retained unchanged. NOT VALID preserves those +-- immutable historical rows while enforcing the structured format for every +-- new or updated reference. +ALTER TABLE public.arsredovisning_signature_requests + ADD CONSTRAINT arsredovisning_signature_evidence_reference_format + CHECK ( + evidence_reference IS NULL + OR evidence_reference ~ '^(archive|document|receipt):[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$' + ) NOT VALID; + +COMMENT ON COLUMN public.arsredovisning_signature_requests.evidence_reference IS + 'Opaque reference only: archive:, document:, or receipt:. Never store free text or personal data.'; + +CREATE OR REPLACE FUNCTION public.enforce_annual_report_signature_version_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + linked_version_status text; + linked_version_finalized_date date; +BEGIN + IF NEW.status <> 'signed' THEN + RETURN NEW; + END IF; + IF TG_OP = 'UPDATE' AND OLD.status = 'signed' THEN + RETURN NEW; + END IF; + + SELECT + version.status, + (version.finalized_at AT TIME ZONE 'Europe/Stockholm')::date + INTO linked_version_status, linked_version_finalized_date + FROM public.annual_report_versions version + WHERE version.id = NEW.annual_report_version_id; + + IF linked_version_status IS DISTINCT FROM 'ready_for_signature' + OR linked_version_finalized_date IS NULL THEN + RAISE EXCEPTION 'Annual report version is not ready for signature evidence' + USING ERRCODE = 'check_violation'; + END IF; + + IF (NEW.signed_at AT TIME ZONE 'Europe/Stockholm')::date + < linked_version_finalized_date + OR (NEW.signed_at AT TIME ZONE 'Europe/Stockholm')::date + > (now() AT TIME ZONE 'Europe/Stockholm')::date THEN + RAISE EXCEPTION 'Signature date must be between version finalization and today' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER enforce_annual_report_signature_version_state + BEFORE INSERT OR UPDATE ON public.arsredovisning_signature_requests + FOR EACH ROW EXECUTE FUNCTION public.enforce_annual_report_signature_version_state(); + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/helpers.ts b/tests/helpers.ts index ca1d840c..568233a6 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -595,6 +595,8 @@ export function makeCompanySettings( invoice_primary_color: '#1a1a1a', invoice_accent_color: '#666666', invoice_font_family: 'Helvetica', + invoice_custom_font_path: null, + invoice_custom_font_name: null, invoice_header_text: null, invoice_footer_text: null, invoice_email_texts: null, diff --git a/tests/pg/annual-report-versions.pg.test.ts b/tests/pg/annual-report-versions.pg.test.ts new file mode 100644 index 00000000..d9494a31 --- /dev/null +++ b/tests/pg/annual-report-versions.pg.test.ts @@ -0,0 +1,537 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import type { PoolClient } from 'pg' +import { getClient, getPool, withUserContext } from './setup' +import { insertAuthUser, seedCompany } from './fixtures' + +async function withCommittedRoleContext( + userId: string, + role: 'authenticated' | 'service_role', + fn: (client: PoolClient) => Promise, +): Promise { + const client = await getClient() + try { + await client.query('BEGIN') + await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [ + JSON.stringify({ sub: userId, role }), + ]) + await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId]) + await client.query(`SELECT set_config('request.jwt.claim.role', $1, true)`, [role]) + await client.query(`SET LOCAL ROLE ${role}`) + const result = await fn(client) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + client.release() + } +} + +function reportData(fiscalPeriodId: string) { + return { + accounting_framework: 'k2', + company: { name: 'Test AB', org_number: '556012-5790' }, + fiscal_period: { + id: fiscalPeriodId, + period_start: '2025-01-01', + period_end: '2025-12-31', + }, + } +} + +async function createVersion(params: { + companyId: string + userId: string + fiscalPeriodId: string + hash?: string + status?: 'draft' | 'ready_for_signature' +}) { + let validationSummary: Record = { + stage: 'draft', + ok: true, + error_count: 0, + warning_count: 0, + issues: [], + } + if (params.status === 'ready_for_signature') { + const profile = await getPool().query<{ signer_roster_confirmed_at: string }>( + `INSERT INTO public.annual_report_profiles + (company_id, fiscal_period_id, user_id, is_in_liquidation, signer_roster_confirmed_at) + VALUES ($1, $2, $3, false, now()) + ON CONFLICT (company_id, fiscal_period_id) DO UPDATE + SET signer_roster_confirmed_at = now() + RETURNING signer_roster_confirmed_at::text`, + [params.companyId, params.fiscalPeriodId, params.userId], + ) + validationSummary = { + stage: 'signing', + ok: true, + error_count: 0, + warning_count: 0, + issues: [], + digital_filing_eligible: true, + digital_issues: [], + profile: { + company_id: params.companyId, + fiscal_period_id: params.fiscalPeriodId, + signer_roster_confirmed_at: profile.rows[0].signer_roster_confirmed_at, + }, + disclosures: {}, + eligibility: {}, + } + } + const rpcName = + params.status === 'ready_for_signature' + ? 'create_annual_report_version_with_signatures' + : 'create_annual_report_version' + return withCommittedRoleContext( + params.userId, + params.status === 'ready_for_signature' ? 'service_role' : 'authenticated', + (client) => + client.query<{ + id: string + version_number: number + status: string + }>( + `SELECT id, version_number, status + FROM public.${rpcName}( + $1, $2, '1.0', 'k2', $3, $4::jsonb, $5::jsonb, $6, '2024-09-12', + 'k2-ab-risbs-2024-09-12', $7::jsonb, $8 + )`, + [ + params.companyId, + params.fiscalPeriodId, + params.status ?? 'draft', + JSON.stringify(reportData(params.fiscalPeriodId)), + JSON.stringify({ entryPointId: 'k2-ab-risbs-2024-09-12' }), + params.hash ?? 'a'.repeat(64), + JSON.stringify(validationSummary), + params.userId, + ], + ), + ) +} + +describe('annual report profile and version enforcement', () => { + it('isolates profiles by membership and rejects cross-company period links', async () => { + const owner = await seedCompany() + const strangerId = await insertAuthUser() + const viewerId = await insertAuthUser() + await getPool().query( + `INSERT INTO public.company_members (company_id, user_id, role) + VALUES ($1, $2, 'viewer')`, + [owner.companyId, viewerId], + ) + const profileId = randomUUID() + await getPool().query( + `INSERT INTO public.annual_report_profiles + (id, company_id, fiscal_period_id, user_id, is_public_limited_company) + VALUES ($1, $2, $3, $4, false)`, + [profileId, owner.companyId, owner.fiscalPeriodId, owner.userId], + ) + + const ownerView = await withUserContext(owner.userId, (client) => + client.query('SELECT id FROM public.annual_report_profiles WHERE id = $1', [profileId]), + ) + expect(ownerView.rows).toHaveLength(1) + const strangerView = await withUserContext(strangerId, (client) => + client.query('SELECT id FROM public.annual_report_profiles WHERE id = $1', [profileId]), + ) + expect(strangerView.rows).toHaveLength(0) + const viewerView = await withUserContext(viewerId, (client) => + client.query('SELECT id FROM public.annual_report_profiles WHERE id = $1', [profileId]), + ) + expect(viewerView.rows).toHaveLength(1) + await expect( + withUserContext(viewerId, (client) => + client.query( + `UPDATE public.annual_report_profiles + SET is_public_limited_company = true + WHERE id = $1`, + [profileId], + ), + ), + ).resolves.toMatchObject({ rowCount: 0 }) + + const second = await seedCompany() + await expect( + getPool().query( + `INSERT INTO public.annual_report_profiles + (company_id, fiscal_period_id, user_id) + VALUES ($1, $2, $3)`, + [owner.companyId, second.fiscalPeriodId, owner.userId], + ), + ).rejects.toThrow(/does not belong to annual report company/i) + }) + + it('allocates sequential versions, deduplicates content, and finalizes a draft atomically', async () => { + const owner = await seedCompany() + const first = await createVersion(owner) + expect(first.rows[0]).toMatchObject({ version_number: 1, status: 'draft' }) + + const signatureId = randomUUID() + await getPool().query( + `INSERT INTO public.arsredovisning_signature_requests + (id, user_id, company_id, fiscal_period_id, role, signer_name) + VALUES ($1, $2, $3, $4, 'Styrelseledamot', 'Anna Andersson')`, + [signatureId, owner.userId, owner.companyId, owner.fiscalPeriodId], + ) + + const finalized = await createVersion({ ...owner, status: 'ready_for_signature' }) + expect(finalized.rows[0]).toMatchObject({ + id: first.rows[0].id, + version_number: 1, + status: 'ready_for_signature', + }) + const boundSignature = await getPool().query( + `SELECT annual_report_version_id + FROM public.arsredovisning_signature_requests + WHERE id = $1`, + [signatureId], + ) + expect(boundSignature.rows[0].annual_report_version_id).toBe(first.rows[0].id) + + const second = await createVersion({ ...owner, hash: 'b'.repeat(64) }) + expect(second.rows[0]).toMatchObject({ version_number: 2, status: 'draft' }) + }) + + it('does not finalize a version without a signer roster', async () => { + const owner = await seedCompany() + await expect( + createVersion({ ...owner, status: 'ready_for_signature' }), + ).rejects.toThrow(/requires at least one signer slot/i) + }) + + it('keeps finalization behind the trusted service boundary and rejects forged validation', async () => { + const owner = await seedCompany() + await expect( + withUserContext(owner.userId, (client) => + client.query( + `SELECT * FROM public.create_annual_report_version_with_signatures( + $1, $2, '1.0', 'k2', 'ready_for_signature', $3::jsonb, $4::jsonb, + $5, '2024-09-12', 'k2-ab-risbs-2024-09-12', $6::jsonb, $7 + )`, + [ + owner.companyId, + owner.fiscalPeriodId, + JSON.stringify(reportData(owner.fiscalPeriodId)), + JSON.stringify({ entryPointId: 'k2-ab-risbs-2024-09-12' }), + 'd'.repeat(64), + JSON.stringify({ ok: true, issues: [] }), + owner.userId, + ], + ), + ), + ).rejects.toThrow(/permission denied|trusted application service/i) + + await expect( + withCommittedRoleContext(owner.userId, 'service_role', (client) => + client.query( + `SELECT * FROM public.create_annual_report_version_with_signatures( + $1, $2, '1.0', 'k2', 'ready_for_signature', $3::jsonb, $4::jsonb, + $5, '2024-09-12', 'k2-ab-risbs-2024-09-12', $6::jsonb, $7 + )`, + [ + owner.companyId, + owner.fiscalPeriodId, + JSON.stringify(reportData(owner.fiscalPeriodId)), + JSON.stringify({ entryPointId: 'k2-ab-risbs-2024-09-12' }), + 'e'.repeat(64), + JSON.stringify({ ok: true, issues: [] }), + owner.userId, + ], + ), + ), + ).rejects.toThrow(/complete server validation snapshot/i) + }) + + it('keeps signature evidence transitions behind the trusted service boundary', async () => { + const owner = await seedCompany() + const signatureId = randomUUID() + + const directInsert = await withUserContext(owner.userId, (client) => + client.query( + `INSERT INTO public.arsredovisning_signature_requests + (id, user_id, company_id, fiscal_period_id, role, signer_name) + VALUES ($1, $2, $3, $4, 'Styrelseledamot', 'Anna Andersson') + RETURNING id`, + [signatureId, owner.userId, owner.companyId, owner.fiscalPeriodId], + ), + ) + expect(directInsert.rowCount).toBe(1) + await getPool().query( + `INSERT INTO public.arsredovisning_signature_requests + (id, user_id, company_id, fiscal_period_id, role, signer_name) + VALUES ($1, $2, $3, $4, 'Styrelseledamot', 'Anna Andersson')`, + [signatureId, owner.userId, owner.companyId, owner.fiscalPeriodId], + ) + const version = await createVersion({ ...owner, status: 'ready_for_signature' }) + + const directUpdate = await withUserContext(owner.userId, (client) => + client.query( + `UPDATE public.arsredovisning_signature_requests + SET status = 'signed', signed_at = now(), + signing_method = 'paper_original', evidence_reference = 'archive:A-1', + evidence_recorded_by = $2, evidence_recorded_at = now() + WHERE id = $1 + RETURNING id`, + [signatureId, owner.userId], + ), + ) + expect(directUpdate.rowCount).toBe(0) + + const directDelete = await withUserContext(owner.userId, (client) => + client.query( + `DELETE FROM public.arsredovisning_signature_requests + WHERE id = $1 + RETURNING id`, + [signatureId], + ), + ) + expect(directDelete.rowCount).toBe(0) + + await expect( + withUserContext(owner.userId, (client) => + client.query( + `INSERT INTO public.arsredovisning_signature_requests + (user_id, company_id, fiscal_period_id, role, signer_name, status, + annual_report_version_id, signed_at, signing_method, evidence_reference, + evidence_recorded_by, evidence_recorded_at) + VALUES ($1, $2, $3, 'VD', 'Erik Eriksson', 'signed', $4, now(), + 'paper_original', 'archive:A-2', $1, now())`, + [owner.userId, owner.companyId, owner.fiscalPeriodId, version.rows[0].id], + ), + ), + ).rejects.toThrow(/row-level security policy/i) + }) + + it('invalidates representative confirmation when the draft signer roster changes', async () => { + const owner = await seedCompany() + await getPool().query( + `INSERT INTO public.annual_report_profiles + (company_id, fiscal_period_id, user_id, signer_roster_confirmed_at) + VALUES ($1, $2, $3, now())`, + [owner.companyId, owner.fiscalPeriodId, owner.userId], + ) + await getPool().query( + `INSERT INTO public.arsredovisning_signature_requests + (user_id, company_id, fiscal_period_id, role, signer_name) + VALUES ($1, $2, $3, 'Styrelseledamot', 'Anna Andersson')`, + [owner.userId, owner.companyId, owner.fiscalPeriodId], + ) + const profile = await getPool().query<{ signer_roster_confirmed_at: string | null }>( + `SELECT signer_roster_confirmed_at::text + FROM public.annual_report_profiles + WHERE company_id = $1 AND fiscal_period_id = $2`, + [owner.companyId, owner.fiscalPeriodId], + ) + expect(profile.rows[0].signer_roster_confirmed_at).toBeNull() + }) + + it('does not allow callers to bypass signer preparation with the draft RPC', async () => { + const owner = await seedCompany() + await expect( + withUserContext(owner.userId, (client) => + client.query( + `SELECT * FROM public.create_annual_report_version( + $1, $2, '1.0', 'k2', 'ready_for_signature', $3::jsonb, $4::jsonb, + $5, '2024-09-12', 'k2-ab-risbs-2024-09-12', $6::jsonb, $7 + )`, + [ + owner.companyId, + owner.fiscalPeriodId, + JSON.stringify(reportData(owner.fiscalPeriodId)), + JSON.stringify({ entryPointId: 'k2-ab-risbs-2024-09-12' }), + 'c'.repeat(64), + JSON.stringify({ ok: true, issues: [] }), + owner.userId, + ], + ), + ), + ).rejects.toThrow(/only permits draft status/i) + }) + + it('keeps version content immutable and blocks member deletion', async () => { + const owner = await seedCompany() + const version = await createVersion(owner) + + await expect( + withUserContext(owner.userId, (client) => + client.query( + `UPDATE public.annual_report_versions + SET report_data = '{"changed":true}'::jsonb + WHERE id = $1`, + [version.rows[0].id], + ), + ), + ).rejects.toThrow(/content is immutable/i) + + const deleted = await withUserContext(owner.userId, (client) => + client.query('DELETE FROM public.annual_report_versions WHERE id = $1 RETURNING id', [ + version.rows[0].id, + ]), + ) + expect(deleted.rowCount).toBe(0) + await expect( + getPool().query('DELETE FROM public.annual_report_versions WHERE id = $1', [ + version.rows[0].id, + ]), + ).rejects.toThrow(/retained as immutable accounting information/i) + await expect( + getPool().query('DELETE FROM public.fiscal_periods WHERE id = $1', [ + owner.fiscalPeriodId, + ]), + ).rejects.toThrow() + }) + + it('requires version-bound signature evidence and freezes signed rows', async () => { + const owner = await seedCompany() + const signatureId = randomUUID() + await getPool().query( + `INSERT INTO public.arsredovisning_signature_requests + (id, user_id, company_id, fiscal_period_id, role, signer_name) + VALUES ($1, $2, $3, $4, 'Styrelseledamot', 'Anna Andersson')`, + [signatureId, owner.userId, owner.companyId, owner.fiscalPeriodId], + ) + const version = await createVersion({ ...owner, status: 'ready_for_signature' }) + + await expect( + getPool().query( + `UPDATE public.annual_report_versions + SET status = 'signed' + WHERE id = $1`, + [version.rows[0].id], + ), + ).rejects.toThrow(/before every locked signer has signed/i) + + await expect( + getPool().query( + `UPDATE public.arsredovisning_signature_requests + SET status = 'signed', signed_at = now(), annual_report_version_id = $2 + WHERE id = $1`, + [signatureId, version.rows[0].id], + ), + ).rejects.toThrow(/signature_evidence_consistency|check constraint/i) + + await expect( + getPool().query( + `UPDATE public.arsredovisning_signature_requests + SET status = 'signed', signed_at = now() - interval '1 day', + signing_method = 'paper_original', evidence_reference = 'archive:A-1', + evidence_recorded_by = $2, evidence_recorded_at = now() + WHERE id = $1`, + [signatureId, owner.userId], + ), + ).rejects.toThrow(/signature date must be between version finalization and today/i) + + await expect( + getPool().query( + `UPDATE public.arsredovisning_signature_requests + SET status = 'signed', signed_at = now() + interval '1 day', + signing_method = 'paper_original', evidence_reference = 'archive:A-1', + evidence_recorded_by = $2, evidence_recorded_at = now() + WHERE id = $1`, + [signatureId, owner.userId], + ), + ).rejects.toThrow(/signature date must be between version finalization and today/i) + + await getPool().query( + `UPDATE public.arsredovisning_signature_requests + SET status = 'signed', signed_at = now(), annual_report_version_id = $2, + signing_method = 'paper_original', evidence_reference = 'archive:A-1', + evidence_recorded_by = $3, evidence_recorded_at = now() + WHERE id = $1`, + [signatureId, version.rows[0].id, owner.userId], + ) + const signedVersion = await getPool().query( + 'SELECT status FROM public.annual_report_versions WHERE id = $1', + [version.rows[0].id], + ) + expect(signedVersion.rows[0].status).toBe('signed') + await expect( + getPool().query( + `UPDATE public.arsredovisning_signature_requests + SET evidence_reference = 'changed' WHERE id = $1`, + [signatureId], + ), + ).rejects.toThrow(/cannot modify a signed signature request/i) + }) + + it('rejects signature evidence after the linked version stops accepting signatures', async () => { + const owner = await seedCompany() + const signatureId = randomUUID() + await getPool().query( + `INSERT INTO public.arsredovisning_signature_requests + (id, user_id, company_id, fiscal_period_id, role, signer_name) + VALUES ($1, $2, $3, $4, 'Styrelseledamot', 'Anna Andersson')`, + [signatureId, owner.userId, owner.companyId, owner.fiscalPeriodId], + ) + const version = await createVersion({ ...owner, status: 'ready_for_signature' }) + await getPool().query( + `UPDATE public.annual_report_versions + SET status = 'superseded' + WHERE id = $1`, + [version.rows[0].id], + ) + + await expect( + getPool().query( + `UPDATE public.arsredovisning_signature_requests + SET status = 'signed', signed_at = now(), + signing_method = 'paper_original', evidence_reference = 'archive:A-1', + evidence_recorded_by = $2, evidence_recorded_at = now() + WHERE id = $1`, + [signatureId, owner.userId], + ), + ).rejects.toThrow(/annual report version is not ready for signature evidence/i) + }) + + it('supersedes an older signed version when a corrected version is locked', async () => { + const owner = await seedCompany() + const signatureId = randomUUID() + await getPool().query( + `INSERT INTO public.arsredovisning_signature_requests + (id, user_id, company_id, fiscal_period_id, role, signer_name) + VALUES ($1, $2, $3, $4, 'Styrelseledamot', 'Anna Andersson')`, + [signatureId, owner.userId, owner.companyId, owner.fiscalPeriodId], + ) + const first = await createVersion({ ...owner, status: 'ready_for_signature' }) + await getPool().query( + `UPDATE public.arsredovisning_signature_requests + SET status = 'signed', signed_at = now(), + signing_method = 'paper_original', evidence_reference = 'archive:A-1', + evidence_recorded_by = $2, evidence_recorded_at = now() + WHERE id = $1`, + [signatureId, owner.userId], + ) + + const correction = await createVersion({ + ...owner, + hash: 'b'.repeat(64), + status: 'ready_for_signature', + }) + expect(correction.rows[0]).toMatchObject({ version_number: 2, status: 'ready_for_signature' }) + + const previous = await getPool().query( + 'SELECT status FROM public.annual_report_versions WHERE id = $1', + [first.rows[0].id], + ) + expect(previous.rows[0].status).toBe('superseded') + }) + + it('rejects validation rows linked to another company version', async () => { + const first = await seedCompany() + const second = await seedCompany() + const version = await createVersion(first) + await expect( + getPool().query( + `INSERT INTO public.annual_report_validation_runs + (company_id, fiscal_period_id, version_id, user_id, validation_layer, status) + VALUES ($1, $2, $3, $4, 'local', 'passed')`, + [second.companyId, second.fiscalPeriodId, version.rows[0].id, second.userId], + ), + ).rejects.toThrow(/belongs to another company or period/i) + }) +}) diff --git a/tests/pg/api-key-audit-noise.pg.test.ts b/tests/pg/api-key-audit-noise.pg.test.ts new file mode 100644 index 00000000..c1133ea4 --- /dev/null +++ b/tests/pg/api-key-audit-noise.pg.test.ts @@ -0,0 +1,72 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { insertAuthUser, insertCompany } from './fixtures' +import { getPool } from './setup' + +describe('API-key audit history', () => { + it('skips request telemetry while retaining security and configuration changes', async () => { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId }) + const apiKeyId = randomUUID() + const keyHash = randomUUID().replaceAll('-', '') + + await getPool().query( + `INSERT INTO public.api_keys + (id, user_id, company_id, key_hash, key_prefix, name, scopes) + VALUES ($1, $2, $3, $4, 'gnubok_sk_test', 'Audit test key', $5)`, + [apiKeyId, userId, companyId, keyHash, ['reports:read']], + ) + + await getPool().query( + `SELECT * FROM public.validate_and_increment_api_key($1)`, + [keyHash], + ) + await getPool().query( + `SELECT * FROM public.validate_and_increment_api_key($1)`, + [keyHash], + ) + + const afterTelemetry = await getPool().query<{ action: string }>( + `SELECT action + FROM public.audit_log + WHERE table_name = 'api_keys' + AND record_id = $1 + ORDER BY created_at, id`, + [apiKeyId], + ) + expect(afterTelemetry.rows.map((row) => row.action)).toEqual(['INSERT']) + + await getPool().query( + `UPDATE public.api_keys + SET name = 'Renamed audit test key', + scopes = ARRAY['reports:read', 'customers:read']::text[] + WHERE id = $1`, + [apiKeyId], + ) + + const afterConfigurationChange = await getPool().query<{ + action: string + old_name: string | null + new_name: string | null + }>( + `SELECT + action, + old_state ->> 'name' AS old_name, + new_state ->> 'name' AS new_name + FROM public.audit_log + WHERE table_name = 'api_keys' + AND record_id = $1 + ORDER BY created_at, id`, + [apiKeyId], + ) + + expect(afterConfigurationChange.rows).toEqual([ + { action: 'INSERT', old_name: null, new_name: 'Audit test key' }, + { + action: 'UPDATE', + old_name: 'Audit test key', + new_name: 'Renamed audit test key', + }, + ]) + }) +}) diff --git a/tests/pg/bfl-retention-expiry.pg.test.ts b/tests/pg/bfl-retention-expiry.pg.test.ts new file mode 100644 index 00000000..81536336 --- /dev/null +++ b/tests/pg/bfl-retention-expiry.pg.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { getPool } from './setup' +import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures' + +describe('BFL retention expiry', () => { + it('retains a fiscal year through the end of the seventh following calendar year', async () => { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId }) + const fiscalPeriodId = await insertFiscalPeriod({ + userId, + companyId, + periodStart: '2025-07-01', + periodEnd: '2026-06-30', + name: '2025/2026', + }) + + const result = await getPool().query<{ retention_expires_at: string }>( + `SELECT retention_expires_at::text + FROM public.fiscal_periods + WHERE id = $1`, + [fiscalPeriodId], + ) + + expect(result.rows[0].retention_expires_at).toBe('2034-01-01') + }) + + it('recalculates the first allowed deletion date when an open period end changes', async () => { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId }) + const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId }) + + await getPool().query( + `UPDATE public.fiscal_periods + SET period_end = '2027-03-31' + WHERE id = $1`, + [fiscalPeriodId], + ) + const result = await getPool().query<{ retention_expires_at: string }>( + `SELECT retention_expires_at::text + FROM public.fiscal_periods + WHERE id = $1`, + [fiscalPeriodId], + ) + + expect(result.rows[0].retention_expires_at).toBe('2035-01-01') + }) +}) diff --git a/tests/pg/fiscal-period-tax-adjustments.pg.test.ts b/tests/pg/fiscal-period-tax-adjustments.pg.test.ts new file mode 100644 index 00000000..609b4403 --- /dev/null +++ b/tests/pg/fiscal-period-tax-adjustments.pg.test.ts @@ -0,0 +1,174 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getPool, withUserContext } from './setup' +import { + insertAuthUser, + insertCompany, + insertCompanyMember, + insertFiscalPeriod, + seedCompany, +} from './fixtures' + +async function setActiveCompany(userId: string, companyId: string): Promise { + await getPool().query( + `INSERT INTO public.user_preferences (user_id, active_company_id) + VALUES ($1, $2) + ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`, + [userId, companyId], + ) +} + +async function insertAdjustment(params: { + companyId: string + userId: string + fiscalPeriodId: string +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.fiscal_period_tax_adjustments + (id, company_id, user_id, fiscal_period_id, adjustment_type, source, + source_key, description, account_number, amount, included) + VALUES ($1, $2, $3, $4, 'non_deductible_expense', 'detected', + 'account:6992', 'Ej avdragsgill kostnad', '6992', 2994, true)`, + [id, params.companyId, params.userId, params.fiscalPeriodId], + ) + return id +} + +describe('fiscal_period_tax_adjustments constraints and RLS', () => { + it('uses the standard UUID default and locks the fiscal period in the guard', async () => { + const result = await getPool().query<{ default_expression: string; function_definition: string }>( + `SELECT + pg_get_expr(d.adbin, d.adrelid) AS default_expression, + pg_get_functiondef('public.guard_fiscal_period_tax_adjustment()'::regprocedure) + AS function_definition + FROM pg_attrdef d + JOIN pg_attribute a + ON a.attrelid = d.adrelid AND a.attnum = d.adnum + WHERE d.adrelid = 'public.fiscal_period_tax_adjustments'::regclass + AND a.attname = 'id'`, + ) + + expect(result.rows[0]?.default_expression).toContain('uuid_generate_v4') + expect(result.rows[0]?.function_definition).toMatch(/FOR UPDATE/i) + }) + + it('isolates adjustments by company membership', async () => { + const owner = await seedCompany() + const adjustmentId = await insertAdjustment(owner) + const strangerId = await insertAuthUser() + + const ownerView = await withUserContext(owner.userId, (client) => + client.query( + 'SELECT id FROM public.fiscal_period_tax_adjustments WHERE id = $1', + [adjustmentId], + ), + ) + expect(ownerView.rows).toHaveLength(1) + + const strangerView = await withUserContext(strangerId, (client) => + client.query( + 'SELECT id FROM public.fiscal_period_tax_adjustments WHERE id = $1', + [adjustmentId], + ), + ) + expect(strangerView.rows).toHaveLength(0) + }) + + it('rejects a company that does not own the fiscal period', async () => { + const first = await seedCompany() + const second = await seedCompany() + + await expect( + insertAdjustment({ + companyId: first.companyId, + userId: first.userId, + fiscalPeriodId: second.fiscalPeriodId, + }), + ).rejects.toThrow(/does not match fiscal period company/i) + }) + + it('allows an owner to insert an adjustment for the active company', async () => { + const owner = await seedCompany() + await setActiveCompany(owner.userId, owner.companyId) + + await withUserContext(owner.userId, async (client) => { + const result = await client.query( + `INSERT INTO public.fiscal_period_tax_adjustments + (company_id, user_id, fiscal_period_id, adjustment_type, source, + source_key, description, account_number, amount, included) + VALUES ($1, $2, $3, 'non_deductible_expense', 'detected', + 'account:6992', 'Ej avdragsgill kostnad', '6992', 2994, true) + RETURNING id`, + [owner.companyId, owner.userId, owner.fiscalPeriodId], + ) + expect(result.rows).toHaveLength(1) + }) + }) + + it('blocks a viewer from inserting an adjustment directly under RLS', async () => { + const owner = await seedCompany() + const viewerId = await insertAuthUser() + await insertCompanyMember({ companyId: owner.companyId, userId: viewerId, role: 'viewer' }) + await setActiveCompany(viewerId, owner.companyId) + + await withUserContext(viewerId, async (client) => { + await expect( + client.query( + `INSERT INTO public.fiscal_period_tax_adjustments + (company_id, user_id, fiscal_period_id, adjustment_type, source, + source_key, description, account_number, amount, included) + VALUES ($1, $2, $3, 'non_deductible_expense', 'detected', + 'account:6992', 'Ej avdragsgill kostnad', '6992', 2994, true)`, + [owner.companyId, viewerId, owner.fiscalPeriodId], + ), + ).rejects.toThrow() + }) + }) + + it('blocks writes to a member company that is not the active company', async () => { + const owner = await seedCompany() + const otherCompanyId = await insertCompany({ createdBy: owner.userId }) + await insertCompanyMember({ companyId: otherCompanyId, userId: owner.userId, role: 'owner' }) + const otherPeriodId = await insertFiscalPeriod({ + userId: owner.userId, + companyId: otherCompanyId, + }) + await setActiveCompany(owner.userId, owner.companyId) + + await withUserContext(owner.userId, async (client) => { + await expect( + client.query( + `INSERT INTO public.fiscal_period_tax_adjustments + (company_id, user_id, fiscal_period_id, adjustment_type, source, + source_key, description, account_number, amount, included) + VALUES ($1, $2, $3, 'non_deductible_expense', 'detected', + 'account:6992', 'Ej avdragsgill kostnad', '6992', 2994, true)`, + [otherCompanyId, owner.userId, otherPeriodId], + ), + ).rejects.toThrow() + }) + }) + + it('blocks adjustment changes after the fiscal period is locked', async () => { + const owner = await seedCompany() + const adjustmentId = await insertAdjustment(owner) + await getPool().query( + 'UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1', + [owner.fiscalPeriodId], + ) + + await expect( + getPool().query( + 'UPDATE public.fiscal_period_tax_adjustments SET amount = 3000 WHERE id = $1', + [adjustmentId], + ), + ).rejects.toThrow(/locked for tax adjustments/i) + await expect( + getPool().query( + 'DELETE FROM public.fiscal_period_tax_adjustments WHERE id = $1', + [adjustmentId], + ), + ).rejects.toThrow(/locked for tax adjustments/i) + }) +}) diff --git a/tests/pg/production-error-regressions.pg.test.ts b/tests/pg/production-error-regressions.pg.test.ts new file mode 100644 index 00000000..a9464682 --- /dev/null +++ b/tests/pg/production-error-regressions.pg.test.ts @@ -0,0 +1,165 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getPool } from './setup' +import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures' + +async function insertEntry(params: { + userId: string + companyId: string + fiscalPeriodId: string + voucherNumber: number + entryDate: string + lines: Array<{ account: string; debit: number; credit: number }> +}): Promise { + const entryId = randomUUID() + await getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES ($1, $2, $3, $4, $5, 'A', $6, 'Production regression test', 'manual', 'posted')`, + [ + entryId, + params.userId, + params.companyId, + params.fiscalPeriodId, + params.voucherNumber, + params.entryDate, + ], + ) + + for (const line of params.lines) { + await getPool().query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, $2, $3, $4)`, + [entryId, line.account, line.debit, line.credit], + ) + } + return entryId +} + +async function seedCompany() { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId }) + const fiscalPeriodId = await insertFiscalPeriod({ + userId, + companyId, + periodStart: '2026-01-01', + periodEnd: '2026-12-31', + }) + return { userId, companyId, fiscalPeriodId } +} + +describe('production error regressions', () => { + it('registers PendingOperationApproved in the processing history catalog', async () => { + const { rows } = await getPool().query( + `SELECT event_type + FROM public.processing_event_types + WHERE event_type = 'PendingOperationApproved'`, + ) + + expect(rows).toEqual([{ event_type: 'PendingOperationApproved' }]) + }) + + it('aggregates period activity and excludes a specified opening entry', async () => { + const ctx = await seedCompany() + const openingId = await insertEntry({ + ...ctx, + voucherNumber: 1, + entryDate: '2026-01-01', + lines: [ + { account: '1930', debit: 1_000, credit: 0 }, + { account: '2010', debit: 0, credit: 1_000 }, + ], + }) + await insertEntry({ + ...ctx, + voucherNumber: 2, + entryDate: '2026-03-15', + lines: [ + { account: '1930', debit: 250, credit: 0 }, + { account: '3001', debit: 0, credit: 250 }, + ], + }) + + const { rows } = await getPool().query( + `SELECT account_number, debit::text, credit::text + FROM public.get_account_period_activity($1, $2, $3, $4, $5)`, + [ctx.companyId, '2026-01-01', '2026-12-31', ['1930', '3001'], openingId], + ) + + expect(rows).toEqual([ + { account_number: '1930', debit: '250', credit: '0' }, + { account_number: '3001', debit: '0', credit: '250' }, + ]) + }) + + it('pages VAT source lines with a stable entry and line cursor', async () => { + const ctx = await seedCompany() + await insertEntry({ + ...ctx, + voucherNumber: 1, + entryDate: '2026-03-01', + lines: [ + { account: '1930', debit: 125, credit: 0 }, + { account: '2611', debit: 0, credit: 25 }, + { account: '3001', debit: 0, credit: 100 }, + ], + }) + await insertEntry({ + ...ctx, + voucherNumber: 2, + entryDate: '2026-03-02', + lines: [ + { account: '1930', debit: 250, credit: 0 }, + { account: '2611', debit: 0, credit: 50 }, + { account: '3001', debit: 0, credit: 200 }, + ], + }) + + const first = await getPool().query( + `SELECT * FROM public.get_vat_ruta_source_lines( + $1, $2, $3, $4, NULL, NULL, NULL, NULL, 1 + )`, + [ctx.companyId, '2026-03-01', '2026-03-31', ['2611']], + ) + expect(first.rows).toHaveLength(1) + expect(first.rows[0].voucher_number).toBe(1) + + const second = await getPool().query( + `SELECT * FROM public.get_vat_ruta_source_lines( + $1, $2, $3, $4, $5, $6, $7, $8, 1 + )`, + [ + ctx.companyId, + '2026-03-01', + '2026-03-31', + ['2611'], + first.rows[0].entry_date, + first.rows[0].voucher_number, + first.rows[0].journal_entry_id, + first.rows[0].line_id, + ], + ) + expect(second.rows).toHaveLength(1) + expect(second.rows[0].voucher_number).toBe(2) + }) + + it('installs the covering indexes used by the timeout fixes', async () => { + const { rows } = await getPool().query( + `SELECT indexname + FROM pg_indexes + WHERE schemaname = 'public' + AND indexname IN ( + 'idx_audit_log_company_created_id', + 'idx_journal_entries_company_posted_date_id' + ) + ORDER BY indexname`, + ) + + expect(rows.map((row) => row.indexname)).toEqual([ + 'idx_audit_log_company_created_id', + 'idx_journal_entries_company_posted_date_id', + ]) + }) +}) diff --git a/tests/pg/tax-assessment-notices.pg.test.ts b/tests/pg/tax-assessment-notices.pg.test.ts new file mode 100644 index 00000000..c9fc810c --- /dev/null +++ b/tests/pg/tax-assessment-notices.pg.test.ts @@ -0,0 +1,111 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getPool, withUserContext } from './setup' +import { insertAuthUser, seedCompany } from './fixtures' + +async function insertNotice(params: { + companyId: string + userId: string + fiscalPeriodId: string + decisionDate?: string + paymentDueDate?: string +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.tax_assessment_notices + (id, company_id, user_id, fiscal_period_id, decision_type, decision_date, payment_due_date) + VALUES ($1, $2, $3, $4, 'final', $5, $6)`, + [ + id, + params.companyId, + params.userId, + params.fiscalPeriodId, + params.decisionDate ?? '2026-07-01', + params.paymentDueDate ?? '2026-10-12', + ], + ) + return id +} + +describe('tax_assessment_notices constraints and RLS', () => { + it('isolates notices by company membership', async () => { + const owner = await seedCompany() + const noticeId = await insertNotice(owner) + const strangerId = await insertAuthUser() + + const ownerView = await withUserContext(owner.userId, (client) => + client.query<{ id: string }>( + `SELECT id FROM public.tax_assessment_notices WHERE id = $1`, + [noticeId], + ), + ) + expect(ownerView.rows).toHaveLength(1) + + const strangerView = await withUserContext(strangerId, (client) => + client.query<{ id: string }>( + `SELECT id FROM public.tax_assessment_notices WHERE id = $1`, + [noticeId], + ), + ) + expect(strangerView.rows).toHaveLength(0) + }) + + it('rejects inserts into another company', async () => { + const owner = await seedCompany() + const strangerId = await insertAuthUser() + + await expect(withUserContext(strangerId, (client) => + client.query( + `INSERT INTO public.tax_assessment_notices + (company_id, user_id, fiscal_period_id, decision_type, decision_date, payment_due_date) + VALUES ($1, $2, $3, 'final', '2026-07-01', '2026-10-12')`, + [owner.companyId, strangerId, owner.fiscalPeriodId], + ), + )).rejects.toThrow(/row-level security|policy/i) + }) + + it('requires the exact due date to be on or after the decision date', async () => { + const owner = await seedCompany() + await expect(insertNotice({ + ...owner, + decisionDate: '2026-07-01', + paymentDueDate: '2026-06-30', + })).rejects.toThrow(/due_after_decision|check constraint/i) + }) + + it('prevents deleting a notice referenced by a generated deadline', async () => { + const owner = await seedCompany() + const noticeId = await insertNotice(owner) + await getPool().query( + `INSERT INTO public.deadlines + (user_id, company_id, title, due_date, deadline_type, source, + tax_deadline_type, tax_period, tax_assessment_notice_id) + VALUES ($1, $2, 'Kvarskatt', '2026-10-12', 'tax', 'system', + 'kvarskatt', $3, $4)`, + [owner.userId, owner.companyId, `notice:${noticeId}`, noticeId], + ) + + await expect( + getPool().query(`DELETE FROM public.tax_assessment_notices WHERE id = $1`, [noticeId]), + ).rejects.toThrow(/foreign key|violates/i) + }) +}) + +describe('company_settings initial setup state', () => { + it('accepts supported paths and rejects unknown values', async () => { + const owner = await seedCompany() + await getPool().query( + `INSERT INTO public.company_settings + (user_id, company_id, onboarding_complete, initial_setup_path) + VALUES ($1, $2, true, 'migration')`, + [owner.userId, owner.companyId], + ) + + await expect( + getPool().query( + `UPDATE public.company_settings SET initial_setup_path = 'unknown' WHERE company_id = $1`, + [owner.companyId], + ), + ).rejects.toThrow(/initial_setup_path_check|check constraint/i) + }) +}) diff --git a/types/index.ts b/types/index.ts index 72dd6f9e..1ffeb7de 100644 --- a/types/index.ts +++ b/types/index.ts @@ -197,6 +197,14 @@ export interface InvoiceEmailTexts { en?: InvoiceEmailTextOverrides } +export type InvoiceFontFamily = + | 'Helvetica' + | 'Times-Roman' + | 'Courier' + | 'Source Sans 3' + | 'Source Serif 4' + | 'Custom' + // Company Settings export interface CompanySettings { id: string @@ -336,7 +344,9 @@ export interface CompanySettings { // render identically to the pre-branding template. invoice_primary_color: string // hex #RRGGBB, default '#1a1a1a' invoice_accent_color: string // hex #RRGGBB, default '#666666' - invoice_font_family: 'Helvetica' | 'Times-Roman' | 'Courier' + invoice_font_family: InvoiceFontFamily + invoice_custom_font_path: string | null + invoice_custom_font_name: string | null invoice_header_text: string | null invoice_footer_text: string | null @@ -360,6 +370,9 @@ export interface CompanySettings { // Onboarding onboarding_step: number onboarding_complete: boolean + initial_setup_path?: InitialSetupPath | null + initial_setup_completed_at?: string | null + initial_setup_dismissed_at?: string | null // Sector sector_slug: string | null @@ -1981,6 +1994,8 @@ export interface CreateFiscalPeriodInput { export type PendingOperationType = | 'categorize_transaction' | 'create_customer' + | 'update_customer' + | 'update_company_settings' | 'create_article' | 'update_article' // Kontoplan reference data (gnubok_create_account / gnubok_update_account) @@ -2136,6 +2151,14 @@ export interface OnboardingProgress { hasSkatteverketConnected: boolean } +export type InitialSetupPath = 'migration' | 'bank' | 'fresh' + +export interface InitialSetupState { + path: InitialSetupPath | null + completedAt: string | null + dismissedAt: string | null +} + // Onboarding step data export interface OnboardingStepData { step1?: { @@ -2209,6 +2232,23 @@ export type TaxDeadlineType = | 'intrastat_monthly' | 'punktskatt_monthly' | 'fyllnadsinbetalning' + | 'kvarskatt' + +export type TaxAssessmentDecisionType = 'final' | 'reassessment' + +export interface TaxAssessmentNotice { + id: string + company_id: string + user_id: string | null + fiscal_period_id: string + decision_type: TaxAssessmentDecisionType + decision_date: string + payment_due_date: string + archived_at: string | null + created_at: string + updated_at: string + fiscal_period?: Pick +} // Deadline status workflow export type DeadlineStatus = @@ -2256,6 +2296,7 @@ export interface Deadline { dismissed_at: string | null linked_report_type: string | null linked_report_period: Record | null + tax_assessment_notice_id: string | null // Relations customer?: Customer @@ -2394,7 +2435,8 @@ export const TAX_DEADLINE_TYPE_LABELS: Record = { ioss_monthly: 'IOSS-deklaration', intrastat_monthly: 'Intrastat', punktskatt_monthly: 'Punktskattedeklaration', - fyllnadsinbetalning: 'Fyllnadsinbetalning' + fyllnadsinbetalning: 'Fyllnadsinbetalning', + kvarskatt: 'Kvarskatt' } // ============================================================