diff --git a/.claude/rules/database.md b/.claude/rules/database.md index a49c263b..3ec7d8c0 100644 --- a/.claude/rules/database.md +++ b/.claude/rules/database.md @@ -26,7 +26,7 @@ Use the `/supabase-migration` skill for new migrations. ## Key Tables (~60) - **Multi-tenant**: `companies`, `company_members`, `company_invitations`, `teams`, `team_members`, `team_invitations`, `user_preferences`, `profiles` -- **Bookkeeping**: `chart_of_accounts`, `fiscal_periods`, `journal_entries`, `journal_entry_lines`, `account_balances`, `voucher_sequences`, `voucher_gap_explanations` +- **Bookkeeping**: `chart_of_accounts`, `fiscal_periods`, `journal_entries`, `journal_entry_lines`, `voucher_sequences`, `voucher_gap_explanations` - **Invoicing**: `customers`, `invoices`, `invoice_items`, `invoice_payments`, `invoice_inbox_items` - **Suppliers**: `suppliers`, `supplier_invoices`, `supplier_invoice_items` - **Banking**: `bank_connections`, `transactions`, `bank_file_imports`, `payment_match_log` diff --git a/.dockerignore b/.dockerignore index d2f06d2a..04557e18 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,7 @@ tests/ .vscode/ *.md !README.md -!DOCKER.md +docs/*.md +!docs/DOCKER.md LICENSE docker-compose*.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ef5c6d50..bb57a82f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -95,7 +95,7 @@ jobs: # between publish and the blocking scan is the scan's own duration # (minutes), not a 24h cron window) plus a daily cron as a safety net. # Accepted residual risk: an image is live for that short scan window - # before the gate fires; see SELF-HOSTING.md / the risk register. + # before the gate fires; see docs/SELF-HOSTING.md / the risk register. continue-on-error: true uses: aquasecurity/trivy-action@v0.36.0 with: diff --git a/README.md b/README.md index 109c6c98..13348473 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ cd Accounted docker compose up -d ``` -You need a Supabase project and must apply the database migrations before first use. See [SELF-HOSTING.md](SELF-HOSTING.md) for the full step-by-step guide, including Supabase setup, auth configuration, optional features (AI, email, push notifications), and troubleshooting. +You need a Supabase project and must apply the database migrations before first use. See [SELF-HOSTING.md](docs/SELF-HOSTING.md) for the full step-by-step guide, including Supabase setup, auth configuration, optional features (AI, email, push notifications), and troubleshooting. ## Development Setup @@ -52,7 +52,7 @@ npm run lint # ESLint ## Documentation -- [SELF-HOSTING.md](SELF-HOSTING.md) -- Full self-hosting guide (Docker, Supabase setup, migrations, optional features) +- [SELF-HOSTING.md](docs/SELF-HOSTING.md) -- Full self-hosting guide (Docker, Supabase setup, migrations, optional features) - [CLAUDE.md](CLAUDE.md) -- Architecture, bookkeeping engine, database conventions, extension system - [CONTRIBUTING.md](CONTRIBUTING.md) -- Development workflow, code style, pull request process - [SECURITY.md](SECURITY.md) -- Vulnerability reporting policy diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index e3a0e5c6..c67bbf0c 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -412,13 +412,12 @@ function LoginPageContent() { {tAuth('bankid_no_account_body')}

- +

) : ( diff --git a/app/(dashboard)/chat/[id]/page.tsx b/app/(dashboard)/chat/[id]/page.tsx index 1fa576c6..d65b8232 100644 --- a/app/(dashboard)/chat/[id]/page.tsx +++ b/app/(dashboard)/chat/[id]/page.tsx @@ -23,21 +23,25 @@ export default async function ChatConversationPage({ params }: PageProps) { const companyId = await getActiveCompanyId(supabase, user.id) if (!companyId) redirect('/onboarding') - const { data: conversation } = await supabase - .from('agent_conversations') - .select('id, intent_id, context_ref, title, pinned, archived, last_message_at') - .eq('id', id) - .eq('company_id', companyId) - .maybeSingle() + // Both queries key on the route id, so they run in parallel. The tenant + // check on the conversation row still gates rendering — when it fails, + // notFound() throws and the messages result is discarded unrendered. + const [{ data: conversation }, { data: messages }] = await Promise.all([ + supabase + .from('agent_conversations') + .select('id, intent_id, context_ref, title, pinned, archived, last_message_at') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle(), + supabase + .from('agent_messages') + .select('role, content, hidden, created_at') + .eq('conversation_id', id) + .order('created_at', { ascending: true }), + ]) if (!conversation) notFound() - const { data: messages } = await supabase - .from('agent_messages') - .select('role, content, hidden, created_at') - .eq('conversation_id', id) - .order('created_at', { ascending: true }) - return ( pathname.startsWith(p) ) - // Fetch team membership + team info - const { data: teamMembership } = await supabase - .from('team_members') - .select('team_id, role') - .eq('user_id', user.id) - .limit(1) - .maybeSingle() - - let team: Team | null = null - if (teamMembership?.team_id) { - const { data: teamRow } = await supabase - .from('teams') - .select('*') - .eq('id', teamMembership.team_id) - .single() - team = teamRow - } - + const team: Team | null = + (teamMembership?.teams as unknown as Team | null) ?? null const isTeamMember = !!teamMembership // No companies: redirect to onboarding, except for allowed escape-hatch @@ -125,15 +120,47 @@ export default async function DashboardLayout({ ) } - // Fetch company + membership for context provider + // Fetch company + membership for context provider, together with the + // nav/badge data, none of these depend on each other, only on + // companyId/user.id, so one round-trip batch instead of two. The rare + // stale-cookie early return below wastes the extra reads; that's cheaper + // than serializing two batches on every dashboard render. const [ { data: companyRow }, { data: memberRow }, { data: allMemberships }, + { data: settings }, + uncategorizedCount, + pendingOpsCount, + { data: agentProfileIdentity }, + { data: userProfile }, + capabilities, ] = await Promise.all([ supabase.from('companies').select('*').eq('id', companyId).single(), supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(), supabase.from('company_members').select('company_id, role, companies:company_id(id, name, org_number, entity_type, accounting_framework, created_by, team_id, archived_at, created_at, updated_at)').eq('user_id', user.id), + supabase + .from('company_settings') + .select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled') + .eq('company_id', companyId) + .single(), + // Shared worklist predicates (lib/worklist), the badge must show the + // same number as every other "att göra" surface. Notably this excludes + // is_ignored rows, which the old inline query here did not. + countUnbookedTransactions(supabase, companyId), + countPendingOperations(supabase, companyId), + // Agent identity, name + avatar, surfaced on the FAB and chat + // surfaces. Null when no agent_profile exists yet (banner CTA path). + supabase + .from('agent_profiles') + .select('display_name, avatar_id, verified_at') + .eq('company_id', companyId) + .maybeSingle(), + // The signed-in user's profile, shown in the bottom-left account + // popover (full_name + initial) so it's clear which user is logged + // in, distinct from the active company shown at the top. + supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(), + getCompanyCapabilities(supabase, companyId), ]) if (!companyRow || !memberRow) { @@ -178,38 +205,6 @@ export default async function DashboardLayout({ ) } - const [ - { data: settings }, - uncategorizedCount, - pendingOpsCount, - { data: agentProfileIdentity }, - { data: userProfile }, - capabilities, - ] = await Promise.all([ - supabase - .from('company_settings') - .select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled') - .eq('company_id', companyId) - .single(), - // Shared worklist predicates (lib/worklist): the badge must show the - // same number as every other "att göra" surface. Notably this excludes - // is_ignored rows, which the old inline query here did not. - countUnbookedTransactions(supabase, companyId), - countPendingOperations(supabase, companyId), - // Agent identity (name + avatar) surfaced on the FAB and chat - // surfaces. Null when no agent_profile exists yet (banner CTA path). - supabase - .from('agent_profiles') - .select('display_name, avatar_id, verified_at') - .eq('company_id', companyId) - .maybeSingle(), - // The signed-in user's profile: shown in the bottom-left account - // popover (full_name + initial) so it's clear which user is logged - // in, distinct from the active company shown at the top. - supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(), - getCompanyCapabilities(supabase, companyId), - ]) - // If onboarding incomplete, still render the dashboard: the page component // will show the inline onboarding card instead of the normal dashboard content. diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index 2708239f..f5d85b7c 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -223,8 +223,33 @@ function formatRelativeTime(dateStr: string): string { } function CategorizePreview({ data }: { data: Record }) { + // The exact journal lines the approval will post (net cost line, VAT line, + // gross bank line, SEK) — staged by the server since the preview-lines fix. + const lines = (data.lines as Array<{ account_number?: string; debit_amount?: number; credit_amount?: number; description?: string }>) || [] const vatLines = (data.vat_lines as Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }>) || [] + if (lines.length > 0) { + return ( +
+

Verifikat

+ {lines.map((line, i) => { + const debitAmt = typeof line.debit_amount === 'number' ? line.debit_amount : 0 + const creditAmt = typeof line.credit_amount === 'number' ? line.credit_amount : 0 + return ( +
+ {line.account_number ?? '?'}{line.description ? ` ${line.description}` : ''} + + {debitAmt > 0 ? `D ${formatCurrency(debitAmt)}` : `K ${formatCurrency(creditAmt)}`} + +
+ ) + })} +
+ ) + } + + // Legacy summary for operations staged before the preview carried full + // lines: debit/credit accounts + gross amount + separate VAT rows. return (
diff --git a/app/(dashboard)/salary/employees/[id]/page.tsx b/app/(dashboard)/salary/employees/[id]/page.tsx index b1357670..862544ee 100644 --- a/app/(dashboard)/salary/employees/[id]/page.tsx +++ b/app/(dashboard)/salary/employees/[id]/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, use } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' +import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Skeleton } from "@/components/ui/skeleton" import { Button } from '@/components/ui/button' @@ -18,10 +19,10 @@ import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard' import LineDimensionFields from '@/components/dimensions/LineDimensionFields' -const EMPLOYMENT_LABELS: Record = { - employee: 'Anställd', - company_owner: 'Företagsledare', - board_member: 'Styrelseledamot', +const EMPLOYMENT_LABEL_KEYS: Record = { + employee: 'form_employment_type_employee', + company_owner: 'form_employment_type_company_owner', + board_member: 'form_employment_type_board_member', } function RequiredMark() { @@ -30,6 +31,7 @@ function RequiredMark() { export default function EmployeeDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) + const t = useTranslations('salary_employee') const router = useRouter() const { toast } = useToast() const { canWrite } = useCanWrite() @@ -126,11 +128,11 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s if (res.ok) { const { data } = await res.json() setEmployee(data) - toast({ title: 'Anställd uppdaterad' }) + toast({ title: t('detail_updated') }) } else { const result = await res.json() toast({ - title: 'Kunde inte uppdatera anställd', + title: t('detail_update_failed'), description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), variant: 'destructive', }) @@ -140,11 +142,11 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s } async function handleDeactivate() { - if (!confirm('Vill du inaktivera denna anställd?')) return + if (!confirm(t('detail_deactivate_confirm'))) return const res = await fetch(`/api/salary/employees/${id}`, { method: 'DELETE' }) if (res.ok) { - toast({ title: 'Anställd inaktiverad' }) + toast({ title: t('detail_deactivated') }) router.push('/salary/employees') } } @@ -159,7 +161,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s } if (!employee) { - return

Anställd hittades inte

+ return

{t('detail_not_found')}

} return ( @@ -167,149 +169,160 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s

{employee.first_name} {employee.last_name}

- {employee.personnummer} · {EMPLOYMENT_LABELS[employee.employment_type]} + {employee.personnummer} · {t(EMPLOYMENT_LABEL_KEYS[employee.employment_type])}

{canWrite && ( )}
-
- {/* Personal info */} + + {/* Person & kontakt - name, contact, and address in one dense card */} - - Personuppgifter + + {t('form_personal_info')}
- +
- +
-
-
- + -

Krävs för att skicka lönebesked

+

{t('form_email_hint')}

- +
-
-
-
- - {/* Address */} - - - Adress - - -
- - -
-
+
+ + +
- +
- +
- {/* Employment */} + {/* Anställning & lön - employment terms, salary, and vacation together */} - - Anställning + + {t('form_employment_salary')} -
+
- +
- +
-
-
- - -

Lönen proportioneras automatiskt om anställningen börjar eller slutar mitt i en löneperiod.

-
-
- - -

Lämna tomt för pågående anställning.

-
-
- - - - {/* Salary */} - - - Lön - - -
-
- +
+
+ + +

{t('detail_employment_start_hint')}

+
+
+ + +

{t('detail_employment_end_hint')}

+
{salaryType === 'monthly' ? (
- +
) : (
- +
)} +
+ + + {vacationRule === 'none' && ( +

+ {t('detail_vacation_none_hint')} +

+ )} + {vacationRule === 'semesterersattning' && ( +

+ {t('form_vacation_semesterersattning_hint')} +

+ )} +
+
+ + +

{t('form_vacation_days_hint')}

+
@@ -317,13 +330,13 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s {/* Default dimensions (kostnadsställe/projekt) */} {dimensionsEnabled && ( - - Kostnadsställe / Projekt (standard) + + {t('form_dimensions_title')}

- Föreslås på lönekostnadsrader vid bokföring av lönekörningar. + {t('form_dimensions_hint')}

@@ -344,75 +357,27 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s /> {employee.f_skatt_verified_at && (

- F-skatt verifierad: {new Date(employee.f_skatt_verified_at).toLocaleDateString('sv-SE')} + {t('detail_f_skatt_verified', { date: new Date(employee.f_skatt_verified_at).toLocaleDateString('sv-SE') })}

)} - {/* Vacation */} - - - Semester - - -
-
- - - {vacationRule === 'none' && ( -

- Ingen avsättning till 2920 bokas. Använd om semester ingår i månadslönen, vanligt för ägare som är enda anställd. -

- )} - {vacationRule === 'semesterersattning' && ( -

- 12 % läggs på varje lönekörning och bokas mot 7285. Ingen semesterlöneskuld byggs upp, vanligt för tim- och visstidsanställda. -

- )} -
-
- - -

Lagstadgat minimum: 25 dagar

-
-
-
-
- {/* Bank */} - - Bankkonto + + {t('form_bank_account')}
- +
- +
-

Krävs innan lönekörning kan godkännas

+

{t('form_bank_hint')}

@@ -422,11 +387,11 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s {canWrite && (
)} diff --git a/app/(dashboard)/salary/page.tsx b/app/(dashboard)/salary/page.tsx index f3786c3b..eead113f 100644 --- a/app/(dashboard)/salary/page.tsx +++ b/app/(dashboard)/salary/page.tsx @@ -1,21 +1,26 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' +import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' +import { createClient } from '@/lib/supabase/client' import { Badge } from '@/components/ui/badge' -import { Skeleton } from "@/components/ui/skeleton" +import { Skeleton } from '@/components/ui/skeleton' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { EmptyState } from '@/components/ui/empty-state' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { Plus, Users, HandCoins, CalendarDays, ArrowRight } from 'lucide-react' +import { ArrowRight, CalendarClock, HandCoins, Loader2, Plus, UserX, Users } from 'lucide-react' import { PageHeader } from '@/components/ui/page-header' +import { useToast } from '@/components/ui/use-toast' import { useCanWrite } from '@/lib/hooks/use-can-write' +import { useCompany } from '@/contexts/CompanyContext' +import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency, formatDate } from '@/lib/utils' -import NewSalaryRunDialog from '@/components/salary/NewSalaryRunDialog' -import type { SalaryRun } from '@/types' +import type { Employee, SalaryRun } from '@/types' + +const supabase = createClient() const STATUS_LABEL_KEYS: Record = { draft: 'status_draft', @@ -23,58 +28,125 @@ const STATUS_LABEL_KEYS: Record = { approved: 'status_approved', paid: 'status_paid', booked: 'status_booked', + corrected: 'status_corrected', } -const STATUS_VARIANTS: Record = { +const STATUS_VARIANTS: Record = { draft: 'secondary', review: 'warning', approved: 'default', paid: 'success', booked: 'success', + corrected: 'outline', +} + +interface TaxPaymentState { + tax_payment_file_generated_at: string | null + tax_paid_at: string | null } export default function SalaryPage() { const [runs, setRuns] = useState([]) - const [employeeCount, setEmployeeCount] = useState(0) + const [employees, setEmployees] = useState([]) + const [payDay, setPayDay] = useState(25) + const [agiDeadline, setAgiDeadline] = useState<{ due_date: string; title: string } | null>(null) + const [taxPayment, setTaxPayment] = useState(null) const [loading, setLoading] = useState(true) + const [starting, setStarting] = useState(false) const { canWrite } = useCanWrite() + const { company } = useCompany() + const { toast } = useToast() const router = useRouter() - const searchParams = useSearchParams() const t = useTranslations('salary') - // The "Ny lönekörning" modal is driven by the URL (?new=1) so every entry - // point (the header button, the empty state, and the legacy - // /salary/runs/new redirect) opens the same dialog, and the browser back - // button closes it. Same pattern as /invoices. - const showNewRun = searchParams.has('new') - const closeNewRun = () => router.replace('/salary', { scroll: false }) - const openNewRun = () => router.push('/salary?new=1', { scroll: false }) + const load = useCallback(async () => { + const [runsRes, empRes, settingsRes] = await Promise.all([ + fetch('/api/salary/runs'), + fetch('/api/salary/employees'), + fetch('/api/settings'), + ]) - useEffect(() => { - async function load() { - const [runsRes, empRes] = await Promise.all([ - fetch('/api/salary/runs'), - fetch('/api/salary/employees'), - ]) - - if (runsRes.ok) { - const { data } = await runsRes.json() - setRuns(data || []) - } - if (empRes.ok) { - const { data } = await empRes.json() - setEmployeeCount((data || []).length) - } - setLoading(false) + let loadedRuns: SalaryRun[] = [] + if (runsRes.ok) { + const { data } = await runsRes.json() + loadedRuns = data || [] + setRuns(loadedRuns) } - load() + if (empRes.ok) { + const { data } = await empRes.json() + setEmployees(data || []) + } + if (settingsRes.ok) { + const { data } = await settingsRes.json() + if (typeof data?.salary_pay_day === 'number') setPayDay(data.salary_pay_day) + } + + // Latest booked run drives the "skatt att betala" card. + const latestBooked = loadedRuns.find(r => r.status === 'booked') + if (latestBooked) { + const period = `${latestBooked.period_year}-${String(latestBooked.period_month).padStart(2, '0')}` + const txRes = await fetch(`/api/skatteverket/tax-payments/${period}`) + if (txRes.ok) { + const tx = await txRes.json() + setTaxPayment(tx.data) + } + } + + setLoading(false) }, []) - const currentYear = new Date().getFullYear() - const yearRuns = runs.filter(r => r.period_year === currentYear) - const totalGrossYTD = yearRuns.filter(r => r.status === 'booked').reduce((sum, r) => sum + r.total_gross, 0) - const totalAvgifterYTD = yearRuns.filter(r => r.status === 'booked').reduce((sum, r) => sum + r.total_avgifter, 0) - const latestRun = runs[0] + useEffect(() => { + load() + }, [load]) + + // Next open AGI deadline instance - generated by the tax-deadline engine + // when the company pays salaries; same source as the /deadlines page. + useEffect(() => { + if (!company) return + const today = new Date().toISOString().split('T')[0] + supabase + .from('deadlines') + .select('due_date, title') + .eq('company_id', company.id) + .eq('tax_deadline_type', 'arbetsgivardeklaration') + .eq('is_completed', false) + .gte('due_date', today) + .order('due_date') + .limit(1) + .maybeSingle() + .then(({ data }) => setAgiDeadline(data ?? null)) + }, [company]) + + // One-click run creation: the API seeds all active employees, calculates, + // and resolves period/pay-date/series defaults from settings. + async function startRun() { + setStarting(true) + try { + const res = await fetch('/api/salary/runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }) + const json = await res.json().catch(() => null) + if (res.status === 201 && json?.data?.id) { + router.push(`/salary/runs/${json.data.id}`) + return + } + const existingId = json?.error?.details?.existingId + if (res.status === 409 && existingId) { + toast({ title: t('run_exists_opening') }) + router.push(`/salary/runs/${existingId}`) + return + } + toast({ + title: t('start_run_failed'), + description: getErrorMessage(json, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } finally { + setStarting(false) + } + } if (loading) { return ( @@ -83,6 +155,7 @@ export default function SalaryPage() {
+
{[1, 2, 3].map(i => ( @@ -92,6 +165,94 @@ export default function SalaryPage() { ) } + // ── Hero state machine (first match wins) ──────────────────────────────── + const activeRun = runs.find(r => r.status !== 'corrected') + const latestBooked = runs.find(r => r.status === 'booked') + const periodOf = (r: SalaryRun) => `${r.period_year}-${String(r.period_month).padStart(2, '0')}` + + // Next period for the quiet state: month after the latest non-corrected run. + const nextPeriod = (() => { + if (!activeRun) { + const now = new Date() + return { year: now.getFullYear(), month: now.getMonth() + 1 } + } + return activeRun.period_month === 12 + ? { year: activeRun.period_year + 1, month: 1 } + : { year: activeRun.period_year, month: activeRun.period_month + 1 } + })() + const nextPayDate = `${nextPeriod.year}-${String(nextPeriod.month).padStart(2, '0')}-${String(payDay).padStart(2, '0')}` + + type Hero = + | { kind: 'onboarding' } + | { kind: 'cta'; title: string; description: string; label: string; runId: string } + | { kind: 'quiet' } + + const hero: Hero = (() => { + if (runs.length === 0 && employees.length === 0) return { kind: 'onboarding' } + if (activeRun && (activeRun.status === 'draft' || activeRun.status === 'review')) { + return { + kind: 'cta', + title: t('hero_review_title', { period: periodOf(activeRun) }), + description: t('hero_review_description', { + count: (activeRun as SalaryRun & { employees?: unknown[] }).employees?.length ?? employees.length, + net: formatCurrency(activeRun.total_net), + date: formatDate(activeRun.payment_date), + }), + label: t('hero_review_action'), + runId: activeRun.id, + } + } + if (activeRun && activeRun.status === 'approved') { + // A run that pays out nothing (nollkörning, or fully net-deducted) has no + // payment file to download - don't send the user to "pay". The real next + // step is to post it and file AGI, so shepherd them into the run instead. + const noPayout = Math.round((activeRun.total_net ?? 0) * 100) === 0 + if (noPayout) { + return { + kind: 'cta', + title: t('hero_finish_title', { period: periodOf(activeRun) }), + description: t('hero_finish_description'), + label: t('hero_finish_action'), + runId: activeRun.id, + } + } + return { + kind: 'cta', + title: t('hero_pay_title', { period: periodOf(activeRun) }), + description: t('hero_pay_description', { + net: formatCurrency(activeRun.total_net), + date: formatDate(activeRun.payment_date), + }), + label: t('hero_pay_action'), + runId: activeRun.id, + } + } + if (activeRun && activeRun.status === 'paid') { + return { + kind: 'cta', + title: t('hero_book_title', { period: periodOf(activeRun) }), + description: t('hero_book_description'), + label: t('hero_book_action'), + runId: activeRun.id, + } + } + if (activeRun && activeRun.status === 'booked' && !activeRun.agi_submitted_at) { + return { + kind: 'cta', + title: t('hero_agi_title', { period: periodOf(activeRun) }), + description: t('hero_agi_description'), + label: t('hero_agi_action'), + runId: activeRun.id, + } + } + return { kind: 'quiet' } + })() + + // ── Blockers: active employees missing what a run needs ────────────────── + const missingBank = employees.filter(e => !e.clearing_number || !e.bank_account_number).length + const missingEmail = employees.filter(e => !e.email).length + const blockerCount = missingBank + missingEmail + return (
{canWrite && ( - )}
} /> - {/* Summary cards */} + {/* Hero - the one thing to do now */} + {hero.kind === 'onboarding' ? ( + + + + + + ) : hero.kind === 'cta' ? ( + + +
+

{hero.title}

+

{hero.description}

+
+ +
+
+ ) : ( + + +
+

+ {t('quiet_title', { + period: `${nextPeriod.year}-${String(nextPeriod.month).padStart(2, '0')}`, + })} +

+

+ {t('quiet_description', { date: formatDate(nextPayDate) })} +

+
+ {canWrite && ( + + )} +
+
+ )} + + {/* Attention cards */}
- -
- -
-

{t('employees')}

-

{employeeCount}

-
+ +
+ +

{t('card_agi_title')}

+ {agiDeadline ? ( + <> +

+ {formatDate(agiDeadline.due_date)} +

+ + {agiDeadline.title} + + + ) : ( +

{t('card_agi_none')}

+ )}
+ - -
- -
-

{t('gross_year', { year: currentYear })}

-

{formatCurrency(totalGrossYTD)}

-
+ +
+ +

{t('card_tax_title')}

+ {latestBooked ? ( + <> +

+ {formatCurrency(latestBooked.total_tax + latestBooked.total_avgifter)} +

+

+ {taxPayment?.tax_paid_at + ? t('card_tax_paid', { date: formatDate(taxPayment.tax_paid_at) }) + : t('card_tax_unpaid', { period: periodOf(latestBooked) })} +

+ + ) : ( +

{t('card_tax_none')}

+ )}
+ - -
- -
-

{t('contributions_year', { year: currentYear })}

-

{formatCurrency(totalAvgifterYTD)}

-
+ +
+ +

{t('card_blockers_title')}

+ {blockerCount > 0 ? ( + <> +

+ {blockerCount} +

+ + {t('card_blockers_detail', { bank: missingBank, email: missingEmail })} + + + ) : ( +

{t('card_blockers_none')}

+ )}
- {/* Recent runs */} + {/* History */} {t('runs_title')} @@ -162,8 +415,8 @@ export default function SalaryPage() { icon={HandCoins} title={t('empty_runs_title')} description={t('empty_runs_description')} - actionLabel={canWrite ? t('create_run') : undefined} - onAction={canWrite ? openNewRun : undefined} + actionLabel={canWrite ? t('start_run') : undefined} + onAction={canWrite ? startRun : undefined} /> ) : ( @@ -182,7 +435,7 @@ export default function SalaryPage() { {runs.slice(0, 12).map(run => ( - {run.period_year}-{String(run.period_month).padStart(2, '0')} + {periodOf(run)} {formatDate(run.payment_date)} @@ -213,13 +466,6 @@ export default function SalaryPage() { )} - - { - if (!open) closeNewRun() - }} - /> ) } diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx index c5de276a..87801f2f 100644 --- a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx @@ -2,6 +2,7 @@ import { use, useEffect, useMemo, useState } from 'react' import Link from 'next/link' +import { useTranslations } from 'next-intl' import { ArrowLeft, Calculator, Loader2 } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -10,44 +11,45 @@ import { SalaryOverridePanel } from '@/components/salary/SalaryOverridePanel' import { formatCurrency } from '@/lib/utils' import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, Employee } from '@/types' -const LINE_ITEM_TYPE_LABELS: Record = { - monthly_salary: 'Månadslön', - hourly_salary: 'Timlön', - overtime: 'Övertid', - overtime_50: 'Övertid 50 %', - overtime_100: 'Övertid 100 %', - ob_weekday_evening: 'OB vardag kväll', - ob_weekend: 'OB helg', - ob_night: 'OB natt', - ob_holiday: 'OB helgdag', - bonus: 'Bonus', - commission: 'Provision', - gross_deduction_pension: 'Bruttoavdrag: pension', - gross_deduction_other: 'Bruttoavdrag: övrigt', - benefit_car: 'Bilförmån', - benefit_housing: 'Bostadsförmån', - benefit_meals: 'Kostförmån', - benefit_wellness: 'Friskvård', - benefit_bike: 'Cykelförmån', - benefit_other: 'Övrig förmån', - sick_karens: 'Karensavdrag', - sick_day2_14: 'Sjuklön (dag 2-14, 80 %)', - sick_day15_plus: 'Sjuklön (dag 15+, Försäkringskassan)', - vab: 'VAB (vård av sjukt barn)', - parental_leave: 'Föräldraledighet', - unpaid_leave: 'Tjänstledighet utan lön', - vacation: 'Semester', - semesterersattning: 'Semesterersättning', - traktamente_taxfree: 'Traktamente (skattefritt)', - traktamente_taxable: 'Traktamente (skattepliktigt)', - mileage_taxfree: 'Milersättning (skattefritt)', - mileage_taxable: 'Milersättning (skattepliktigt)', - net_deduction_advance: 'Nettoavdrag: förskott', - net_deduction_union: 'Nettoavdrag: fackavgift', - net_deduction_benefit_payment: 'Nettoavdrag: förmånsbetalning', - net_deduction_other: 'Nettoavdrag: övrigt', - correction: 'Korrigering', - other: 'Övrigt', +/** Translation keys in the `salary_run_employee` namespace. */ +const LINE_ITEM_TYPE_KEYS: Record = { + monthly_salary: 'li_monthly_salary', + hourly_salary: 'li_hourly_salary', + overtime: 'li_overtime', + overtime_50: 'li_overtime_50', + overtime_100: 'li_overtime_100', + ob_weekday_evening: 'li_ob_weekday_evening', + ob_weekend: 'li_ob_weekend', + ob_night: 'li_ob_night', + ob_holiday: 'li_ob_holiday', + bonus: 'li_bonus', + commission: 'li_commission', + gross_deduction_pension: 'li_gross_deduction_pension', + gross_deduction_other: 'li_gross_deduction_other', + benefit_car: 'li_benefit_car', + benefit_housing: 'li_benefit_housing', + benefit_meals: 'li_benefit_meals', + benefit_wellness: 'li_benefit_wellness', + benefit_bike: 'li_benefit_bike', + benefit_other: 'li_benefit_other', + sick_karens: 'li_sick_karens', + sick_day2_14: 'li_sick_day2_14', + sick_day15_plus: 'li_sick_day15_plus', + vab: 'li_vab', + parental_leave: 'li_parental_leave', + unpaid_leave: 'li_unpaid_leave', + vacation: 'li_vacation', + semesterersattning: 'li_semesterersattning', + traktamente_taxfree: 'li_traktamente_taxfree', + traktamente_taxable: 'li_traktamente_taxable', + mileage_taxfree: 'li_mileage_taxfree', + mileage_taxable: 'li_mileage_taxable', + net_deduction_advance: 'li_net_deduction_advance', + net_deduction_union: 'li_net_deduction_union', + net_deduction_benefit_payment: 'li_net_deduction_benefit_payment', + net_deduction_other: 'li_net_deduction_other', + correction: 'li_correction', + other: 'li_other', } interface DetailResponse { @@ -60,6 +62,7 @@ export default function SalaryRunEmployeeDetailPage({ }: { params: Promise<{ id: string; employeeId: string }> }) { + const t = useTranslations('salary_run_employee') const { id: runId, employeeId } = use(params) const [data, setData] = useState(null) const [loading, setLoading] = useState(true) @@ -79,11 +82,11 @@ export default function SalaryRunEmployeeDetailPage({ ]) const runJson = await runRes.json() const sreJson = await sreRes.json() - if (!runRes.ok) throw new Error(runJson.error || 'Kunde inte ladda lönekörning') - if (!sreRes.ok) throw new Error(sreJson.error || 'Kunde inte ladda anställd') + if (!runRes.ok) throw new Error(runJson.error || t('error_load_run')) + if (!sreRes.ok) throw new Error(sreJson.error || t('error_load_employee')) setData({ run: runJson.data, runEmployee: sreJson.data }) } catch (e) { - setError(e instanceof Error ? e.message : 'Okänt fel') + setError(e instanceof Error ? e.message : t('unknown_error')) } finally { setLoading(false) } @@ -101,11 +104,11 @@ export default function SalaryRunEmployeeDetailPage({ const res = await fetch(`/api/salary/runs/${runId}/calculate`, { method: 'POST' }) const json = await res.json().catch(() => ({})) if (!res.ok) { - throw new Error(json.error || 'Beräkning misslyckades') + throw new Error(json.error || t('error_calculate')) } await load() } catch (e) { - setError(e instanceof Error ? e.message : 'Okänt fel') + setError(e instanceof Error ? e.message : t('unknown_error')) } finally { setCalculating(false) } @@ -129,7 +132,7 @@ export default function SalaryRunEmployeeDetailPage({ if (loading) { return (
- Laddar... + {t('loading')}
) } @@ -141,10 +144,10 @@ export default function SalaryRunEmployeeDetailPage({ href={`/salary/runs/${runId}`} className="inline-flex items-center text-sm text-muted-foreground hover:underline" > - Tillbaka till lönekörning + {t('back_to_run')}
- {error ?? 'Kunde inte ladda anställd'} + {error ?? t('error_load_employee')}
) @@ -164,7 +167,7 @@ export default function SalaryRunEmployeeDetailPage({ href={`/salary/runs/${runId}`} className="inline-flex items-center text-sm text-muted-foreground hover:underline" > - Tillbaka till lönekörning + {t('back_to_run')}
@@ -172,7 +175,7 @@ export default function SalaryRunEmployeeDetailPage({ {employee.first_name} {employee.last_name}

- {employee.personnummer} · Lönespecifikation {periodLabel} + {employee.personnummer} · {t('payslip_period', { period: periodLabel })}

{run.status === 'draft' && ( @@ -187,7 +190,7 @@ export default function SalaryRunEmployeeDetailPage({ ) : ( )} - Beräkna + {t('calculate')} )}
@@ -195,20 +198,20 @@ export default function SalaryRunEmployeeDetailPage({ {/* Summary */}
- + @@ -234,11 +237,11 @@ export default function SalaryRunEmployeeDetailPage({ {/* Unified calendar: worked time (for hourly) + absence on the same grid */} - Tid och frånvaro + {t('time_absence_title')}

{employee.salary_type === 'hourly' - ? 'Markera dagar och ange arbetade timmar eller frånvaro. Grundlönen räknas som timlön × summa arbetade timmar. Karensavdrag, sjuklön och AGI-rapportering härleds automatiskt.' - : 'Markera sjukdom, VAB, föräldraledighet och annan frånvaro per dag. Karensavdrag, sjuklön och AGI-rapportering räknas ut automatiskt.'} + ? t('calendar_hint_hourly') + : t('calendar_hint_monthly')}

@@ -253,9 +256,9 @@ export default function SalaryRunEmployeeDetailPage({ onAbsenceCountsChange={setLiveCounts} />
- - - + + +
@@ -263,27 +266,27 @@ export default function SalaryRunEmployeeDetailPage({ {/* Line items */} - Lönerader ({lineItems.length}) + {t('line_items_title', { count: lineItems.length })} {lineItems.length === 0 ? (

- Inga lönerader. Kör beräkning på lönekörningen för att skapa standardrader. + {t('no_line_items')}

) : (
- - - - + + + + {lineItems.map(li => ( - + @@ -299,11 +302,12 @@ export default function SalaryRunEmployeeDetailPage({ } function SummaryCard({ label, value, accent, overridden }: { label: string; value: number; accent?: boolean; overridden?: boolean }) { + const t = useTranslations('salary_run_employee') return (
{label} - {overridden && Justerat} + {overridden && {t('adjusted_badge')}}
{formatCurrency(value)}
@@ -311,10 +315,11 @@ function SummaryCard({ label, value, accent, overridden }: { label: string; valu } function AbsenceCount({ label, days }: { label: string; days: number }) { + const t = useTranslations('salary_run_employee') return (
{label}
-
{days} dagar
+
{t('days_count', { days })}
) } diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index e8127d48..580f1700 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -1,73 +1,52 @@ 'use client' -import { useState, useEffect, use } from 'react' +import { use, useEffect, useState } from 'react' import { useRouter } from 'next/navigation' -import Link from 'next/link' -import { Badge } from '@/components/ui/badge' -import { Skeleton } from "@/components/ui/skeleton" -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Card, CardContent } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' import { - ArrowLeft, Calculator, Eye, Check, CreditCard, BookOpen, - ArrowLeftCircle, Loader2, Download, FileDown, Trash2, -} from 'lucide-react' + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { AlertTriangle, Download, Loader2 } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { useCanWrite } from '@/lib/hooks/use-can-write' -import { formatCurrency, formatDate } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' -import type { SalaryRun, SalaryRunEmployee, Employee, CreateJournalEntryLineInput } from '@/types' import { AGIPanel } from '@/components/salary/AGIPanel' import { PaymentFilePanel } from '@/components/salary/PaymentFilePanel' import { TaxPaymentPanel } from '@/components/salary/TaxPaymentPanel' -import { TaxTableStatus } from '@/components/salary/TaxTableStatus' +import { RunHeader } from '@/components/salary/run/RunHeader' +import { RunProgressBar } from '@/components/salary/run/RunProgressBar' +import { RunKpiCards } from '@/components/salary/run/RunKpiCards' +import { RunEmployeesTable } from '@/components/salary/run/RunEmployeesTable' +import { RunCalculationDetails } from '@/components/salary/run/RunCalculationDetails' +import { RunJournalPreview, type PreviewData } from '@/components/salary/run/RunJournalPreview' +import { periodLabelOf, type RunDetail } from '@/components/salary/run/types' +import type { Employee, SalaryRunEmployee } from '@/types' -type SalaryRunWithArbetsgivare = SalaryRun & { arbetsgivare?: string | null } - -const STATUS_LABELS: Record = { - draft: 'Utkast', - review: 'Granskning', - approved: 'Godkänd', - paid: 'Betald', - booked: 'Bokförd', - corrected: 'Korrigerad', -} - -const STATUS_VARIANTS: Record = { - draft: 'secondary', - review: 'warning', - approved: 'default', - paid: 'success', - booked: 'success', - corrected: 'secondary', -} - -interface EntryPreview { - description: string - lines: CreateJournalEntryLineInput[] -} - -interface PreviewData { - salaryEntry: EntryPreview | null - avgifterEntry: EntryPreview | null - vacationEntry: EntryPreview | null -} - -export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: string }> }) { +export default function SalaryRunPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) const router = useRouter() const { toast } = useToast() const { canWrite } = useCanWrite() + const t = useTranslations('salary_run') - const [run, setRun] = useState(null) + const [run, setRun] = useState(null) const [availableEmployees, setAvailableEmployees] = useState([]) const [preview, setPreview] = useState(null) const [loading, setLoading] = useState(true) const [actionLoading, setActionLoading] = useState(null) - const [addEmployeeKey, setAddEmployeeKey] = useState(0) - const [preferredPaymentFormat, setPreferredPaymentFormat] = useState<'bg_lb' | 'pain001'>('bg_lb') + // Non-null while the "Godkänn ändå?" dialog is open: holds the missing + // bank-detail reasons returned by the approve route (overridable block). + const [approveOverride, setApproveOverride] = useState(null) + const [preferredPaymentFormat, setPreferredPaymentFormat] = useState<'bg_lb' | 'pain001'>('pain001') + const [defaultBank, setDefaultBank] = useState(null) // Gates the default-dimensions chips on the employee rows: same // company_settings.dimensions_enabled UI gate as the voucher form. const [dimensionsEnabled, setDimensionsEnabled] = useState(false) @@ -94,23 +73,29 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: useEffect(() => { async function load() { - await loadRun() - const empRes = await fetch('/api/salary/employees') + // Employees and settings don't depend on the run - load all three in + // parallel instead of serially. + const [, empRes, settingsRes] = await Promise.all([ + loadRun(), + fetch('/api/salary/employees'), + fetch('/api/settings'), + ]) if (empRes.ok) { const { data } = await empRes.json() setAvailableEmployees(data || []) } - const settingsRes = await fetch('/api/settings') if (settingsRes.ok) { const { data } = await settingsRes.json() if (data?.preferred_payment_format === 'pain001' || data?.preferred_payment_format === 'bg_lb') { setPreferredPaymentFormat(data.preferred_payment_format) } + setDefaultBank(typeof data?.salary_default_bank === 'string' ? data.salary_default_bank : null) setDimensionsEnabled(data?.dimensions_enabled === true) } setLoading(false) } load() + // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]) // Refetch when the tab regains focus. AGI can be generated out-of-band (via @@ -125,39 +110,132 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: } document.addEventListener('visibilitychange', onVisible) return () => document.removeEventListener('visibilitychange', onVisible) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]) + // Auto-load the journal preview once the run is calculated, so the + // "Bokföring (förhandsgranskning)" box renders beside Beräkningsdetaljer + // without a manual Förhandsgranska click. Re-runs when the calculated totals + // change (e.g. after Beräkna om) so the preview stays in sync; clears while + // the run isn't calculated yet. + const isCalculatedForPreview = run?.calculation_params != null + useEffect(() => { + if (!isCalculatedForPreview) { + setPreview(null) + return + } + let cancelled = false + ;(async () => { + const res = await fetch(`/api/salary/runs/${id}/preview`) + if (!res.ok) return + const { data } = await res.json() + if (!cancelled) setPreview(data) + })() + return () => { + cancelled = true + } + }, [id, isCalculatedForPreview, run?.total_gross, run?.total_tax, run?.total_avgifter]) + async function handleAction(action: string, method: string = 'POST') { setActionLoading(action) const res = await fetch(`/api/salary/runs/${id}/${action}`, { method }) if (res.ok) { - await loadRun() - toast({ title: 'Status uppdaterad' }) - } else { - const result = await res.json() - toast({ - title: 'Kunde inte uppdatera status', - description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), - variant: 'destructive', - }) + // Optimistic: the status-transition endpoints return the updated run row. + // Merge it in immediately so the screen flips without waiting for the + // heavy detail refetch, then reconcile in the background. This is what + // makes "Till granskning" / "Godkänn" feel instant. + const payload = await res.json().catch(() => null) + if (payload?.data) { + setRun(prev => (prev ? { ...prev, ...payload.data } : prev)) + } + setActionLoading(null) + toast({ title: t('toast_status_updated') }) + loadRun() // background reconcile - not awaited + return } + const result = await res.json().catch(() => ({})) + toast({ + title: t('toast_status_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + setActionLoading(null) + } + + // Approval is an authorization step. Missing bank details are an *overridable* + // block (SALARY_APPROVE_BANK_DETAILS_MISSING) - rather than dead-ending on a + // 400 toast, we surface a confirm dialog and re-approve with ?force=true when + // the user chooses "Godkänn ändå". The payment-file step still hard-blocks. + async function doApprove(force: boolean) { + setActionLoading('approve') + const res = await fetch(`/api/salary/runs/${id}/approve${force ? '?force=true' : ''}`, { + method: 'POST', + }) + if (res.ok) { + setApproveOverride(null) + const payload = await res.json().catch(() => null) + if (payload?.data) { + setRun(prev => (prev ? { ...prev, ...payload.data } : prev)) + } + setActionLoading(null) + toast({ title: t('toast_status_updated') }) + loadRun() // background reconcile - not awaited + return + } + const result = await res.json().catch(() => ({})) + // Overridable → open the confirm dialog instead of toasting the error. + if ( + !force && + result?.code === 'SALARY_APPROVE_BANK_DETAILS_MISSING' && + Array.isArray(result.details) + ) { + setApproveOverride(result.details as string[]) + setActionLoading(null) + return + } + setApproveOverride(null) + toast({ + title: t('toast_status_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) setActionLoading(null) } async function handleDelete() { if (!run) return - const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` - if (!confirm(`Radera utkastet för ${period}? Alla anställda och beräkningar i körningen tas bort. Detta kan inte ångras.`)) return + const period = periodLabelOf(run) + if (!confirm(t('confirm_delete', { period }))) return setActionLoading('delete') const res = await fetch(`/api/salary/runs/${id}`, { method: 'DELETE' }) if (res.ok) { - toast({ title: 'Utkast raderat' }) + toast({ title: t('toast_draft_deleted') }) router.push('/salary') return } - const result = await res.json() + const result = await res.json().catch(() => ({})) toast({ - title: 'Kunde inte radera utkast', + title: t('toast_delete_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + setActionLoading(null) + } + + // Storno-based correction (BFL 5 kap. 5 §) - the confirm dialog lives in + // RunHeader; this fires only after the user has confirmed there. + async function handleCorrect() { + setActionLoading('correct') + const res = await fetch(`/api/salary/runs/${id}/correct`, { method: 'POST' }) + if (res.ok) { + const { data } = await res.json() + toast({ title: t('toast_correction_created'), description: t('toast_correction_description') }) + router.push(`/salary/runs/${data.id}`) + return + } + const result = await res.json().catch(() => ({})) + toast({ + title: t('toast_correction_failed'), description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), variant: 'destructive', }) @@ -173,11 +251,11 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: }) if (res.ok) { await loadRun() - toast({ title: 'Anställd tillagd' }) + toast({ title: t('toast_employee_added') }) } else { - const result = await res.json() + const result = await res.json().catch(() => ({})) toast({ - title: 'Kunde inte lägga till anställd', + title: t('toast_add_employee_failed'), description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), variant: 'destructive', }) @@ -186,21 +264,20 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: } // Remove an employee from a draft run. The DELETE endpoint is draft-only and - // cascades to the employee's line items; the button is only rendered while the - // run is a draft, matching that guard. + // cascades to the employee's line items. async function handleRemoveEmployee(employeeId: string, name: string) { - if (!confirm(`Ta bort ${name} från lönekörningen?`)) return + if (!confirm(t('confirm_remove_employee', { name }))) return setActionLoading(`remove-${employeeId}`) const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, { method: 'DELETE', }) if (res.ok) { await loadRun() - toast({ title: 'Anställd borttagen' }) + toast({ title: t('toast_employee_removed') }) } else { const result = await res.json() toast({ - title: 'Kunde inte ta bort anställd', + title: t('toast_remove_employee_failed'), description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), variant: 'destructive', }) @@ -209,9 +286,8 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: } // Edit this month's monthly salary for one employee (draft only). The engine - // reads this per-run value at calc time, so each month's gross can differ - // without changing the employee's standard pay. Saved on blur; the user then - // clicks Beräkna to refresh the outcome. + // reads this per-run value at calc time. Saved on blur; the user then clicks + // Beräkna to refresh the outcome. async function handleSalaryEdit(employeeId: string, raw: string, previous: number) { const monthly = Number(raw.replace(/\s/g, '').replace(',', '.')) if (!Number.isFinite(monthly) || monthly < 0 || monthly === previous) return @@ -223,11 +299,11 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: }) if (res.ok) { await loadRun() - toast({ title: 'Månadslön uppdaterad', description: 'Klicka Beräkna för att uppdatera utfallet.' }) + toast({ title: t('toast_salary_updated'), description: t('toast_salary_updated_hint') }) } else { const result = await res.json() toast({ - title: 'Kunde inte uppdatera månadslön', + title: t('toast_salary_update_failed'), description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), variant: 'destructive', }) @@ -243,16 +319,16 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: await loadRun() const warnings = (payload.warnings as string[] | undefined) ?? [] if (warnings.length === 0) { - toast({ title: 'Beräkning klar' }) + toast({ title: t('toast_calculation_done') }) } else { for (const warning of warnings) { - toast({ title: 'Att kontrollera', description: warning }) + toast({ title: t('toast_calculation_warning'), description: warning }) } } } else { const result = await res.json() toast({ - title: 'Beräkningsfel', + title: t('toast_calculation_failed'), description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), variant: 'destructive', }) @@ -270,15 +346,47 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: setActionLoading(null) } + async function handleSendPayslips() { + setActionLoading('payslips-send') + const res = await fetch(`/api/salary/runs/${id}/payslips/send`, { method: 'POST' }) + if (res.ok) { + const { data } = await res.json() + await loadRun() + toast({ + title: t('toast_payslips_sent'), + description: t('toast_payslips_sent_detail', { + sent: data.sent, + skipped: data.skipped, + }), + }) + if (data.errors?.length) { + for (const err of data.errors as string[]) { + toast({ title: t('toast_payslip_error'), description: err, variant: 'destructive' }) + } + } + } else { + const result = await res.json() + toast({ + title: t('toast_payslips_send_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } + setActionLoading(null) + } + async function handleBulkPayslipDownload() { + if (!run) return setActionLoading('bulk_payslip') try { const { default: JSZip } = await import('jszip') const zip = new JSZip() - const periodLabel = `${run!.period_year}-${String(run!.period_month).padStart(2, '0')}` + const periodLabel = periodLabelOf(run) let added = 0 for (const sre of employees) { - const employee = (sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string } }).employee + const employee = (sre as SalaryRunEmployee & { + employee?: { first_name: string; last_name: string } + }).employee const res = await fetch(`/api/salary/runs/${id}/payslips/${sre.employee_id}/pdf`) if (!res.ok) continue const blob = await res.blob() @@ -289,7 +397,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: added++ } if (added === 0) { - toast({ title: 'Inga lönespecifikationer kunde laddas ner', variant: 'destructive' }) + toast({ title: t('toast_payslips_download_empty'), variant: 'destructive' }) return } const archive = await zip.generateAsync({ type: 'blob' }) @@ -301,11 +409,11 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: a.click() document.body.removeChild(a) URL.revokeObjectURL(url) - toast({ title: 'Lönespecifikationer nedladdade', description: `${added} stycken i zip-arkiv.` }) + toast({ title: t('toast_payslips_downloaded'), description: t('toast_payslips_downloaded_detail', { count: added }) }) } catch (err) { toast({ - title: 'Kunde inte skapa zip-fil', - description: err instanceof Error ? err.message : 'Okänt fel', + title: t('toast_zip_failed'), + description: err instanceof Error ? err.message : t('unknown_error'), variant: 'destructive', }) } finally { @@ -314,12 +422,13 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: } async function handleDownloadAgi() { + if (!run) return setActionLoading('agi-download') const res = await fetch(`/api/salary/runs/${id}/agi/xml`) if (!res.ok) { - const result = await res.json().catch(() => ({ error: 'Kunde inte generera AGI-fil' })) + const result = await res.json().catch(() => ({ error: t('toast_agi_failed') })) toast({ - title: 'AGI-fil kunde inte genereras', + title: t('toast_agi_failed'), description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), variant: 'destructive', }) @@ -328,16 +437,16 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: } const blob = await res.blob() const url = URL.createObjectURL(blob) - const periodLabel = `${run!.period_year}${String(run!.period_month).padStart(2, '0')}` + const compactPeriod = `${run.period_year}${String(run.period_month).padStart(2, '0')}` const a = document.createElement('a') a.href = url - a.download = `AGI_${periodLabel}.xml` + a.download = `AGI_${compactPeriod}.xml` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) await loadRun() - toast({ title: 'AGI-fil nedladdad' }) + toast({ title: t('toast_agi_downloaded') }) setActionLoading(null) } @@ -345,374 +454,132 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: return (
- +
+ + +
) } if (!run) { - return

Lönekörning hittades inte

+ return

{t('not_found')}

} - const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + const periodLabel = periodLabelOf(run) const employees = (run.employees || []) as SalaryRunEmployee[] - const addedEmployeeIds = new Set(employees.map(e => e.employee_id)) - const notAdded = availableEmployees.filter(e => !addedEmployeeIds.has(e.id)) - // Employees can only be removed while the run is a draft (matches the DELETE - // endpoint's guard); gate the row action column on the same condition. - const canRemoveEmployee = run.status === 'draft' && canWrite // calculation_params is frozen only when the run has been calculated, so it // distinguishes "not yet calculated" from "calculated to 0" (a nollkörning). const isCalculated = run.calculation_params != null const isNollkorning = isCalculated && Math.round((run.total_gross ?? 0) * 100) === 0 + // A run that pays out nothing (a nollkörning, or one fully consumed by a + // nettolöneavdrag) has no payment-file line and no payout to perform. The + // pay step collapses to a plain "continue" and the payment-file panel is + // hidden - mirrors the pain.001 / BG-LB generators, which emit no rows here. + const noPayout = isCalculated && Math.round((run.total_net ?? 0) * 100) === 0 // Advancing a draft to review. For a nollkörning confirm first: an empty // declaration is filed to Skatteverket, which should be deliberate. function handleToReview() { - if ( - isNollkorning && - !confirm( - 'Detta är en nollkörning: ingen lön rapporteras för perioden. ' + - 'En nolldeklaration (huvuduppgift utan individuppgifter) lämnas till Skatteverket. Vill du fortsätta?', - ) - ) { + if (isNollkorning && !confirm(t('confirm_nollkorning'))) { return } handleAction('review') } - return ( -
- {/* Header */} -
-
- -
-

- Lönekörning {periodLabel} -

-

- Utbetalning: {formatDate(run.payment_date)} -

-
-
- - {STATUS_LABELS[run.status]} - -
+ // The one next step for the current status, mirrored as a prominent header + // button - the rail alone buried it (nobody found Godkänn). + const primaryAction = !canWrite + ? null + : run.status === 'draft' + ? isCalculated + ? { key: 'review', label: t('action_to_review'), onClick: handleToReview } + : { key: 'calculate', label: t('action_calculate'), onClick: handleCalculate } + : run.status === 'review' + ? { key: 'approve', label: t('action_approve'), onClick: () => doApprove(false) } + : run.status === 'approved' + ? { key: 'paid', label: noPayout ? t('action_continue') : t('action_mark_paid'), onClick: () => handleAction('paid') } + : run.status === 'paid' + ? { key: 'book', label: t('action_book'), onClick: () => handleAction('book') } + : null - {/* Summary cards: recompute from per-employee rows so manual overrides - (avancerat läge) are reflected immediately, without relying on - run.total_* columns which are frozen at calculate-time. */} -
- {(() => { - const effTax = employees.reduce((s, e) => s + (e.tax_withheld_override ?? e.tax_withheld), 0) - const effAvgifter = employees.reduce((s, e) => s + (e.avgifter_amount_override ?? e.avgifter_amount), 0) - const effNet = employees.reduce( - (s, e) => s + (e.net_salary + (e.tax_withheld - (e.tax_withheld_override ?? e.tax_withheld))), - 0, - ) - const effEmployerCost = employees.reduce( - (s, e) => s + e.gross_salary + (e.avgifter_amount_override ?? e.avgifter_amount) + e.vacation_accrual + e.vacation_accrual_avgifter, - 0, - ) - return [ - { label: 'Brutto', value: run.total_gross }, - { label: 'Skatt', value: effTax }, - { label: 'Netto', value: effNet, accent: true }, - { label: 'Avgifter', value: effAvgifter }, - { label: 'Total kostnad', value: effEmployerCost }, - ] - })().map(({ label, value, accent }) => ( - - -

{label}

-

- {formatCurrency(value)} -

-
-
- ))} -
+ return ( +
+ + + {/* Control zone: the wizard line and every action for the current stage, + grouped in one place with a large primary target. */} + handleAction('revert')} + onSendPayslips={handleSendPayslips} + onDownloadPayslips={handleBulkPayslipDownload} + /> + + {isNollkorning && ( -

Nollkörning

+

{t('nollkorning_title')}

- Ingen lön rapporteras för {periodLabel}. En nolldeklaration (huvuduppgift utan - individuppgifter) lämnas till Skatteverket: en registrerad arbetsgivare måste lämna - arbetsgivardeklaration varje månad, även månader utan lön. + {t('nollkorning_body', { period: periodLabel })}

)} - {/* Employees */} - - - Anställda ({employees.length}) -
- {employees.length > 0 && ( - - )} - {run.status === 'draft' && canWrite && notAdded.length > 0 && ( - - )} -
-
- - {employees.length === 0 ? ( -

- Inga anställda tillagda ännu -

- ) : ( -
TypBeskrivningAntalBelopp{t('th_type')}{t('th_description')}{t('th_quantity')}{t('th_amount')}
{LINE_ITEM_TYPE_LABELS[li.item_type] ?? li.item_type}{LINE_ITEM_TYPE_KEYS[li.item_type] ? t(LINE_ITEM_TYPE_KEYS[li.item_type]) : li.item_type} {li.description} {li.quantity ?? '-'} {formatCurrency(li.amount)}
- - - Anställd - {run.status === 'draft' ? 'Månadslön' : 'Brutto'} - Skatt - Netto - Avgifter - Semester - Lönespec - {canRemoveEmployee && Ta bort} - - - - {employees.map(sre => { - const employee = (sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string; personnummer: string; default_dimensions?: Record } }).employee - const name = employee - ? `${employee.first_name} ${employee.last_name}` - : `Anställd ${sre.employee_id.slice(0, 8)}...` - // Compact default-dimensions bag ({sie_dim_no: object_code}), - // dim-number order (kostnadsställe "1" before projekt "6"). - const dims = employee?.default_dimensions ?? {} - const dimLabel = Object.keys(dims) - .sort((a, b) => Number(a) - Number(b)) - .map(k => dims[k]) - .join(' · ') - const taxValue = sre.tax_withheld_override ?? sre.tax_withheld - const avgifterValue = sre.avgifter_amount_override ?? sre.avgifter_amount - // Monthly salary is editable per run while the run is a draft. - const editableSalary = run.status === 'draft' && canWrite && sre.salary_type === 'monthly' - return ( - router.push(`/salary/runs/${id}/employees/${sre.employee_id}`)} - > - - e.stopPropagation()} - > - {name} - - {dimensionsEnabled && dimLabel && ( - {dimLabel} - )} - - {run.status === 'draft' - ? `Månadslön ${formatCurrency(sre.monthly_salary)}` - : `Brutto ${formatCurrency(sre.gross_salary)}`} - - - - {editableSalary ? ( - e.stopPropagation()} - onBlur={(e) => handleSalaryEdit(sre.employee_id, e.target.value, sre.monthly_salary)} - disabled={actionLoading === `salary-${sre.employee_id}`} - aria-label={`Månadslön för ${name}`} - className="h-8 w-32 ml-auto text-right tabular-nums" - /> - ) : ( - formatCurrency(sre.gross_salary) - )} - - {formatCurrency(taxValue)} - {formatCurrency(sre.net_salary + (sre.tax_withheld - taxValue))} - {formatCurrency(avgifterValue)} - {formatCurrency(sre.vacation_accrual)} - - e.stopPropagation()} - className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" - title="Visa lönespecifikation" - > - - Visa PDF - - - {canRemoveEmployee && ( - - - - )} - - ) - })} - -
- )} -
-
+ - {/* Calculation breakdown (if available) */} - {employees.some(e => e.calculation_breakdown) && ( - - - Beräkningsdetaljer - - - - {employees.filter(e => e.calculation_breakdown).map(sre => { - const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number | null }> } - return ( -
-

- {(sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string } }).employee - ? `${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.first_name} ${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.last_name}` - : sre.employee_id.slice(0, 8)} -

-
- {(breakdown?.steps || []).map((step, i) => ( -
- - {step.label}: {step.formula} - - {step.output !== null && ( - {formatCurrency(step.output)} - )} -
- ))} -
-
- ) - })} -
-
- )} + {/* Calculation detail and the journal preview read best side by side on + the wide canvas; they stack on smaller viewports. */} +
+ + {preview && ( + + )} +
- {/* Journal preview */} - {preview && ( - - - Förhandsgranskning: verifikationer - - - {(() => { - const entries = [ - preview.salaryEntry, - preview.avgifterEntry, - preview.vacationEntry, - (preview as unknown as Record).pensionEntry, - ].filter(Boolean) as EntryPreview[] - if (entries.length === 0) { - return ( -

- Nollkörning: inga verifikat bokförs för den här körningen. - Kontrollera att övriga lönekörningar för perioden täcker - arbetsgivardeklarationen till Skatteverket. -

- ) - } - return entries.map((entry, idx) => ( -
-

{entry.description}

- - - - - - - - - - - {entry.lines.map((line, li) => ( - - - - - - - ))} - -
KontoBeskrivningDebetKredit
{line.account_number}{line.line_description}{line.debit_amount ? formatCurrency(line.debit_amount) : ''}{line.credit_amount ? formatCurrency(line.credit_amount) : ''}
-
- )) - })()} -
-
- )} - - {/* Payment file: available once the run is approved */} - {['approved', 'paid', 'booked'].includes(run.status) && ( + {/* Payment file: available once the run is approved, but only when there + is something to pay out. A zero-payout run generates no file rows. */} + {['approved', 'paid', 'booked'].includes(run.status) && !noPayout && ( @@ -736,7 +603,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
)} - Ladda ner AGI-fil (XML) + {t('action_download_agi')}
)}
)} - {/* Actions */} - {canWrite && ( -
- {run.status === 'draft' && ( - <> - - - - - + {/* Overridable approval guard: missing bank details don't dead-end - + the user can approve now and complete details before the payment file. */} + { if (!open) setApproveOverride(null) }}> + + + {t('approve_override_title')} + + {t('approve_override_body')} + + + {approveOverride && approveOverride.length > 0 && ( +
+ +
    + {approveOverride.map((reason, i) => ( +
  • {reason}
  • + ))} +
+
)} - {run.status === 'review' && ( - <> - - - - - )} - {run.status === 'approved' && ( - - )} - {run.status === 'paid' && ( - - )} -
- )} + + +
) } diff --git a/app/(dashboard)/salary/runs/new/page.tsx b/app/(dashboard)/salary/runs/new/page.tsx deleted file mode 100644 index 6abb4b7a..00000000 --- a/app/(dashboard)/salary/runs/new/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { redirect } from 'next/navigation' - -// Salary run creation now happens in a modal on the salary overview (matching -// the verifikat pattern): the form itself lives in -// components/salary/NewSalaryRunDialog.tsx. This route survives as a redirect -// so old links, bookmarks, and agent intents keep working. -export default function NewSalaryRunPage() { - redirect('/salary?new=1') -} diff --git a/app/(dashboard)/suppliers/[id]/page.tsx b/app/(dashboard)/suppliers/[id]/page.tsx index bed958a6..5dd1b774 100644 --- a/app/(dashboard)/suppliers/[id]/page.tsx +++ b/app/(dashboard)/suppliers/[id]/page.tsx @@ -55,10 +55,12 @@ export default function SupplierDetailPage() { } async function fetchInvoices() { - const res = await fetch(`/api/supplier-invoices?status=all`) + const res = await fetch( + `/api/supplier-invoices?status=all&supplier_id=${encodeURIComponent(String(params.id))}`, + ) const { data } = await res.json() if (data) { - setInvoices(data.filter((inv: SupplierInvoice) => inv.supplier_id === params.id)) + setInvoices(data as SupplierInvoice[]) } } diff --git a/app/(onboarding)/onboarding/agent/page.tsx b/app/(onboarding)/onboarding/agent/page.tsx index 6fbb9518..088d37d0 100644 --- a/app/(onboarding)/onboarding/agent/page.tsx +++ b/app/(onboarding)/onboarding/agent/page.tsx @@ -21,16 +21,45 @@ export default async function AgentOnboardingPage() { const companyId = await getActiveCompanyId(supabase, user.id) if (!companyId) redirect('/onboarding') + // Everything that doesn't depend on the TIC snapshot loads in one batch: + // the settings row (carrying the is_sandbox gate + the onboarding-form + // data, moms_period, fiscal_year_start_month, f_skatt, city, …, that + // never makes it onto `companies` proper), the greeting profile, any + // existing agent profile, and the atom registry titles ("Konsult It"-style + // slug labels look ugly; the registry has them as authored). + const [ + { data: settings }, + { data: profile }, + { data: existingProfile }, + { data: atomRows }, + hdrs, + ] = await Promise.all([ + supabase + .from('company_settings') + .select( + 'is_sandbox, city, address_line1, postal_code, f_skatt, vat_registered, moms_period, fiscal_year_start_month, employee_count, has_employees', + ) + .eq('company_id', companyId) + .maybeSingle(), + supabase.from('profiles').select('full_name').eq('id', user.id).single(), + supabase + .from('agent_profiles') + .select('company_id, profile_summary, verified_at') + .eq('company_id', companyId) + .maybeSingle(), + supabase + .from('agent_atom_registry') + .select('id, title') + .eq('is_active', true) + .is('parent_atom_id', null), // skill titles only; reference children never appear as profile chips + headers(), + ]) + // Sandbox companies ship with a pre-built verified agent_profile: the // build flow on this page would call TIC and the gated composer stream, // both of which 403. Send them back to the dashboard where the demo // assistant is already visible via the sheet preview. - const { data: settingsForSandbox } = await supabase - .from('company_settings') - .select('is_sandbox') - .eq('company_id', companyId) - .maybeSingle() - if (settingsForSandbox?.is_sandbox) redirect('/') + if (settings?.is_sandbox) redirect('/') // Trigger the TIC live-fetch + cache before the field-resolving query // below. ensureTicSnapshot is fast on cache-hit (single SELECT) and @@ -38,7 +67,6 @@ export default async function AgentOnboardingPage() { // streaming endpoint; this just lets the initial Phase B render show the // SNI/verksamhetsbeskrivning when the user returns to the page after // stream completion. - const hdrs = await headers() const cookieHeader = hdrs.get('cookie') ?? '' const host = hdrs.get('host') ?? 'localhost:3000' const proto = hdrs.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https') @@ -51,30 +79,13 @@ export default async function AgentOnboardingPage() { // Fetch the small handful of fields we render directly into Phase B so the // user sees real values (not "Laddar…") the moment the stream finishes. - // company_settings is a separate fetch because it carries the onboarding- - // form data (moms_period, fiscal_year_start_month, f_skatt, city, …) that - // never makes it onto `companies` proper. - const [{ data: company }, { data: profile }, { data: existingProfile }, { data: settings }] = - await Promise.all([ - supabase - .from('companies') - .select('name, entity_type, org_number, tic_snapshot') - .eq('id', companyId) - .single(), - supabase.from('profiles').select('full_name').eq('id', user.id).single(), - supabase - .from('agent_profiles') - .select('company_id, profile_summary, verified_at') - .eq('company_id', companyId) - .maybeSingle(), - supabase - .from('company_settings') - .select( - 'city, address_line1, postal_code, f_skatt, vat_registered, moms_period, fiscal_year_start_month, employee_count, has_employees', - ) - .eq('company_id', companyId) - .maybeSingle(), - ]) + // Must run AFTER ensureTicSnapshot, it reads the tic_snapshot that call + // may have just written. + const { data: company } = await supabase + .from('companies') + .select('name, entity_type, org_number, tic_snapshot') + .eq('id', companyId) + .single() if (!company) redirect('/onboarding') @@ -83,14 +94,6 @@ export default async function AgentOnboardingPage() { // before the stream completes so the layout doesn't jump. const initialFields = buildInitialFields(company, settings) - // Atom titles: slug-derived labels look ugly ("Konsult It", - // "Single Shareholder Ab Fmb"). Fetch the registry titles once and pass them - // to the review card so chips render as authored. - const { data: atomRows } = await supabase - .from('agent_atom_registry') - .select('id, title') - .eq('is_active', true) - .is('parent_atom_id', null) // skill titles only; reference children never appear as profile chips const atomTitles: Record = {} for (const row of (atomRows ?? []) as { id: string; title: string }[]) { atomTitles[row.id] = row.title diff --git a/app/(onboarding)/select-company/page.tsx b/app/(onboarding)/select-company/page.tsx index 593ebb3a..6a8e8ab2 100644 --- a/app/(onboarding)/select-company/page.tsx +++ b/app/(onboarding)/select-company/page.tsx @@ -18,21 +18,46 @@ export default async function SelectCompanyPage() { redirect('/login') } - // Existing Accounted memberships. - const { data: memberships } = await supabase - .from('company_members') - .select(` - role, - company:company_id ( - id, - name, - org_number, - entity_type, - archived_at - ) - `) - .eq('user_id', user.id) - .order('joined_at', { ascending: true }) + // All four lookups key only on user.id, one parallel batch instead of + // four serial round-trips on the post-BankID-login landing page. + const [ + // Existing Accounted memberships. + { data: memberships }, + { data: teamMembership }, + // Greeting name. + { data: profile }, + // BankID enrichment (CompanyRoles from Bolagsverket via TIC). Stored + // user-keyed in `bankid_enrichment` because it lands before company + // selection, see fetchAndStoreEnrichment in the tic extension. + { data: enrichmentRow }, + ] = await Promise.all([ + supabase + .from('company_members') + .select(` + role, + company:company_id ( + id, + name, + org_number, + entity_type, + archived_at + ) + `) + .eq('user_id', user.id) + .order('joined_at', { ascending: true }), + supabase + .from('team_members') + .select('team_id') + .eq('user_id', user.id) + .limit(1) + .maybeSingle(), + supabase.from('profiles').select('full_name').eq('id', user.id).single(), + supabase + .from('bankid_enrichment') + .select('company_roles, created_at, updated_at') + .eq('user_id', user.id) + .maybeSingle(), + ]) type CompanyRow = { id: string @@ -68,13 +93,6 @@ export default async function SelectCompanyPage() { ) // Ensure the user has a team (same pattern as /onboarding). - const { data: teamMembership } = await supabase - .from('team_members') - .select('team_id') - .eq('user_id', user.id) - .limit(1) - .maybeSingle() - let teamId = teamMembership?.team_id if (!teamId) { const { data: ensured } = await supabase.rpc('ensure_user_team') @@ -84,23 +102,8 @@ export default async function SelectCompanyPage() { redirect('/login') } - // Greeting name. - const { data: profile } = await supabase - .from('profiles') - .select('full_name') - .eq('id', user.id) - .single() const firstName = profile?.full_name?.split(' ')[0] ?? null - // BankID enrichment (CompanyRoles from Bolagsverket via TIC). Stored - // user-keyed in `bankid_enrichment` because it lands before company - // selection: see fetchAndStoreEnrichment in the tic extension. - const { data: enrichmentRow } = await supabase - .from('bankid_enrichment') - .select('company_roles, created_at, updated_at') - .eq('user_id', user.id) - .maybeSingle() - const enrichmentValue = enrichmentRow ? { companyRoles: enrichmentRow.company_roles as EnrichmentCompanyRole[] } : null diff --git a/app/api/account/delete/route.ts b/app/api/account/delete/route.ts index 10d05f96..6481b30a 100644 --- a/app/api/account/delete/route.ts +++ b/app/api/account/delete/route.ts @@ -1,7 +1,8 @@ -import { createClient, createServiceClient } from '@/lib/supabase/server' +import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' import { ensureInitialized } from '@/lib/init' +import { requireAuth } from '@/lib/auth/require-auth' import { validateBody } from '@/lib/api/validate' import { eventBus } from '@/lib/events' import { createLogger } from '@/lib/logger' @@ -26,14 +27,17 @@ const DeleteAccountSchema = z.object({ * Precondition: the user must own zero non-archived companies. The RPC * enforces this at the DB level and raises SQLSTATE P0001 with a message * if the precondition fails: we return 409 in that case. + * + * Not wrapped in withRouteContext: deletion must work for users with zero + * companies, so there is no company context to resolve. requireAuth() is + * used directly so MFA (AAL2) is still enforced on hosted: a stolen AAL1 + * cookie must not be able to destroy the account. BankID-linked users are + * exempt from the AAL2 gate (BankID is inherently 2FA, see shouldEnforceMfa). */ export async function POST(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const auth = await requireAuth() + if (auth.error) return auth.error + const { user, supabase } = auth const result = await validateBody(request, DeleteAccountSchema) if (!result.success) return result.response @@ -130,8 +134,5 @@ export async function POST(request: Request) { // Best-effort: clear the caller's session cookie too. await supabase.auth.signOut().catch(() => {}) - // Request body is consumed; avoid unused-var lint. - void request - return NextResponse.json({ success: true }) } diff --git a/app/api/agent/__tests__/composer.test.ts b/app/api/agent/__tests__/composer.test.ts new file mode 100644 index 00000000..ce205938 --- /dev/null +++ b/app/api/agent/__tests__/composer.test.ts @@ -0,0 +1,102 @@ +/** + * Tests for POST /api/agent/composer. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const checkRateMock = vi.fn() +vi.mock('@/lib/rate-limits/agent', () => ({ + checkAgentRateLimit: (...args: unknown[]) => checkRateMock(...args), + agentRateLimitResponseBody: () => ({ error: 'För många förfrågningar.' }), +})) + +vi.mock('@/lib/sandbox/guard', () => ({ + guardSandbox: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/entitlements/has-capability', () => ({ + requireCapability: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/entitlements/keys', () => ({ + CAPABILITY: { ai: 'ai' }, +})) + +const composeMock = vi.fn() +vi.mock('@/lib/agent/composer', () => ({ + composeAgentProfile: (...args: unknown[]) => composeMock(...args), +})) + +import { POST } from '../composer/route' + +const routeParams = { params: Promise.resolve({}) } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + checkRateMock.mockResolvedValue({ ok: true }) +}) + +describe('POST /api/agent/composer', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const req = createMockRequest('/api/agent/composer', { method: 'POST', body: {} }) + const res = await POST(req, routeParams) + expect(res.status).toBe(401) + }) + + it('returns 429 when rate limited', async () => { + checkRateMock.mockResolvedValue({ ok: false, retryAfterSec: 30 }) + + const req = createMockRequest('/api/agent/composer', { method: 'POST', body: {} }) + const res = await POST(req, routeParams) + expect(res.status).toBe(429) + expect(res.headers.get('Retry-After')).toBe('30') + }) + + it('refuses a viewer with 403 (composer rewrites the profile)', async () => { + enqueue({ data: { role: 'viewer' } }) + + const req = createMockRequest('/api/agent/composer', { method: 'POST', body: {} }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await POST(req, routeParams) + ) + expect(status).toBe(403) + expect(body.error.code).toBe('WRITE_PERMISSION_REQUIRED') + expect(composeMock).not.toHaveBeenCalled() + }) + + it('runs the composer for a non-viewer member', async () => { + enqueue({ data: { role: 'owner' } }) + composeMock.mockResolvedValue({ company_id: 'company-1', profile_summary: 'Byggd' }) + + const req = createMockRequest('/api/agent/composer', { + method: 'POST', + body: { dry_run: true }, + }) + const { status, body } = await parseJsonResponse<{ data: { profile_summary: string } }>( + await POST(req, routeParams) + ) + expect(status).toBe(200) + expect(body.data.profile_summary).toBe('Byggd') + expect(composeMock).toHaveBeenCalledWith(expect.anything(), 'company-1', { dryRun: true }) + }) +}) diff --git a/app/api/agent/__tests__/conversations.test.ts b/app/api/agent/__tests__/conversations.test.ts new file mode 100644 index 00000000..3c9acc12 --- /dev/null +++ b/app/api/agent/__tests__/conversations.test.ts @@ -0,0 +1,176 @@ +/** + * Tests for GET /api/agent/conversations and GET/PATCH /api/agent/conversations/[id]. + * + * Uses a filter-capturing Supabase mock so the user-scoping regression is + * locked in: RLS on agent_conversations is company-scoped, so the explicit + * .eq('user_id', …) filter in the list route is the only thing preventing + * team members from seeing each other's conversation titles/previews. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET as listGET } from '../conversations/route' +import { GET as detailGET, PATCH as detailPATCH } from '../conversations/[id]/route' + +interface CapturedCall { + method: string + args: unknown[] +} + +/** Chainable builder that records every call and resolves queued results per from(). */ +function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) { + const calls: CapturedCall[] = [] + let idx = 0 + const makeBuilder = () => { + const result = results[idx++] ?? { data: null, error: null } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b: any = {} + for (const m of ['select', 'eq', 'is', 'or', 'order', 'limit', 'insert', 'update', 'maybeSingle', 'single']) { + b[m] = (...args: unknown[]) => { + calls.push({ method: m, args }) + return b + } + } + b.then = (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null, count: null }) + return b + } + const supabase = { + from: (table: string) => { + calls.push({ method: 'from', args: [table] }) + return makeBuilder() + }, + } + return { supabase, calls } +} + +const routeParams = { params: Promise.resolve({}) } +const idParams = { params: Promise.resolve({ id: 'conv-1' }) } + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/agent/conversations', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const res = await listGET(createMockRequest('/api/agent/conversations'), routeParams) + expect(res.status).toBe(401) + }) + + it('returns 400 for a non-numeric limit', async () => { + const { supabase } = createCapturingSupabase([]) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + + const req = createMockRequest('/api/agent/conversations', { searchParams: { limit: 'abc' } }) + const { status } = await parseJsonResponse(await listGET(req, routeParams)) + expect(status).toBe(400) + }) + + it('filters the list by BOTH company_id and the calling user_id', async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: [{ id: 'conv-1', title: 'Min konversation' }] }, + ]) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + + const req = createMockRequest('/api/agent/conversations') + const { status, body } = await parseJsonResponse<{ data: unknown[] }>( + await listGET(req, routeParams) + ) + + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args) + expect(eqCalls).toContainEqual(['company_id', 'company-1']) + // Privacy regression guard: without this filter, company-scoped RLS lets + // every member read colleagues' titles and last_message_preview. + expect(eqCalls).toContainEqual(['user_id', 'user-1']) + }) +}) + +describe('GET /api/agent/conversations/[id]', () => { + it('returns 404 when the conversation is not owned by the caller', async () => { + // Ownership is part of the fetch (.eq user_id) — a non-owned id resolves null. + const { supabase, calls } = createCapturingSupabase([{ data: null }]) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await detailGET(createMockRequest('/api/agent/conversations/conv-1'), idParams) + ) + + expect(status).toBe(404) + expect(body.error.code).toBe('CONVERSATION_NOT_FOUND') + const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args) + expect(eqCalls).toContainEqual(['user_id', 'user-1']) + }) + + it('returns the conversation with its messages for the owner', async () => { + const { supabase } = createCapturingSupabase([ + { data: { id: 'conv-1', company_id: 'company-1', user_id: 'user-1', title: 'T' } }, + { data: { role: 'member' } }, + { data: [{ id: 'm1', role: 'user', content: 'Hej' }] }, + ]) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + + const { status, body } = await parseJsonResponse<{ + data: { conversation: { id: string }; messages: unknown[] } + }>(await detailGET(createMockRequest('/api/agent/conversations/conv-1'), idParams)) + + expect(status).toBe(200) + expect(body.data.conversation.id).toBe('conv-1') + expect(body.data.messages).toHaveLength(1) + }) +}) + +describe('PATCH /api/agent/conversations/[id]', () => { + it('returns 400 when the body has nothing to update', async () => { + const { supabase } = createCapturingSupabase([]) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + + const req = createMockRequest('/api/agent/conversations/conv-1', { + method: 'PATCH', + body: {}, + }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await detailPATCH(req, idParams) + ) + + expect(status).toBe(400) + expect(body.error.code).toBe('NOTHING_TO_UPDATE') + }) + + it('updates pin state for an owned conversation', async () => { + const { supabase } = createCapturingSupabase([ + { data: { user_id: 'user-1', company_id: 'company-1' } }, + { data: { id: 'conv-1', pinned: true } }, + ]) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + + const req = createMockRequest('/api/agent/conversations/conv-1', { + method: 'PATCH', + body: { pinned: true }, + }) + const { status, body } = await parseJsonResponse<{ data: { pinned: boolean } }>( + await detailPATCH(req, idParams) + ) + + expect(status).toBe(200) + expect(body.data.pinned).toBe(true) + }) +}) diff --git a/app/api/agent/__tests__/memory.test.ts b/app/api/agent/__tests__/memory.test.ts new file mode 100644 index 00000000..e275bc80 --- /dev/null +++ b/app/api/agent/__tests__/memory.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for GET/POST /api/agent/memory and PATCH /api/agent/memory/[id]. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET, POST } from '../memory/route' +import { PATCH } from '../memory/[id]/route' + +const routeParams = { params: Promise.resolve({}) } +const idParams = { params: Promise.resolve({ id: 'mem-1' }) } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +describe('GET /api/agent/memory', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const res = await GET(createMockRequest('/api/agent/memory'), routeParams) + expect(res.status).toBe(401) + }) + + it('returns 400 for an unknown kind filter', async () => { + const req = createMockRequest('/api/agent/memory', { searchParams: { kind: 'gossip' } }) + const { status } = await parseJsonResponse(await GET(req, routeParams)) + expect(status).toBe(400) + }) + + it('lists memory entries', async () => { + enqueue({ data: [{ id: 'mem-1', kind: 'fact', content: 'Fakturerar i SEK' }] }) + + const { status, body } = await parseJsonResponse<{ data: unknown[] }>( + await GET(createMockRequest('/api/agent/memory'), routeParams) + ) + + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + }) +}) + +describe('POST /api/agent/memory', () => { + it('rejects a viewer in the target company with 403', async () => { + // requireWrite passed for the ACTIVE company, but the body targets a + // company where the caller is only a viewer — the re-check must refuse. + enqueue({ data: { role: 'viewer' } }) + + const req = createMockRequest('/api/agent/memory', { + method: 'POST', + body: { company_id: '7f3e0b1a-9c4d-4a2b-8f6e-1d2c3b4a5e6f', content: 'Ett minne' }, + }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await POST(req, routeParams) + ) + + expect(status).toBe(403) + expect(body.error.code).toBe('WRITE_PERMISSION_REQUIRED') + }) + + it('rejects an invalid body with 400', async () => { + const req = createMockRequest('/api/agent/memory', { + method: 'POST', + body: { content: 'x' }, // below min length 2 + }) + const { status } = await parseJsonResponse(await POST(req, routeParams)) + expect(status).toBe(400) + }) + + it('inserts a memory entry for the active company', async () => { + enqueue({ data: { role: 'admin' } }) // membership re-check + enqueue({ data: { id: 'mem-2', kind: 'fact', content: 'Ett minne' } }) + + const req = createMockRequest('/api/agent/memory', { + method: 'POST', + body: { content: 'Ett minne' }, + }) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>( + await POST(req, routeParams) + ) + + expect(status).toBe(200) + expect(body.data.id).toBe('mem-2') + }) +}) + +describe('PATCH /api/agent/memory/[id]', () => { + it('returns 400 when the body has nothing to update', async () => { + const req = createMockRequest('/api/agent/memory/mem-1', { method: 'PATCH', body: {} }) + const { status } = await parseJsonResponse(await PATCH(req, idParams)) + expect(status).toBe(400) + }) + + it('returns 404 when the memory row does not exist', async () => { + enqueue({ data: null }) + + const req = createMockRequest('/api/agent/memory/mem-1', { + method: 'PATCH', + body: { is_pinned: true }, + }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await PATCH(req, idParams) + ) + + expect(status).toBe(404) + expect(body.error.code).toBe('MEMORY_NOT_FOUND') + }) + + it('updates a memory entry', async () => { + enqueue({ data: { company_id: 'company-1' } }) // row lookup + enqueue({ data: { role: 'member' } }) // membership re-check + enqueue({ data: { id: 'mem-1', is_pinned: true } }) // update + + const req = createMockRequest('/api/agent/memory/mem-1', { + method: 'PATCH', + body: { is_pinned: true }, + }) + const { status, body } = await parseJsonResponse<{ data: { is_pinned: boolean } }>( + await PATCH(req, idParams) + ) + + expect(status).toBe(200) + expect(body.data.is_pinned).toBe(true) + }) +}) diff --git a/app/api/agent/__tests__/onboarding-stream.test.ts b/app/api/agent/__tests__/onboarding-stream.test.ts new file mode 100644 index 00000000..d148c379 --- /dev/null +++ b/app/api/agent/__tests__/onboarding-stream.test.ts @@ -0,0 +1,92 @@ +/** + * Tests for POST /api/agent/onboarding/stream — auth surface only. + * + * The composer pipeline itself is exercised via lib tests; here we lock in + * the guard order: 401, membership 403, and the viewer refusal (the pipeline + * upserts agent_profiles, so viewers must not be able to trigger it). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/rate-limits/agent', () => ({ + checkAgentRateLimit: vi.fn().mockResolvedValue({ ok: true }), + agentRateLimitResponseBody: () => ({ error: 'För många förfrågningar.' }), +})) + +vi.mock('@/lib/sandbox/guard', () => ({ guardSandbox: vi.fn().mockResolvedValue(null) })) +vi.mock('@/lib/entitlements/has-capability', () => ({ + requireCapability: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/entitlements/keys', () => ({ CAPABILITY: { ai: 'ai' } })) + +// Pipeline internals — never reached in these tests, stubbed so the module loads. +vi.mock('@/lib/agent/composer/inputs', () => ({ + gatherComposerInputs: vi.fn(), + inputsToSourceSignals: vi.fn(), +})) +vi.mock('@/lib/agent/composer/atom-selection', () => ({ + selectAtoms: vi.fn(), + filterRedundantQuestions: vi.fn(), +})) +vi.mock('@/lib/agent/composer/narrative', () => ({ writeNarrative: vi.fn() })) +vi.mock('@/lib/agent/composer/fallback', () => ({ + fallbackAtomSelection: vi.fn(), + fallbackNarrative: vi.fn(), +})) +vi.mock('@/lib/agent/composer/prewarm', () => ({ preWarmAtomCache: vi.fn() })) +vi.mock('@/lib/agent/composer/client', () => ({ OPUS_MODEL: 'opus-test' })) +vi.mock('@/lib/agent/composer/tic-fetch', () => ({ ensureTicSnapshot: vi.fn() })) + +vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn() })) + +import { createClient } from '@/lib/supabase/server' +import { POST } from '../onboarding/stream/route' + +const mockCreateClient = vi.mocked(createClient) + +function mockAuth(userId: string | null, membership: { role: string } | null) { + mockCreateClient.mockResolvedValue({ + auth: { + getUser: vi.fn().mockResolvedValue({ data: { user: userId ? { id: userId } : null } }), + }, + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ data: membership, error: null }), + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('POST /api/agent/onboarding/stream', () => { + it('returns 401 when not authenticated', async () => { + mockAuth(null, null) + const req = createMockRequest('/api/agent/onboarding/stream', { method: 'POST', body: {} }) + const res = await POST(req) + expect(res.status).toBe(401) + }) + + it('returns 403 for a non-member', async () => { + mockAuth('user-1', null) + const req = createMockRequest('/api/agent/onboarding/stream', { method: 'POST', body: {} }) + const { status } = await parseJsonResponse(await POST(req)) + expect(status).toBe(403) + }) + + it('refuses a viewer with 403 (pipeline upserts agent_profiles)', async () => { + mockAuth('user-1', { role: 'viewer' }) + const req = createMockRequest('/api/agent/onboarding/stream', { method: 'POST', body: {} }) + const { status, body } = await parseJsonResponse<{ error: string }>(await POST(req)) + expect(status).toBe(403) + expect(body.error).toContain('läsbehörighet') + }) +}) diff --git a/app/api/agent/__tests__/profile.test.ts b/app/api/agent/__tests__/profile.test.ts new file mode 100644 index 00000000..c457607e --- /dev/null +++ b/app/api/agent/__tests__/profile.test.ts @@ -0,0 +1,149 @@ +/** + * Tests for GET/PATCH /api/agent/profile and POST /api/agent/profile/verify. + * + * Covers the role model: reads allow any member, mutations (PATCH, verify) + * refuse viewers — the same rule verify always had, now enforced on PATCH too. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET, PATCH } from '../profile/route' +import { POST as VERIFY } from '../profile/verify/route' + +const routeParams = { params: Promise.resolve({}) } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +}) + +describe('GET /api/agent/profile', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await GET(createMockRequest('/api/agent/profile'), routeParams) + expect(res.status).toBe(401) + }) + + it('returns 403 when the caller is not a member of the target company', async () => { + enqueue({ data: null }) // membership lookup + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await GET(createMockRequest('/api/agent/profile'), routeParams) + ) + expect(status).toBe(403) + expect(body.error.code).toBe('NOT_COMPANY_MEMBER') + }) + + it('returns the profile for a member (viewers may read)', async () => { + enqueue({ data: { role: 'viewer' } }) + enqueue({ data: { company_id: 'company-1', profile_summary: 'Konsultbolag' } }) + + const { status, body } = await parseJsonResponse<{ data: { profile_summary: string } }>( + await GET(createMockRequest('/api/agent/profile'), routeParams) + ) + expect(status).toBe(200) + expect(body.data.profile_summary).toBe('Konsultbolag') + }) +}) + +describe('PATCH /api/agent/profile', () => { + it('refuses a viewer with 403 (profile mutation)', async () => { + enqueue({ data: { role: 'viewer' } }) + + const req = createMockRequest('/api/agent/profile', { + method: 'PATCH', + body: { profile_summary: 'Nytt sammandrag' }, + }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await PATCH(req, routeParams) + ) + expect(status).toBe(403) + expect(body.error.code).toBe('WRITE_PERMISSION_REQUIRED') + }) + + it('returns 404 when the company has no agent_profile row', async () => { + enqueue({ data: { role: 'admin' } }) + enqueue({ data: null }) // current profile lookup + + const req = createMockRequest('/api/agent/profile', { + method: 'PATCH', + body: { profile_summary: 'Nytt sammandrag' }, + }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await PATCH(req, routeParams) + ) + expect(status).toBe(404) + expect(body.error.code).toBe('AGENT_PROFILE_NOT_FOUND') + }) + + it('returns 400 when the body contains nothing to update', async () => { + enqueue({ data: { role: 'admin' } }) + enqueue({ data: { field_overrides: null } }) + + const req = createMockRequest('/api/agent/profile', { method: 'PATCH', body: {} }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await PATCH(req, routeParams) + ) + expect(status).toBe(400) + expect(body.error.code).toBe('NOTHING_TO_UPDATE') + }) + + it('merges field_overrides and updates the profile', async () => { + enqueue({ data: { role: 'admin' } }) + enqueue({ data: { field_overrides: { old: { value: 1, overridden_at: 'x' } } } }) + enqueue({ data: { company_id: 'company-1', profile_summary: 'Uppdaterad' } }) + + const req = createMockRequest('/api/agent/profile', { + method: 'PATCH', + body: { profile_summary: 'Uppdaterad', field_overrides: { vat_period: 'quarterly' } }, + }) + const { status, body } = await parseJsonResponse<{ data: { profile_summary: string } }>( + await PATCH(req, routeParams) + ) + expect(status).toBe(200) + expect(body.data.profile_summary).toBe('Uppdaterad') + }) +}) + +describe('POST /api/agent/profile/verify', () => { + it('refuses a viewer with 403', async () => { + enqueue({ data: { role: 'viewer' } }) + + const req = createMockRequest('/api/agent/profile/verify', { method: 'POST', body: {} }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await VERIFY(req, routeParams) + ) + expect(status).toBe(403) + expect(body.error.code).toBe('WRITE_PERMISSION_REQUIRED') + }) + + it('stamps verified_at for a non-viewer member', async () => { + enqueue({ data: { role: 'owner' } }) + enqueue({ data: { company_id: 'company-1', verified_at: '2026-07-03T00:00:00Z', verified_by_user_id: 'user-1' } }) + + const req = createMockRequest('/api/agent/profile/verify', { method: 'POST', body: {} }) + const { status, body } = await parseJsonResponse<{ data: { verified_by_user_id: string } }>( + await VERIFY(req, routeParams) + ) + expect(status).toBe(200) + expect(body.data.verified_by_user_id).toBe('user-1') + }) +}) diff --git a/app/api/agent/composer/route.ts b/app/api/agent/composer/route.ts index c8cb4240..ca0ab9fd 100644 --- a/app/api/agent/composer/route.ts +++ b/app/api/agent/composer/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' -import { getActiveCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent' import { composeAgentProfile } from '@/lib/agent/composer' import { guardSandbox } from '@/lib/sandbox/guard' @@ -24,13 +23,12 @@ const BodySchema = z.object({ // 4. Persists to agent_profiles (skipped on dry_run). // 5. Fires fire-and-forget cache pre-warm. // -// Auth: must be a member of the target company. +// Auth: must be a non-viewer member of the target company (it rewrites the +// company's agent_profile unless dry_run). // // Plan ref: dev_docs/specialized-agent-plan.md §6. -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext('agent.composer.run', async (request, ctx) => { + const { supabase, companyId: activeCompanyId, user } = ctx const rate = await checkAgentRateLimit(supabase, user.id) if (!rate.ok) { @@ -40,22 +38,29 @@ export async function POST(request: Request) { }) } - let body: z.infer - try { - body = BodySchema.parse(await request.json().catch(() => ({}))) - } catch (err) { + // Tolerant parse: ops callers POST with an empty body, which is valid here. + const raw = await request.json().catch(() => ({})) + const parsed = BodySchema.safeParse(raw) + if (!parsed.success) { return NextResponse.json( - { error: err instanceof Error ? err.message : 'Invalid body' }, + { + error: 'Validation failed', + type: 'validation_error', + errors: parsed.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + code: issue.code, + })), + }, { status: 400 }, ) } + const body = parsed.data - const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id)) - if (!companyId) { - return NextResponse.json({ error: 'No active company' }, { status: 400 }) - } + const companyId = body.company_id ?? activeCompanyId - // Defense in depth alongside RLS: confirm membership before composing. + // Defense in depth alongside RLS: confirm membership before composing, and + // require a non-viewer role: the composer rewrites agent_profiles. const { data: membership } = await supabase .from('company_members') .select('role') @@ -63,7 +68,28 @@ export async function POST(request: Request) { .eq('user_id', user.id) .maybeSingle() if (!membership) { - return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 }) + return NextResponse.json( + { + error: { + code: 'NOT_COMPANY_MEMBER', + message: 'Du är inte medlem i detta företag.', + message_en: 'Not a member of this company.', + }, + }, + { status: 403 }, + ) + } + if (membership.role === 'viewer') { + return NextResponse.json( + { + error: { + code: 'WRITE_PERMISSION_REQUIRED', + message: 'Du har endast läsbehörighet i detta företag.', + message_en: 'You only have read access in this company.', + }, + }, + { status: 403 }, + ) } const blocked = await guardSandbox(supabase, companyId) @@ -72,11 +98,6 @@ export async function POST(request: Request) { const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.ai) if (capBlocked) return capBlocked - try { - const composed = await composeAgentProfile(supabase, companyId, { dryRun: body.dry_run }) - return NextResponse.json({ data: composed }) - } catch (err) { - const message = err instanceof Error ? err.message : 'Composer failed' - return NextResponse.json({ error: message }, { status: 500 }) - } -} + const composed = await composeAgentProfile(supabase, companyId, { dryRun: body.dry_run }) + return NextResponse.json({ data: composed }) +}) diff --git a/app/api/agent/conversations/[id]/route.ts b/app/api/agent/conversations/[id]/route.ts index e6704bfd..322ca86c 100644 --- a/app/api/agent/conversations/[id]/route.ts +++ b/app/api/agent/conversations/[id]/route.ts @@ -1,6 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' // GET /api/agent/conversations/[id] // @@ -18,98 +19,108 @@ const PatchSchema = z.object({ title: z.string().min(1).max(200).nullable().optional(), }) -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +const notFound = () => + NextResponse.json( + { + error: { + code: 'CONVERSATION_NOT_FOUND', + message: 'Konversationen hittades inte.', + message_en: 'Conversation not found.', + }, + }, + { status: 404 }, + ) - const { id } = await params +export const GET = withRouteContext( + 'agent.conversations.get', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, user } = ctx - const { data: conv, error: convErr } = await supabase - .from('agent_conversations') - .select( - 'id, company_id, user_id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at', - ) - .eq('id', id) - .maybeSingle() - if (convErr) return NextResponse.json({ error: convErr.message }, { status: 500 }) - if (!conv) return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }) + // Conversations are user-scoped: fetched by ownership rather than the + // active company, so a user can open their own conversations in any + // company they belong to. + const { data: conv, error: convErr } = await supabase + .from('agent_conversations') + .select( + 'id, company_id, user_id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at', + ) + .eq('id', id) + .eq('user_id', user.id) + .maybeSingle() + if (convErr) throw convErr + if (!conv) return notFound() - // Defense in depth alongside RLS: verify caller is a member of the - // conversation's company AND owns the conversation row. Conversations are - // user-scoped within a company; one team member should not see another's. - if (conv.user_id !== user.id) { - return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }) - } - const { data: membership } = await supabase - .from('company_members') - .select('role') - .eq('company_id', conv.company_id) - .eq('user_id', user.id) - .maybeSingle() - if (!membership) { - return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }) - } + // Defense in depth alongside RLS: the caller must still be a member of + // the conversation's company (they may have been removed since). + const { data: membership } = await supabase + .from('company_members') + .select('role') + .eq('company_id', conv.company_id) + .eq('user_id', user.id) + .maybeSingle() + if (!membership) return notFound() - const { data: messages, error: msgErr } = await supabase - .from('agent_messages') - .select('id, role, content, tool_use_id, hidden, created_at') - .eq('conversation_id', id) - .order('created_at', { ascending: true }) - if (msgErr) return NextResponse.json({ error: msgErr.message }, { status: 500 }) + const { data: messages, error: msgErr } = await supabase + .from('agent_messages') + .select('id, role, content, tool_use_id, hidden, created_at') + .eq('conversation_id', id) + .order('created_at', { ascending: true }) + if (msgErr) throw msgErr - return NextResponse.json({ data: { conversation: conv, messages: messages ?? [] } }) -} + return NextResponse.json({ data: { conversation: conv, messages: messages ?? [] } }) + }, +) -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const PATCH = withRouteContext( + 'agent.conversations.update', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, user, log } = ctx - const { id } = await params - let body: z.infer - try { - body = PatchSchema.parse(await request.json()) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Invalid body' }, - { status: 400 }, - ) - } + const validation = await validateBody(request, PatchSchema, { + log, + operation: 'agent.conversations.update', + }) + if (!validation.success) return validation.response + const body = validation.data - const update: Record = {} - if (body.pinned != null) update.pinned = body.pinned - if (body.archived != null) update.archived = body.archived - if (body.title != null) update.title = body.title - if (Object.keys(update).length === 0) { - return NextResponse.json({ error: 'Nothing to update' }, { status: 400 }) - } + const update: Record = {} + if (body.pinned != null) update.pinned = body.pinned + if (body.archived != null) update.archived = body.archived + if (body.title != null) update.title = body.title + if (Object.keys(update).length === 0) { + return NextResponse.json( + { + error: { + code: 'NOTHING_TO_UPDATE', + message: 'Inget att uppdatera.', + message_en: 'Nothing to update.', + }, + }, + { status: 400 }, + ) + } - // Defense in depth: verify ownership before update so a 404 is returned - // (instead of relying solely on RLS, which would silently 0-row). - const { data: existing } = await supabase - .from('agent_conversations') - .select('user_id, company_id') - .eq('id', id) - .maybeSingle() - if (!existing || existing.user_id !== user.id) { - return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }) - } + // Defense in depth: verify ownership before update so a 404 is returned + // (instead of relying solely on RLS, which would silently 0-row). + const { data: existing } = await supabase + .from('agent_conversations') + .select('user_id, company_id') + .eq('id', id) + .eq('user_id', user.id) + .maybeSingle() + if (!existing) return notFound() - const { data, error } = await supabase - .from('agent_conversations') - .update(update) - .eq('id', id) - .eq('user_id', user.id) - .eq('company_id', existing.company_id) - .select('id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at') - .single() - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - return NextResponse.json({ data }) -} + const { data, error } = await supabase + .from('agent_conversations') + .update(update) + .eq('id', id) + .eq('user_id', user.id) + .eq('company_id', existing.company_id) + .select('id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at') + .single() + if (error) throw error + return NextResponse.json({ data }) + }, +) diff --git a/app/api/agent/conversations/route.ts b/app/api/agent/conversations/route.ts index 1c85bac3..9f8df63b 100644 --- a/app/api/agent/conversations/route.ts +++ b/app/api/agent/conversations/route.ts @@ -1,6 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { getActiveCompanyId } from '@/lib/company/context' +import { z } from 'zod' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateQuery } from '@/lib/api/validate' // GET /api/agent/conversations // @@ -16,20 +17,25 @@ import { getActiveCompanyId } from '@/lib/company/context' // // Ordered: pinned first (within archived bucket), then last_message_at desc. // Used by the /chat sidebar and "resume conversation" UI in the sheet. -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const companyId = await getActiveCompanyId(supabase, user.id) - if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 }) +const ListQuerySchema = z.object({ + archived: z.enum(['true', 'false']).default('false'), + pinned: z.enum(['true', 'false']).optional(), + intent: z.string().min(1).optional(), + q: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), +}) - const url = new URL(request.url) - const archived = url.searchParams.get('archived') === 'true' - const pinnedOnly = url.searchParams.get('pinned') === 'true' - const intent = url.searchParams.get('intent') ?? null - const q = url.searchParams.get('q')?.trim() ?? '' - const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 50, 1), 200) +export const GET = withRouteContext('agent.conversations.list', async (request, ctx) => { + const { supabase, companyId, user, log } = ctx + + const validated = validateQuery(request, ListQuerySchema, { + log, + operation: 'agent.conversations.list', + }) + if (!validated.success) return validated.response + const { archived, pinned, intent, limit } = validated.data + const q = validated.data.q?.trim() ?? '' let query = supabase .from('agent_conversations') @@ -37,9 +43,14 @@ export async function GET(request: Request) { 'id, intent_id, context_ref, title, pinned, archived, last_message_at, last_message_preview, created_at', ) .eq('company_id', companyId) - .eq('archived', archived) + // Conversations are user-scoped within a company — one member must not + // see another's (see [id]/route.ts). The RLS policy is company-scoped, + // so this filter is what actually prevents cross-member leakage of + // titles and last_message_preview snippets. + .eq('user_id', user.id) + .eq('archived', archived === 'true') - if (pinnedOnly) query = query.eq('pinned', true) + if (pinned === 'true') query = query.eq('pinned', true) if (intent) query = query.eq('intent_id', intent) if (q.length > 0) { // Pattern is sanitized via Postgres' percent-handling; ilike accepts the @@ -55,6 +66,6 @@ export async function GET(request: Request) { .limit(limit) const { data, error } = await query - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (error) throw error return NextResponse.json({ data: data ?? [] }) -} +}) diff --git a/app/api/agent/memory/[id]/route.ts b/app/api/agent/memory/[id]/route.ts index e5ebf90d..067fcbf8 100644 --- a/app/api/agent/memory/[id]/route.ts +++ b/app/api/agent/memory/[id]/route.ts @@ -1,7 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' // PATCH /api/agent/memory/[id] // @@ -30,61 +30,65 @@ const PatchSchema = z { message: 'Nothing to update' }, ) -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +const notFound = () => + NextResponse.json( + { + error: { + code: 'MEMORY_NOT_FOUND', + message: 'Minnet hittades inte.', + message_en: 'Memory not found.', + }, + }, + { status: 404 }, + ) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response +export const PATCH = withRouteContext( + 'agent.memory.update', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, user, log } = ctx - const { id } = await params + const validation = await validateBody(request, PatchSchema, { + log, + operation: 'agent.memory.update', + }) + if (!validation.success) return validation.response + const body = validation.data - let body: z.infer - try { - body = PatchSchema.parse(await request.json()) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Invalid body' }, - { status: 400 }, - ) - } + const update: Record = {} + if (body.content !== undefined) update.content = body.content + if (body.is_pinned !== undefined) update.is_pinned = body.is_pinned + if (body.is_active !== undefined) update.is_active = body.is_active - const update: Record = {} - if (body.content !== undefined) update.content = body.content - if (body.is_pinned !== undefined) update.is_pinned = body.is_pinned - if (body.is_active !== undefined) update.is_active = body.is_active + // Look up the row's company_id and re-check membership before mutating. + const { data: existing } = await supabase + .from('agent_memory') + .select('company_id') + .eq('id', id) + .maybeSingle() + if (!existing) return notFound() - // Look up the row's company_id and re-check membership before mutating. - const { data: existing } = await supabase - .from('agent_memory') - .select('company_id') - .eq('id', id) - .maybeSingle() - if (!existing) return NextResponse.json({ error: 'Memory not found' }, { status: 404 }) + const { data: membership } = await supabase + .from('company_members') + .select('role') + .eq('company_id', existing.company_id) + .eq('user_id', user.id) + .maybeSingle() + if (!membership) return notFound() - const { data: membership } = await supabase - .from('company_members') - .select('role') - .eq('company_id', existing.company_id) - .eq('user_id', user.id) - .maybeSingle() - if (!membership) return NextResponse.json({ error: 'Memory not found' }, { status: 404 }) + const { data, error } = await supabase + .from('agent_memory') + .update(update) + .eq('id', id) + .eq('company_id', existing.company_id) + .select( + 'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at', + ) + .maybeSingle() + if (error) throw error + if (!data) return notFound() - const { data, error } = await supabase - .from('agent_memory') - .update(update) - .eq('id', id) - .eq('company_id', existing.company_id) - .select( - 'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at', - ) - .maybeSingle() - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (!data) return NextResponse.json({ error: 'Memory not found' }, { status: 404 }) - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/agent/memory/__tests__/route.test.ts b/app/api/agent/memory/__tests__/route.test.ts index e6674fb5..31c351dc 100644 --- a/app/api/agent/memory/__tests__/route.test.ts +++ b/app/api/agent/memory/__tests__/route.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { describe('GET /api/agent/memory', () => { it('returns 401 when not authenticated', async () => { mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) - const response = await GET(createMockRequest('/api/agent/memory')) + const response = await GET(createMockRequest('/api/agent/memory'), createMockRouteParams({})) const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(401) expect(body.error).toBe('Unauthorized') @@ -45,7 +45,7 @@ describe('GET /api/agent/memory', () => { it('returns 400 when no active company', async () => { getActiveCompanyIdMock.mockResolvedValue(null) - const response = await GET(createMockRequest('/api/agent/memory')) + const response = await GET(createMockRequest('/api/agent/memory'), createMockRouteParams({})) const { status } = await parseJsonResponse(response) expect(status).toBe(400) }) @@ -67,7 +67,7 @@ describe('GET /api/agent/memory', () => { }, ] enqueue({ data: rows }) - const response = await GET(createMockRequest('/api/agent/memory')) + const response = await GET(createMockRequest('/api/agent/memory'), createMockRouteParams({})) const { status, body } = await parseJsonResponse<{ data: typeof rows }>(response) expect(status).toBe(200) expect(body.data).toHaveLength(1) @@ -76,7 +76,7 @@ describe('GET /api/agent/memory', () => { it('does not require write permission for read', async () => { enqueue({ data: [] }) - await GET(createMockRequest('/api/agent/memory')) + await GET(createMockRequest('/api/agent/memory'), createMockRouteParams({})) expect(requireWritePermissionMock).not.toHaveBeenCalled() }) }) @@ -89,6 +89,7 @@ describe('POST /api/agent/memory', () => { method: 'POST', body: { content: 'hello world' }, }), + createMockRouteParams({}), ) expect(response.status).toBe(401) }) @@ -104,6 +105,7 @@ describe('POST /api/agent/memory', () => { method: 'POST', body: { content: 'hello world' }, }), + createMockRouteParams({}), ) expect(response.status).toBe(403) }) @@ -114,6 +116,7 @@ describe('POST /api/agent/memory', () => { method: 'POST', body: { content: 'x' }, }), + createMockRouteParams({}), ) expect(response.status).toBe(400) }) @@ -142,6 +145,7 @@ describe('POST /api/agent/memory', () => { method: 'POST', body: { content: 'En sak att komma ihåg' }, }), + createMockRouteParams({}), ) const { status, body } = await parseJsonResponse<{ data: typeof inserted }>(response) expect(status).toBe(200) @@ -155,6 +159,7 @@ describe('POST /api/agent/memory', () => { method: 'POST', body: { content: 'En sak att komma ihåg' }, }), + createMockRouteParams({}), ) expect(response.status).toBe(403) }) diff --git a/app/api/agent/memory/route.ts b/app/api/agent/memory/route.ts index c53a6a96..841ce238 100644 --- a/app/api/agent/memory/route.ts +++ b/app/api/agent/memory/route.ts @@ -1,8 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' -import { getActiveCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody, validateQuery } from '@/lib/api/validate' // GET /api/agent/memory // @@ -27,6 +26,15 @@ import { requireWritePermission } from '@/lib/auth/require-write' const KIND = ['fact', 'preference', 'pattern', 'correction'] as const const SOURCE = ['composer', 'user_taught', 'agent_learned', 'derived'] as const +const MEMORY_COLUMNS = + 'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at' + +const ListQuerySchema = z.object({ + include_dismissed: z.enum(['true', 'false']).optional(), + kind: z.enum(KIND).optional(), + limit: z.coerce.number().int().min(1).max(200).default(200), +}) + const BodySchema = z.object({ company_id: z.string().uuid().optional(), content: z.string().min(2).max(2000), @@ -38,30 +46,22 @@ const BodySchema = z.object({ relevance_score: z.number().min(0).max(1).default(1.0), }) -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext('agent.memory.list', async (request, ctx) => { + const { supabase, companyId, log } = ctx - const companyId = await getActiveCompanyId(supabase, user.id) - if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 }) - - const url = new URL(request.url) - const includeDismissed = url.searchParams.get('include_dismissed') === 'true' - const kindParam = url.searchParams.get('kind') - const kind = KIND.includes(kindParam as (typeof KIND)[number]) - ? (kindParam as (typeof KIND)[number]) - : null - const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 200, 1), 200) + const validated = validateQuery(request, ListQuerySchema, { + log, + operation: 'agent.memory.list', + }) + if (!validated.success) return validated.response + const { include_dismissed, kind, limit } = validated.data let query = supabase .from('agent_memory') - .select( - 'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at', - ) + .select(MEMORY_COLUMNS) .eq('company_id', companyId) - if (!includeDismissed) query = query.eq('is_active', true) + if (include_dismissed !== 'true') query = query.eq('is_active', true) if (kind) query = query.eq('kind', kind) query = query @@ -73,64 +73,63 @@ export async function GET(request: Request) { .limit(limit) const { data, error } = await query - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (error) throw error return NextResponse.json({ data: data ?? [] }) -} +}) -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext( + 'agent.memory.create', + async (request, ctx) => { + const { supabase, companyId: activeCompanyId, user, log } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - let body: z.infer - try { - body = BodySchema.parse(await request.json()) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Invalid body' }, - { status: 400 }, - ) - } - - const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id)) - if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 }) - - // requireWritePermission above checks the *active* company's role; if the - // caller passes a different company_id in the body, re-check membership + - // non-viewer role for THAT company specifically. - const { data: bodyMembership } = await supabase - .from('company_members') - .select('role') - .eq('company_id', companyId) - .eq('user_id', user.id) - .maybeSingle() - if (!bodyMembership || bodyMembership.role === 'viewer') { - return NextResponse.json( - { error: 'Du har endast läsbehörighet i detta företag.' }, - { status: 403 }, - ) - } - - const { data, error } = await supabase - .from('agent_memory') - .insert({ - company_id: companyId, - kind: body.kind, - content: body.content, - source: body.source, - source_ref: body.source_ref ?? null, - relevance_score: body.relevance_score, - is_active: true, - created_by_user_id: user.id, + const validation = await validateBody(request, BodySchema, { + log, + operation: 'agent.memory.create', }) - .select( - 'id, kind, content, source, source_ref, relevance_score, is_pinned, is_active, last_accessed_at, created_at, updated_at', - ) - .single() - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (!validation.success) return validation.response + const body = validation.data - return NextResponse.json({ data }) -} + const companyId = body.company_id ?? activeCompanyId + + // requireWrite (wrapper option) checks the *active* company's role; if + // the caller passes a different company_id in the body, re-check + // membership + non-viewer role for THAT company specifically. + const { data: bodyMembership } = await supabase + .from('company_members') + .select('role') + .eq('company_id', companyId) + .eq('user_id', user.id) + .maybeSingle() + if (!bodyMembership || bodyMembership.role === 'viewer') { + return NextResponse.json( + { + error: { + code: 'WRITE_PERMISSION_REQUIRED', + message: 'Du har endast läsbehörighet i detta företag.', + message_en: 'You only have read access in this company.', + }, + }, + { status: 403 }, + ) + } + + const { data, error } = await supabase + .from('agent_memory') + .insert({ + company_id: companyId, + kind: body.kind, + content: body.content, + source: body.source, + source_ref: body.source_ref ?? null, + relevance_score: body.relevance_score, + is_active: true, + created_by_user_id: user.id, + }) + .select(MEMORY_COLUMNS) + .single() + if (error) throw error + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/agent/onboarding/stream/route.ts b/app/api/agent/onboarding/stream/route.ts index 61ea41d0..92d492dd 100644 --- a/app/api/agent/onboarding/stream/route.ts +++ b/app/api/agent/onboarding/stream/route.ts @@ -102,6 +102,14 @@ export async function POST(request: Request) { if (!membership) { return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 }) } + // The pipeline upserts agent_profiles — a mutation, so viewers are refused + // (same rule as /api/agent/profile and /verify). + if (membership.role === 'viewer') { + return NextResponse.json( + { error: 'Du har endast läsbehörighet i detta företag.' }, + { status: 403 }, + ) + } // No live composer run for sandbox companies: they ship with a pre-built // verified agent_profile so the chrome is visible without burning Bedrock. diff --git a/app/api/agent/profile/route.ts b/app/api/agent/profile/route.ts index f54e3232..a9f3e99e 100644 --- a/app/api/agent/profile/route.ts +++ b/app/api/agent/profile/route.ts @@ -1,7 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' -import { getActiveCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody, validateQuery } from '@/lib/api/validate' // GET /api/agent/profile?company_id=... // PATCH same path @@ -11,7 +11,8 @@ import { getActiveCompanyId } from '@/lib/company/context' // // PATCH updates field_overrides (timestamped, merged with existing) and // optionally rewrites the atom arrays from the review UI. Does not touch -// verified_at: that flows through /verify. +// verified_at: that flows through /verify. Requires a non-viewer role in +// the target company (same rule as /verify: it mutates the profile). const AtomArrays = z.object({ horizontal_atoms: z.array(z.string()).optional(), @@ -31,15 +32,38 @@ const PatchBody = z.object({ avatar_id: z.string().max(60).nullable().optional(), }) -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +const GetQuerySchema = z.object({ + company_id: z.string().uuid().optional(), +}) - const url = new URL(request.url) - const companyId = - url.searchParams.get('company_id') ?? (await getActiveCompanyId(supabase, user.id)) - if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 }) +const forbidden = (code: 'NOT_COMPANY_MEMBER' | 'WRITE_PERMISSION_REQUIRED') => + NextResponse.json( + { + error: + code === 'NOT_COMPANY_MEMBER' + ? { + code, + message: 'Du är inte medlem i detta företag.', + message_en: 'Not a member of this company.', + } + : { + code, + message: 'Du har endast läsbehörighet i detta företag.', + message_en: 'You only have read access in this company.', + }, + }, + { status: 403 }, + ) + +export const GET = withRouteContext('agent.profile.get', async (request, ctx) => { + const { supabase, companyId: activeCompanyId, user, log } = ctx + + const validated = validateQuery(request, GetQuerySchema, { + log, + operation: 'agent.profile.get', + }) + if (!validated.success) return validated.response + const companyId = validated.data.company_id ?? activeCompanyId // Defense in depth alongside RLS: confirm membership before reading. const { data: membership } = await supabase @@ -48,7 +72,7 @@ export async function GET(request: Request) { .eq('company_id', companyId) .eq('user_id', user.id) .maybeSingle() - if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + if (!membership) return forbidden('NOT_COMPANY_MEMBER') const { data, error } = await supabase .from('agent_profiles') @@ -57,38 +81,35 @@ export async function GET(request: Request) { ) .eq('company_id', companyId) .maybeSingle() - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (error) throw error if (!data) return NextResponse.json({ data: null }) return NextResponse.json({ data }) -} +}) -export async function PATCH(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const PATCH = withRouteContext('agent.profile.update', async (request, ctx) => { + const { supabase, companyId: activeCompanyId, user, log } = ctx - let body: z.infer - try { - body = PatchBody.parse(await request.json()) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Invalid body' }, - { status: 400 }, - ) - } + const validation = await validateBody(request, PatchBody, { + log, + operation: 'agent.profile.update', + }) + if (!validation.success) return validation.response + const body = validation.data - const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id)) - if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 }) + const companyId = body.company_id ?? activeCompanyId - // RLS guards reads/updates by company_id; defense in depth: confirm membership. + // RLS guards reads/updates by company_id; defense in depth: confirm + // membership AND a non-viewer role (this mutates the company's profile; + // same rule /verify already enforces). const { data: membership } = await supabase .from('company_members') .select('role') .eq('company_id', companyId) .eq('user_id', user.id) .maybeSingle() - if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + if (!membership) return forbidden('NOT_COMPANY_MEMBER') + if (membership.role === 'viewer') return forbidden('WRITE_PERMISSION_REQUIRED') // Load current overrides to merge timestamp-stamped entries. Avoids round-trip // when caller sends only an atom-array change. @@ -98,7 +119,16 @@ export async function PATCH(request: Request) { .eq('company_id', companyId) .single() if (!current) { - return NextResponse.json({ error: 'agent_profile not found for this company' }, { status: 404 }) + return NextResponse.json( + { + error: { + code: 'AGENT_PROFILE_NOT_FOUND', + message: 'Det finns ingen agentprofil för detta företag.', + message_en: 'agent_profile not found for this company.', + }, + }, + { status: 404 }, + ) } const update: Record = {} @@ -120,7 +150,16 @@ export async function PATCH(request: Request) { if (body.avatar_id !== undefined) update.avatar_id = body.avatar_id if (Object.keys(update).length === 0) { - return NextResponse.json({ error: 'Nothing to update' }, { status: 400 }) + return NextResponse.json( + { + error: { + code: 'NOTHING_TO_UPDATE', + message: 'Inget att uppdatera.', + message_en: 'Nothing to update.', + }, + }, + { status: 400 }, + ) } const { data, error } = await supabase @@ -131,7 +170,7 @@ export async function PATCH(request: Request) { 'company_id, horizontal_atoms, vertical_atoms, modifier_atoms, profile_summary, field_overrides', ) .single() - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (error) throw error return NextResponse.json({ data }) -} +}) diff --git a/app/api/agent/profile/verify/route.ts b/app/api/agent/profile/verify/route.ts index 07558af1..07b9faa4 100644 --- a/app/api/agent/profile/verify/route.ts +++ b/app/api/agent/profile/verify/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' -import { getActiveCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' // POST /api/agent/profile/verify // @@ -13,23 +12,29 @@ const BodySchema = z.object({ company_id: z.string().uuid().optional(), }) -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext('agent.profile.verify', async (request, ctx) => { + const { supabase, companyId: activeCompanyId, user } = ctx - let body: z.infer - try { - body = BodySchema.parse(await request.json().catch(() => ({}))) - } catch (err) { + // Tolerant parse: the review card POSTs with an empty body when verifying + // the active company. + const raw = await request.json().catch(() => ({})) + const parsed = BodySchema.safeParse(raw) + if (!parsed.success) { return NextResponse.json( - { error: err instanceof Error ? err.message : 'Invalid body' }, + { + error: 'Validation failed', + type: 'validation_error', + errors: parsed.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + code: issue.code, + })), + }, { status: 400 }, ) } - const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id)) - if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 }) + const companyId = parsed.data.company_id ?? activeCompanyId // Defense in depth alongside RLS: confirm membership for the target // company; a non-viewer role is required to stamp verified_at. @@ -41,7 +46,13 @@ export async function POST(request: Request) { .maybeSingle() if (!membership || membership.role === 'viewer') { return NextResponse.json( - { error: 'Du har endast läsbehörighet i detta företag.' }, + { + error: { + code: 'WRITE_PERMISSION_REQUIRED', + message: 'Du har endast läsbehörighet i detta företag.', + message_en: 'You only have read access in this company.', + }, + }, { status: 403 }, ) } @@ -55,7 +66,7 @@ export async function POST(request: Request) { .eq('company_id', companyId) .select('company_id, verified_at, verified_by_user_id') .single() - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (error) throw error return NextResponse.json({ data }) -} +}) diff --git a/app/api/agent/skills/route.ts b/app/api/agent/skills/route.ts index 524b99e7..406f3c6d 100644 --- a/app/api/agent/skills/route.ts +++ b/app/api/agent/skills/route.ts @@ -1,6 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { getActiveCompanyId } from '@/lib/company/context' +import { z } from 'zod' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateQuery } from '@/lib/api/validate' // GET /api/agent/skills // @@ -32,27 +33,41 @@ interface AtomMeta { active: boolean } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +const QuerySchema = z.object({ + slug: z.string().min(1).optional(), +}) - const companyId = await getActiveCompanyId(supabase, user.id) - if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 }) +export const GET = withRouteContext('agent.skills.list', async (request, ctx) => { + const { supabase, companyId, log } = ctx - const url = new URL(request.url) - const slug = url.searchParams.get('slug') + const validated = validateQuery(request, QuerySchema, { + log, + operation: 'agent.skills.list', + }) + if (!validated.success) return validated.response + const { slug } = validated.data // Detail: one atom's body, fetched lazily when the user expands a card. + // The atom registry is global product content (not tenant data), so no + // company filter applies here. if (slug) { const { data, error } = await supabase .from('agent_atom_registry') .select('id, title, body, is_active, mcp_exposed') .eq('id', slug) .maybeSingle() - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (error) throw error if (!data || !data.is_active || !data.mcp_exposed) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json( + { + error: { + code: 'SKILL_NOT_FOUND', + message: 'Kunskapen hittades inte.', + message_en: 'Skill not found.', + }, + }, + { status: 404 }, + ) } return NextResponse.json({ data: { id: data.id, title: data.title, body: data.body ?? '' } }) } @@ -66,7 +81,7 @@ export async function GET(request: Request) { .is('parent_atom_id', null) // show top-level skills only; reference children are internal .order('tier', { ascending: true }) .order('title', { ascending: true }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (error) throw error const { data: profile } = await supabase .from('agent_profiles') @@ -89,4 +104,4 @@ export async function GET(request: Request) { }) return NextResponse.json({ data: result }) -} +}) diff --git a/app/api/articles/route.ts b/app/api/articles/route.ts index 3ed29b99..951086aa 100644 --- a/app/api/articles/route.ts +++ b/app/api/articles/route.ts @@ -7,7 +7,8 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { ensureArticleNumber } from '@/lib/articles/ensure-article-number' import { checkRevenueAccount } from '@/lib/articles/validate-revenue-account' import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' -import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { fetchAllRows } from '@/lib/supabase/fetch-all' import type { Article } from '@/types' ensureInitialized() @@ -17,22 +18,25 @@ ensureInitialized() export const GET = withRouteContext( 'article.list', async (request, ctx) => { - const { supabase, companyId, log, requestId } = ctx + const { supabase, companyId } = ctx const includeInactive = new URL(request.url).searchParams.get('include_inactive') === '1' - let query = supabase - .from('articles') - .select('*') - .eq('company_id', companyId) - if (!includeInactive) query = query.eq('active', true) - - const { data, error } = await query.order('name', { ascending: true }) - - if (error) { - log.error('article list failed', error) - return errorResponse(error, log, { requestId }) - } + // Article registers can exceed PostgREST's silent 1000-row cap (imported + // product catalogs), so paginate. The secondary order on id gives the + // stable total order .range() paging requires — name alone is not unique. + // Errors thrown here surface via the wrapper's canonical envelope. + const data = await fetchAllRows(({ from, to }) => { + let query = supabase + .from('articles') + .select('*') + .eq('company_id', companyId) + if (!includeInactive) query = query.eq('active', true) + return query + .order('name', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to) + }) return NextResponse.json({ data }) }, diff --git a/app/api/assets/[id]/route.ts b/app/api/assets/[id]/route.ts index 6187cdfe..76086d10 100644 --- a/app/api/assets/[id]/route.ts +++ b/app/api/assets/[id]/route.ts @@ -51,8 +51,8 @@ const UpdateAssetSchema = z // K3 component depreciation. Accepting `null` lets the caller clear an // existing breakdown (the engine then falls back to depreciation_method). // Per-component validation runs whenever the field is set to a non-null - // value; the cross-sum check needs acquisition_cost so it's deferred to - // updateAsset() which can read the existing row. + // value; the cross-sum check needs the asset's acquisition_cost so it runs + // in the PATCH handler below, which can read the existing row. k3_components: z.array(K3ComponentSchema).nullable().optional(), }) .superRefine((value, ctx) => { @@ -136,8 +136,11 @@ export const PATCH = withRouteContext( if (!existing) { return NextResponse.json({ error: { code: 'ASSET_NOT_FOUND' } }, { status: 404 }) } + // Validate against the cost that will be in effect after this PATCH — + // a body that changes acquisition_cost and k3_components together must + // sum to the NEW cost, not the stored one. const { errors } = validateComponents({ - acquisition_cost: Number(existing.acquisition_cost), + acquisition_cost: validation.data.acquisition_cost ?? Number(existing.acquisition_cost), k3_components: validation.data.k3_components, }) if (errors.length > 0) { diff --git a/app/api/assets/__tests__/id.test.ts b/app/api/assets/__tests__/id.test.ts new file mode 100644 index 00000000..71550bb9 --- /dev/null +++ b/app/api/assets/__tests__/id.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for GET/PATCH /api/assets/[id]. + * + * Exercises the routes through the real withRouteContext wrapper, mocking the + * asset service and auth/company dependencies. The K3 component cross-sum + * validation runs the REAL validateComponents so the regression case (body + * changes acquisition_cost and k3_components together — sum must match the + * NEW cost) is covered end to end. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/bokslut/assets/asset-service', () => ({ + getAsset: vi.fn(), + updateAsset: vi.fn(), +})) + +import { getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service' +import { GET, PATCH } from '../[id]/route' + +const mockGetAsset = vi.mocked(getAsset) +const mockUpdateAsset = vi.mocked(updateAsset) +const routeParams = { params: Promise.resolve({ id: 'asset-1' }) } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +describe('GET /api/assets/[id]', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const res = await GET(createMockRequest('/api/assets/asset-1'), routeParams) + expect(res.status).toBe(401) + }) + + it('returns 404 when the asset does not exist', async () => { + mockGetAsset.mockResolvedValue(null) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await GET(createMockRequest('/api/assets/asset-1'), routeParams) + ) + + expect(status).toBe(404) + expect(body.error.code).toBe('ASSET_NOT_FOUND') + }) +}) + +describe('PATCH /api/assets/[id]', () => { + it('rejects an invalid body (non-positive acquisition_cost) with 400', async () => { + const req = createMockRequest('/api/assets/asset-1', { + method: 'PATCH', + body: { acquisition_cost: -5 }, + }) + + const { status } = await parseJsonResponse(await PATCH(req, routeParams)) + + expect(status).toBe(400) + expect(mockUpdateAsset).not.toHaveBeenCalled() + }) + + it('rejects k3_components for a K2 company with 422', async () => { + enqueue({ data: { accounting_framework: 'k2' } }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any) + + const req = createMockRequest('/api/assets/asset-1', { + method: 'PATCH', + body: { + k3_components: [{ name: 'Stomme', cost: 100000, useful_life_months: 600 }], + }, + }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await PATCH(req, routeParams) + ) + + expect(status).toBe(422) + expect(body.error.code).toBe('K3_REQUIRED_FOR_COMPONENTS') + expect(mockUpdateAsset).not.toHaveBeenCalled() + }) + + it('validates the component sum against the NEW acquisition_cost when both change', async () => { + // Regression: stored cost is 100 000 but the PATCH raises it to 120 000. + // Components summing to 120 000 must pass — previously they were checked + // against the stale stored cost and wrongly rejected. + enqueue({ data: { accounting_framework: 'k3' } }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockUpdateAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 120000 } as any) + + const req = createMockRequest('/api/assets/asset-1', { + method: 'PATCH', + body: { + acquisition_cost: 120000, + k3_components: [ + { name: 'Stomme', cost: 90000, useful_life_months: 600 }, + { name: 'Tak', cost: 30000, useful_life_months: 240 }, + ], + }, + }) + + const { status } = await parseJsonResponse(await PATCH(req, routeParams)) + + expect(status).toBe(200) + expect(mockUpdateAsset).toHaveBeenCalled() + }) + + it('rejects components that sum to the OLD cost when the PATCH changes the cost', async () => { + enqueue({ data: { accounting_framework: 'k3' } }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any) + + const req = createMockRequest('/api/assets/asset-1', { + method: 'PATCH', + body: { + acquisition_cost: 120000, + k3_components: [{ name: 'Stomme', cost: 100000, useful_life_months: 600 }], + }, + }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await PATCH(req, routeParams) + ) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVALID_K3_COMPONENTS') + expect(mockUpdateAsset).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/audit-trail/__tests__/route.test.ts b/app/api/audit-trail/__tests__/route.test.ts index 73ca69fb..b4a60cc4 100644 --- a/app/api/audit-trail/__tests__/route.test.ts +++ b/app/api/audit-trail/__tests__/route.test.ts @@ -1,52 +1,75 @@ +/** + * Tests for GET /api/audit-trail. + * + * Exercises the route through the real withRouteContext wrapper, mocking its + * auth/company dependencies and the audit service. Covers: auth 401, query + * validation 400, filter passthrough, and the canonical 500 envelope. + */ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse } from '@/tests/helpers' -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) vi.mock('@/lib/core/audit/audit-service', () => ({ getAuditLog: vi.fn(), })) -vi.mock('@/lib/company/context', () => ({ - requireCompanyId: vi.fn().mockResolvedValue('company-1'), - getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), -})) - -import { createClient } from '@/lib/supabase/server' import { getAuditLog } from '@/lib/core/audit/audit-service' import { GET } from '../route' -const mockCreateClient = vi.mocked(createClient) const mockGetAuditLog = vi.mocked(getAuditLog) - -function mockAuth(userId: string | null) { - mockCreateClient.mockResolvedValue({ - auth: { - getUser: vi.fn().mockResolvedValue({ - data: { user: userId ? { id: userId } : null }, - }), - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) -} +const routeParams = { params: Promise.resolve({}) } beforeEach(() => { vi.clearAllMocks() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null }) }) describe('GET /api/audit-trail', () => { it('returns 401 when not authenticated', async () => { - mockAuth(null) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const req = createMockRequest('/api/audit-trail') - const { status, body } = await parseJsonResponse(await GET(req)) + const { status, body } = await parseJsonResponse(await GET(req, routeParams)) + expect(status).toBe(401) expect(body).toEqual({ error: 'Unauthorized' }) }) - it('returns audit log with data and count', async () => { - mockAuth('user-1') + it('returns 400 for an unknown action filter', async () => { + const req = createMockRequest('/api/audit-trail', { + searchParams: { action: 'NOT_AN_ACTION' }, + }) + const { status } = await parseJsonResponse(await GET(req, routeParams)) + + expect(status).toBe(400) + expect(mockGetAuditLog).not.toHaveBeenCalled() + }) + + it('returns 400 for a non-numeric page', async () => { + const req = createMockRequest('/api/audit-trail', { + searchParams: { page: 'abc' }, + }) + const { status } = await parseJsonResponse(await GET(req, routeParams)) + + expect(status).toBe(400) + expect(mockGetAuditLog).not.toHaveBeenCalled() + }) + + it('returns audit log with data and count, defaulting pagination', async () => { const entries = [ { id: '1', action: 'INSERT', table_name: 'journal_entries', created_at: '2024-01-01T00:00:00Z' }, { id: '2', action: 'COMMIT', table_name: 'journal_entries', created_at: '2024-01-02T00:00:00Z' }, @@ -56,7 +79,9 @@ describe('GET /api/audit-trail', () => { const req = createMockRequest('/api/audit-trail') // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { status, body } = await parseJsonResponse<{ data: any[]; count: number }>(await GET(req)) + const { status, body } = await parseJsonResponse<{ data: any[]; count: number }>( + await GET(req, routeParams) + ) expect(status).toBe(200) expect(body.data).toHaveLength(2) @@ -64,12 +89,11 @@ describe('GET /api/audit-trail', () => { expect(mockGetAuditLog).toHaveBeenCalledWith( expect.anything(), 'company-1', - expect.objectContaining({}) + expect.objectContaining({ page: 1, pageSize: 50 }) ) }) it('passes query param filters to getAuditLog', async () => { - mockAuth('user-1') mockGetAuditLog.mockResolvedValue({ data: [], count: 0 }) const req = createMockRequest('/api/audit-trail', { @@ -84,7 +108,7 @@ describe('GET /api/audit-trail', () => { }, }) - await GET(req) + await GET(req, routeParams) expect(mockGetAuditLog).toHaveBeenCalledWith( expect.anything(), @@ -101,14 +125,15 @@ describe('GET /api/audit-trail', () => { ) }) - it('returns 500 on service error', async () => { - mockAuth('user-1') + it('returns the canonical error envelope on service failure', async () => { mockGetAuditLog.mockRejectedValue(new Error('DB error')) const req = createMockRequest('/api/audit-trail') - const { status, body } = await parseJsonResponse(await GET(req)) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await GET(req, routeParams) + ) expect(status).toBe(500) - expect(body).toEqual({ error: 'DB error' }) + expect(body.error.code).toBe('INTERNAL_ERROR') }) }) diff --git a/app/api/audit-trail/route.ts b/app/api/audit-trail/route.ts index 9548bc21..06084793 100644 --- a/app/api/audit-trail/route.ts +++ b/app/api/audit-trail/route.ts @@ -1,38 +1,30 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateQuery } from '@/lib/api/validate' +import { AuditTrailQuerySchema } from '@/lib/api/schemas' import { getAuditLog } from '@/lib/core/audit/audit-service' -import type { AuditAction } from '@/types' -import { requireCompanyId } from '@/lib/company/context' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +// GET /api/audit-trail — paginated audit log for the active company. +// The audit log is written exclusively by SECURITY DEFINER triggers; this +// endpoint is read-only. +export const GET = withRouteContext( + 'audit_trail.list', + async (request, ctx) => { + const { supabase, companyId, log } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const query = validateQuery(request, AuditTrailQuerySchema, { + log, + operation: 'audit_trail.list', + }) + if (!query.success) return query.response + const { page, page_size, ...filters } = query.data - const companyId = await requireCompanyId(supabase, user.id) + const result = await getAuditLog(supabase, companyId, { + ...filters, + page, + pageSize: page_size, + }) - const { searchParams } = new URL(request.url) - - const filters = { - action: (searchParams.get('action') as AuditAction) || undefined, - table_name: searchParams.get('table_name') || undefined, - record_id: searchParams.get('record_id') || undefined, - from_date: searchParams.get('from_date') || undefined, - to_date: searchParams.get('to_date') || undefined, - page: searchParams.has('page') ? Number(searchParams.get('page')) : undefined, - pageSize: searchParams.has('page_size') ? Number(searchParams.get('page_size')) : undefined, - } - - try { - const result = await getAuditLog(supabase, companyId, filters) return NextResponse.json({ data: result.data, count: result.count }) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to fetch audit log' }, - { status: 500 } - ) - } -} + }, +) diff --git a/app/api/billing/__tests__/checkout.test.ts b/app/api/billing/__tests__/checkout.test.ts new file mode 100644 index 00000000..f0cdfbcc --- /dev/null +++ b/app/api/billing/__tests__/checkout.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for POST /api/billing/checkout. + * + * Exercises the route through the real withRouteContext wrapper, mocking + * auth/company, the Stripe client, and the service-role Supabase client. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: () => serviceSupabase, +})) + +const customersCreate = vi.fn() +const sessionsCreate = vi.fn() +vi.mock('@/lib/stripe/client', () => ({ + getStripe: () => ({ + customers: { create: customersCreate }, + checkout: { sessions: { create: sessionsCreate } }, + }), + priceIdForPlan: vi.fn().mockReturnValue('price_123'), +})) + +import { POST } from '../checkout/route' + +const routeParams = { params: Promise.resolve({}) } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1', email: 'u@example.com' }, + supabase: {}, + error: null, + }) +}) + +describe('POST /api/billing/checkout', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} }) + const res = await POST(req, routeParams) + expect(res.status).toBe(401) + }) + + it('rejects an unknown plan with 400', async () => { + const req = createMockRequest('/api/billing/checkout', { + method: 'POST', + body: { plan: 'weekly' }, + }) + + const { status } = await parseJsonResponse(await POST(req, routeParams)) + + expect(status).toBe(400) + expect(sessionsCreate).not.toHaveBeenCalled() + }) + + it('reuses an existing Stripe customer and returns the checkout URL', async () => { + enqueue({ data: { stripe_customer_id: 'cus_existing' } }) + sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' }) + + const req = createMockRequest('/api/billing/checkout', { + method: 'POST', + body: { plan: 'yearly' }, + }) + + const { status, body } = await parseJsonResponse<{ url: string }>(await POST(req, routeParams)) + + expect(status).toBe(200) + expect(body.url).toBe('https://stripe.test/session') + expect(customersCreate).not.toHaveBeenCalled() + expect(sessionsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + customer: 'cus_existing', + client_reference_id: 'company-1', + }) + ) + }) + + it('creates a Stripe customer when none exists yet', async () => { + enqueue({ data: null }) // no existing subscription row + enqueue({ data: null }) // upsert result + customersCreate.mockResolvedValue({ id: 'cus_new' }) + sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' }) + + const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} }) + const { status, body } = await parseJsonResponse<{ url: string }>(await POST(req, routeParams)) + + expect(status).toBe(200) + expect(body.url).toBe('https://stripe.test/session') + expect(customersCreate).toHaveBeenCalledWith( + expect.objectContaining({ metadata: { company_id: 'company-1' } }) + ) + expect(sessionsCreate).toHaveBeenCalledWith( + expect.objectContaining({ customer: 'cus_new' }) + ) + }) +}) diff --git a/app/api/billing/__tests__/portal.test.ts b/app/api/billing/__tests__/portal.test.ts new file mode 100644 index 00000000..4ed9678e --- /dev/null +++ b/app/api/billing/__tests__/portal.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for POST /api/billing/portal. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: () => serviceSupabase, +})) + +const portalCreate = vi.fn() +vi.mock('@/lib/stripe/client', () => ({ + getStripe: () => ({ + billingPortal: { sessions: { create: portalCreate } }, + }), +})) + +import { POST } from '../portal/route' + +const routeParams = { params: Promise.resolve({}) } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null }) +}) + +describe('POST /api/billing/portal', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const req = createMockRequest('/api/billing/portal', { method: 'POST', body: {} }) + const res = await POST(req, routeParams) + expect(res.status).toBe(401) + }) + + it('returns 400 with NO_SUBSCRIPTION when the company has no Stripe customer', async () => { + enqueue({ data: null }) + + const req = createMockRequest('/api/billing/portal', { method: 'POST', body: {} }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await POST(req, routeParams) + ) + + expect(status).toBe(400) + expect(body.error.code).toBe('NO_SUBSCRIPTION') + expect(portalCreate).not.toHaveBeenCalled() + }) + + it('returns the portal URL for a company with a Stripe customer', async () => { + enqueue({ data: { stripe_customer_id: 'cus_1' } }) + portalCreate.mockResolvedValue({ url: 'https://stripe.test/portal' }) + + const req = createMockRequest('/api/billing/portal', { method: 'POST', body: {} }) + const { status, body } = await parseJsonResponse<{ url: string }>(await POST(req, routeParams)) + + expect(status).toBe(200) + expect(body.url).toBe('https://stripe.test/portal') + expect(portalCreate).toHaveBeenCalledWith( + expect.objectContaining({ customer: 'cus_1' }) + ) + }) +}) diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts index 43ee4725..a9009470 100644 --- a/app/api/billing/checkout/route.ts +++ b/app/api/billing/checkout/route.ts @@ -1,30 +1,45 @@ import { NextResponse } from 'next/server' -import { requireAuth } from '@/lib/auth/require-auth' -import { requireCompanyId } from '@/lib/company/context' +import { z } from 'zod' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' import { createServiceClient } from '@/lib/supabase/server' -import { getStripe, priceIdForPlan, type BillingPlan } from '@/lib/stripe/client' +import { getStripe, priceIdForPlan } from '@/lib/stripe/client' + +const CheckoutSchema = z.object({ + plan: z.enum(['monthly', 'yearly']).default('monthly'), +}) /** * Create a Stripe subscription Checkout Session and return its hosted URL. * The client redirects to it; provisioning happens via the webhook on * checkout.session.completed (never trust the success redirect for fulfilment). + * + * company_subscriptions is read/written via the service client on purpose — + * the row is webhook-owned and not member-readable under RLS; every query + * still filters by the membership-validated companyId. */ -export async function POST(request: Request) { - const { user, supabase, error } = await requireAuth() - if (error) return error +export const POST = withRouteContext('billing.checkout', async (request, ctx) => { + const { user, companyId, log } = ctx - let companyId: string - try { - companyId = await requireCompanyId(supabase, user.id) - } catch { - return NextResponse.json({ error: 'No company context' }, { status: 400 }) - } + const validation = await validateBody(request, CheckoutSchema, { + log, + operation: 'billing.checkout', + }) + if (!validation.success) return validation.response + const { plan } = validation.data - const body = (await request.json().catch(() => ({}))) as { plan?: string } - const plan: BillingPlan = body.plan === 'yearly' ? 'yearly' : 'monthly' const priceId = priceIdForPlan(plan) if (!priceId) { - return NextResponse.json({ error: 'Stripe price not configured' }, { status: 500 }) + return NextResponse.json( + { + error: { + code: 'STRIPE_NOT_CONFIGURED', + message: 'Betalning är inte konfigurerad. Kontakta supporten.', + message_en: 'Stripe price not configured.', + }, + }, + { status: 500 }, + ) } const stripe = getStripe() @@ -63,4 +78,4 @@ export async function POST(request: Request) { }) return NextResponse.json({ url: session.url }) -} +}) diff --git a/app/api/billing/portal/route.ts b/app/api/billing/portal/route.ts index f95d427c..b3914220 100644 --- a/app/api/billing/portal/route.ts +++ b/app/api/billing/portal/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server' -import { requireAuth } from '@/lib/auth/require-auth' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { createServiceClient } from '@/lib/supabase/server' import { getStripe } from '@/lib/stripe/client' @@ -8,17 +7,13 @@ import { getStripe } from '@/lib/stripe/client' * Create a Stripe Billing Customer Portal session so the user can manage, * upgrade/downgrade, or cancel their subscription. Stripe handles all the * compliance/PCI surface: we never build those flows ourselves. + * + * company_subscriptions is read via the service client on purpose: the row + * is webhook-owned and not member-readable under RLS; the query still filters + * by the membership-validated companyId. */ -export async function POST() { - const { user, supabase, error } = await requireAuth() - if (error) return error - - let companyId: string - try { - companyId = await requireCompanyId(supabase, user.id) - } catch { - return NextResponse.json({ error: 'No company context' }, { status: 400 }) - } +export const POST = withRouteContext('billing.portal', async (_request, ctx) => { + const { companyId } = ctx const service = createServiceClient() const { data: sub } = await service @@ -29,7 +24,16 @@ export async function POST() { const customerId = (sub as { stripe_customer_id: string | null } | null)?.stripe_customer_id if (!customerId) { - return NextResponse.json({ error: 'No subscription to manage' }, { status: 400 }) + return NextResponse.json( + { + error: { + code: 'NO_SUBSCRIPTION', + message: 'Det finns inget abonnemang att hantera.', + message_en: 'No subscription to manage.', + }, + }, + { status: 400 }, + ) } const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '' @@ -39,4 +43,4 @@ export async function POST() { }) return NextResponse.json({ url: portal.url }) -} +}) diff --git a/app/api/bookkeeping/account-balances/route.ts b/app/api/bookkeeping/account-balances/route.ts index a237f658..fba4bfd1 100644 --- a/app/api/bookkeeping/account-balances/route.ts +++ b/app/api/bookkeeping/account-balances/route.ts @@ -1,13 +1,9 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateQuery } from '@/lib/api/validate' import { AccountBalancesQuerySchema } from '@/lib/api/schemas' import { getOpeningBalances } from '@/lib/reports/opening-balances' import { fetchAllRows } from '@/lib/supabase/fetch-all' -import { createLogger } from '@/lib/logger' - -const log = createLogger('api.bookkeeping.account-balances') /** * Per-account saldo as of a date. Used by the journal-entry form to show @@ -24,20 +20,16 @@ const log = createLogger('api.bookkeeping.account-balances') * companies behave identically. The opening-balance entry is excluded from * period activity to avoid double-counting its lines. */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext('bookkeeping.account_balances', async (request, ctx) => { + const { supabase, companyId, log } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const params = validateQuery(request, AccountBalancesQuerySchema) + const params = validateQuery(request, AccountBalancesQuerySchema, { + log, + operation: 'bookkeeping.account_balances', + }) if (!params.success) return params.response const { accounts, as_of } = params.data - const companyId = await requireCompanyId(supabase, user.id) - // Find the fiscal period containing as_of (any state: we want a reference // saldo even for closed/locked periods). const { data: period, error: periodError } = await supabase @@ -159,4 +151,4 @@ export async function GET(request: Request) { } }), }) -} +}) diff --git a/app/api/bookkeeping/account-totals/route.ts b/app/api/bookkeeping/account-totals/route.ts index 3540ea4a..bbcd42dd 100644 --- a/app/api/bookkeeping/account-totals/route.ts +++ b/app/api/bookkeeping/account-totals/route.ts @@ -1,59 +1,57 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { z } from 'zod' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateQuery } from '@/lib/api/validate' +import { fetchAllRows } from '@/lib/supabase/fetch-all' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +// GET /api/bookkeeping/account-totals?from=3000&to=3999[&date_from=..&date_to=..&group_by=month] +// +// Sums posted debit/credit per account in an account-number range, optionally +// bucketed by month. Both the entry list and the per-batch line fetches are +// paginated — PostgREST caps unpaginated selects at 1000 rows, which would +// silently under-count totals for companies with large journals. - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +const QuerySchema = z.object({ + from: z.string().regex(/^\d{4}$/, 'from must be a 4-digit account number'), + to: z.string().regex(/^\d{4}$/, 'to must be a 4-digit account number'), + date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + group_by: z.enum(['month']).optional(), +}) - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext('bookkeeping.account_totals', async (request, ctx) => { + const { supabase, companyId, log } = ctx - const { searchParams } = new URL(request.url) - const from = searchParams.get('from') - const to = searchParams.get('to') - const dateFrom = searchParams.get('date_from') - const dateTo = searchParams.get('date_to') - const groupBy = searchParams.get('group_by') + const validated = validateQuery(request, QuerySchema, { + log, + operation: 'bookkeeping.account_totals', + }) + if (!validated.success) return validated.response + const { from, to, date_from: dateFrom, date_to: dateTo, group_by: groupBy } = validated.data - if (!from || !to) { - return NextResponse.json( - { error: 'from and to account numbers are required' }, - { status: 400 } - ) - } + // Posted entries in range — paginated (large journals exceed 1000 entries). + const entries = await fetchAllRows<{ id: string; entry_date: string }>(({ from: f, to: t }) => { + let query = supabase + .from('journal_entries') + .select('id, entry_date') + .eq('company_id', companyId) + .eq('status', 'posted') - // Get posted journal entries within date range - let entriesQuery = supabase - .from('journal_entries') - .select('id, entry_date') - .eq('company_id', companyId) - .eq('status', 'posted') + if (dateFrom) query = query.gte('entry_date', dateFrom) + if (dateTo) query = query.lte('entry_date', dateTo) - if (dateFrom) { - entriesQuery = entriesQuery.gte('entry_date', dateFrom) - } - if (dateTo) { - entriesQuery = entriesQuery.lte('entry_date', dateTo) - } + return query.order('id', { ascending: true }).range(f, t) + }) - const { data: entries, error: entriesError } = await entriesQuery - - if (entriesError) { - return NextResponse.json({ error: entriesError.message }, { status: 500 }) - } - - if (!entries || entries.length === 0) { + if (entries.length === 0) { return NextResponse.json({ totals: [], monthly: groupBy === 'month' ? [] : undefined }) } const entryIds = entries.map((e) => e.id) const entryDateMap = new Map(entries.map((e) => [e.id, e.entry_date])) - // Fetch lines in batches to avoid URL length limits + // Fetch lines in id-batches to avoid URL length limits; each batch is + // itself paginated (200 entries can easily carry >1000 lines). const batchSize = 200 const allLines: Array<{ journal_entry_id: string @@ -64,19 +62,22 @@ export async function GET(request: Request) { for (let i = 0; i < entryIds.length; i += batchSize) { const batch = entryIds.slice(i, i + batchSize) - const { data: lines, error: linesError } = await supabase - .from('journal_entry_lines') - .select('journal_entry_id, account_number, debit_amount, credit_amount') - .in('journal_entry_id', batch) - .gte('account_number', from) - .lte('account_number', to) - - if (linesError) { - return NextResponse.json({ error: linesError.message }, { status: 500 }) - } - if (lines) { - allLines.push(...lines) - } + const lines = await fetchAllRows<{ + journal_entry_id: string + account_number: string + debit_amount: number + credit_amount: number + }>(({ from: f, to: t }) => + supabase + .from('journal_entry_lines') + .select('journal_entry_id, account_number, debit_amount, credit_amount') + .in('journal_entry_id', batch) + .gte('account_number', from) + .lte('account_number', to) + .order('id', { ascending: true }) + .range(f, t) + ) + allLines.push(...lines) } // Aggregate by account @@ -132,4 +133,4 @@ export async function GET(request: Request) { } return NextResponse.json({ totals }) -} +}) diff --git a/app/api/bookkeeping/accounts/[number]/route.ts b/app/api/bookkeeping/accounts/[number]/route.ts index e3114e6b..d6b38446 100644 --- a/app/api/bookkeeping/accounts/[number]/route.ts +++ b/app/api/bookkeeping/accounts/[number]/route.ts @@ -1,104 +1,104 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { UpdateAccountSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -export async function DELETE( - request: Request, - { params }: { params: Promise<{ number: string }> } -) { - const { number } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +// DELETE hard-deletes an unused, non-system account; accounts referenced by +// this company's journal entries must be deactivated instead (PUT is_active). +// Response shapes are legacy `{ error: string }` — the kontoplan UI renders +// them directly. - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const DELETE = withRouteContext( + 'bookkeeping.accounts.delete', + async (_request, ctx, { params }: { params: Promise<{ number: string }> }) => { + const { number } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Fetch the account to check if it's a system account + const { data: account, error: fetchError } = await supabase + .from('chart_of_accounts') + .select('id, is_system_account') + .eq('company_id', companyId) + .eq('account_number', number) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (fetchError || !account) { + return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 }) + } - // Fetch the account to check if it's a system account - const { data: account, error: fetchError } = await supabase - .from('chart_of_accounts') - .select('id, is_system_account') - .eq('company_id', companyId) - .eq('account_number', number) - .single() + if (account.is_system_account) { + return NextResponse.json( + { error: 'Systemkonton kan inte tas bort' }, + { status: 400 } + ) + } - if (fetchError || !account) { - return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 }) - } + // Check if the account is referenced in THIS company's journal entries. + // journal_entry_lines has no company_id column, so scope via the parent + // entry — a user can be a member of several companies, and another + // company's usage of the same BAS number must not block deletion here. + const { count } = await supabase + .from('journal_entry_lines') + .select('id, journal_entries!inner(company_id)', { count: 'exact', head: true }) + .eq('journal_entries.company_id', companyId) + .eq('account_number', number) - if (account.is_system_account) { - return NextResponse.json( - { error: 'Systemkonton kan inte tas bort' }, - { status: 400 } - ) - } + if (count && count > 0) { + return NextResponse.json( + { error: 'Kontot kan inte tas bort eftersom det används i bokförda verifikationer. Inaktivera det istället.' }, + { status: 400 } + ) + } - // Check if account is referenced in posted journal entries - const { count } = await supabase - .from('journal_entry_lines') - .select('id', { count: 'exact', head: true }) - .eq('account_number', number) + const { error: deleteError } = await supabase + .from('chart_of_accounts') + .delete() + .eq('id', account.id) + .eq('company_id', companyId) - if (count && count > 0) { - return NextResponse.json( - { error: 'Kontot kan inte tas bort eftersom det används i bokförda verifikationer. Inaktivera det istället.' }, - { status: 400 } - ) - } + if (deleteError) { + return NextResponse.json({ error: deleteError.message }, { status: 500 }) + } - const { error: deleteError } = await supabase - .from('chart_of_accounts') - .delete() - .eq('id', account.id) - .eq('company_id', companyId) + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) - if (deleteError) { - return NextResponse.json({ error: deleteError.message }, { status: 500 }) - } +export const PUT = withRouteContext( + 'bookkeeping.accounts.update', + async (request, ctx, { params }: { params: Promise<{ number: string }> }) => { + const { number } = await params + const { supabase, companyId, log } = ctx - return NextResponse.json({ success: true }) -} + const validation = await validateBody(request, UpdateAccountSchema, { + log, + operation: 'bookkeeping.accounts.update', + }) + if (!validation.success) return validation.response + const body = validation.data -export async function PUT( - request: Request, - { params }: { params: Promise<{ number: string }> } -) { - const { number } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + if (Object.keys(body).length === 0) { + return NextResponse.json({ error: 'Inget att uppdatera' }, { status: 400 }) + } - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data, error } = await supabase + .from('chart_of_accounts') + .update(body) + .eq('company_id', companyId) + .eq('account_number', number) + .select() + .single() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (error) { + // PGRST116 = zero rows — the account doesn't exist in this company. + if (error.code === 'PGRST116') { + return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 }) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, UpdateAccountSchema) - if (!validation.success) return validation.response - const body = validation.data - - const { data, error } = await supabase - .from('chart_of_accounts') - .update(body) - .eq('company_id', companyId) - .eq('account_number', number) - .select() - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/accounts/__tests__/accounts.test.ts b/app/api/bookkeeping/accounts/__tests__/accounts.test.ts new file mode 100644 index 00000000..9e8117fc --- /dev/null +++ b/app/api/bookkeeping/accounts/__tests__/accounts.test.ts @@ -0,0 +1,269 @@ +/** + * Tests for /api/bookkeeping/accounts (list/create), /[number] (update/delete) + * and /activate. + * + * The DELETE usage check is asserted with a call-capturing mock: the count + * query must be scoped to the caller's company via the journal_entries join — + * without it, another company's use of the same BAS number (same user, + * multiple memberships under RLS) wrongly blocks deletion. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET as listGET, POST as createPOST } from '../route' +import { DELETE, PUT } from '../[number]/route' +import { POST as activatePOST } from '../activate/route' + +interface CapturedCall { + method: string + args: unknown[] +} + +/** Chainable builder recording calls; resolves queued {data,error,count} per from(). */ +function createCapturingSupabase( + results: { data?: unknown; error?: unknown; count?: number | null }[] +) { + const calls: CapturedCall[] = [] + let idx = 0 + const makeBuilder = () => { + const result = results[idx++] ?? { data: null, error: null, count: null } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b: any = {} + for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'is', 'order', 'limit', 'range', 'insert', 'update', 'delete', 'maybeSingle', 'single']) { + b[m] = (...args: unknown[]) => { + calls.push({ method: m, args }) + return b + } + } + b.then = (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null, count: result.count ?? null }) + return b + } + const supabase = { + from: (table: string) => { + calls.push({ method: 'from', args: [table] }) + return makeBuilder() + }, + } + return { supabase, calls } +} + +const routeParams = { params: Promise.resolve({}) } +const numberParams = { params: Promise.resolve({ number: '5010' }) } + +beforeEach(() => { + vi.clearAllMocks() + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +function auth(supabase: unknown) { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +describe('GET /api/bookkeeping/accounts', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams) + expect(res.status).toBe(401) + }) + + it('returns 400 for a non-numeric class filter', async () => { + const { supabase } = createCapturingSupabase([]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts', { searchParams: { class: 'abc' } }) + const { status } = await parseJsonResponse(await listGET(req, routeParams)) + expect(status).toBe(400) + }) + + it('lists accounts for the company', async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: [{ account_number: '1930', account_name: 'Företagskonto' }] }, + ]) + auth(supabase) + const { status, body } = await parseJsonResponse<{ data: unknown[] }>( + await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams) + ) + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + expect(calls.filter((c) => c.method === 'eq').map((c) => c.args)).toContainEqual([ + 'company_id', + 'company-1', + ]) + }) +}) + +describe('POST /api/bookkeeping/accounts', () => { + it('returns 409 with a Swedish message on duplicate account number', async () => { + const { supabase } = createCapturingSupabase([{ error: { code: '23505', message: 'dup' } }]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts', { + method: 'POST', + body: { + account_number: '5010', + account_name: 'Lokalhyra', + account_type: 'expense', + normal_balance: 'debit', + }, + }) + const { status, body } = await parseJsonResponse<{ error: string }>( + await createPOST(req, routeParams) + ) + expect(status).toBe(409) + expect(body.error).toContain('5010') + }) +}) + +describe('DELETE /api/bookkeeping/accounts/[number]', () => { + it('scopes the usage check to the company via the journal_entries join', async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: { id: 'acc-1', is_system_account: false } }, // account fetch + { count: 0 }, // usage count + { data: null }, // delete + ]) + auth(supabase) + + const { status } = await parseJsonResponse( + await DELETE(createMockRequest('/api/bookkeeping/accounts/5010'), numberParams) + ) + + expect(status).toBe(200) + const selectArgs = calls.filter((c) => c.method === 'select').map((c) => c.args[0]) + expect(selectArgs).toContain('id, journal_entries!inner(company_id)') + const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args) + expect(eqCalls).toContainEqual(['journal_entries.company_id', 'company-1']) + }) + + it('refuses deleting an account used in this company with 400', async () => { + const { supabase } = createCapturingSupabase([ + { data: { id: 'acc-1', is_system_account: false } }, + { count: 3 }, + ]) + auth(supabase) + + const { status, body } = await parseJsonResponse<{ error: string }>( + await DELETE(createMockRequest('/api/bookkeeping/accounts/5010'), numberParams) + ) + expect(status).toBe(400) + expect(body.error).toContain('Inaktivera') + }) + + it('refuses deleting a system account', async () => { + const { supabase } = createCapturingSupabase([ + { data: { id: 'acc-1', is_system_account: true } }, + ]) + auth(supabase) + + const { status } = await parseJsonResponse( + await DELETE(createMockRequest('/api/bookkeeping/accounts/5010'), numberParams) + ) + expect(status).toBe(400) + }) +}) + +describe('PUT /api/bookkeeping/accounts/[number]', () => { + it('returns 400 when the body has nothing to update', async () => { + const { supabase } = createCapturingSupabase([]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts/5010', { method: 'PUT', body: {} }) + const { status } = await parseJsonResponse(await PUT(req, numberParams)) + expect(status).toBe(400) + }) + + it('maps zero-rows (PGRST116) to 404', async () => { + const { supabase } = createCapturingSupabase([ + { error: { code: 'PGRST116', message: 'no rows' } }, + ]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts/5010', { + method: 'PUT', + body: { account_name: 'Nytt namn' }, + }) + const { status, body } = await parseJsonResponse<{ error: string }>(await PUT(req, numberParams)) + expect(status).toBe(404) + expect(body.error).toBe('Kontot hittades inte') + }) + + it('updates the account', async () => { + const { supabase } = createCapturingSupabase([ + { data: { account_number: '5010', account_name: 'Nytt namn' } }, + ]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts/5010', { + method: 'PUT', + body: { account_name: 'Nytt namn' }, + }) + const { status, body } = await parseJsonResponse<{ data: { account_name: string } }>( + await PUT(req, numberParams) + ) + expect(status).toBe(200) + expect(body.data.account_name).toBe('Nytt namn') + }) +}) + +describe('POST /api/bookkeeping/accounts/activate', () => { + it('returns 400 (not a crash) on invalid JSON', async () => { + const { supabase } = createCapturingSupabase([]) + auth(supabase) + const req = new Request('http://localhost/api/bookkeeping/accounts/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{not json', + }) + const { status, body } = await parseJsonResponse<{ error: string }>( + await activatePOST(req, routeParams) + ) + expect(status).toBe(400) + expect(body.error).toBe('account_numbers array required') + }) + + it('returns 400 when account_numbers is missing or empty', async () => { + const { supabase } = createCapturingSupabase([]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts/activate', { + method: 'POST', + body: { account_numbers: [] }, + }) + const { status } = await parseJsonResponse(await activatePOST(req, routeParams)) + expect(status).toBe(400) + }) + + it('activates a known BAS account and buckets unknown numbers', async () => { + const { supabase } = createCapturingSupabase([ + { data: [] }, // existing lookup — none in chart + { data: [{ account_number: '1930' }] }, // insert result + ]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts/activate', { + method: 'POST', + body: { account_numbers: ['1930', '0000'] }, + }) + const { status, body } = await parseJsonResponse<{ + activated: number + unknown: string[] + }>(await activatePOST(req, routeParams)) + + expect(status).toBe(200) + expect(body.activated).toBe(1) + expect(body.unknown).toEqual(['0000']) + }) +}) diff --git a/app/api/bookkeeping/accounts/__tests__/prune.test.ts b/app/api/bookkeeping/accounts/__tests__/prune.test.ts new file mode 100644 index 00000000..77e3f5f0 --- /dev/null +++ b/app/api/bookkeeping/accounts/__tests__/prune.test.ts @@ -0,0 +1,257 @@ +/** + * Tests for /api/bookkeeping/accounts/usage and /prune. + * + * The prune execute phase is the safety-critical part: the client's + * account_numbers list is a selection, not an authority. The tests assert + * that used accounts, system accounts, and unknown numbers sent by the + * client are skipped/reported — only freshly re-verified unused accounts + * reach the DELETE. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET as usageGET } from '../usage/route' +import { POST as prunePOST } from '../prune/route' + +interface CapturedCall { + method: string + args: unknown[] +} + +/** + * Chainable builder recording calls; resolves queued {data,error} per + * from()/rpc() invocation, in call order. + */ +function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) { + const calls: CapturedCall[] = [] + let idx = 0 + const nextResult = () => results[idx++] ?? { data: null, error: null } + const makeBuilder = () => { + const result = nextResult() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b: any = {} + for (const m of ['select', 'eq', 'in', 'order', 'range', 'insert', 'update', 'delete']) { + b[m] = (...args: unknown[]) => { + calls.push({ method: m, args }) + return b + } + } + b.then = (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null }) + return b + } + const supabase = { + from: (table: string) => { + calls.push({ method: 'from', args: [table] }) + return makeBuilder() + }, + rpc: (fn: string, params: unknown) => { + calls.push({ method: 'rpc', args: [fn, params] }) + const result = nextResult() + return Promise.resolve({ data: result.data ?? null, error: result.error ?? null }) + }, + } + return { supabase, calls } +} + +const routeParams = { params: Promise.resolve({}) } + +beforeEach(() => { + vi.clearAllMocks() + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +function auth(supabase: unknown) { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function unauthenticated() { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) +} + +// Chart fixture: one system account, one used BAS account, one unused BAS +// account from the seed, one unused imported custom account. +const chartAccounts = [ + { account_number: '1930', account_name: 'Företagskonto', account_class: 1, plan_type: 'k1', is_active: true, is_system_account: true }, + { account_number: '3001', account_name: 'Försäljning', account_class: 3, plan_type: 'k1', is_active: true, is_system_account: false }, + { account_number: '5410', account_name: 'Förbrukningsinventarier', account_class: 5, plan_type: 'k1', is_active: true, is_system_account: false }, + { account_number: '19301', account_name: 'Sparkonto (import)', account_class: 1, plan_type: 'full_bas', is_active: true, is_system_account: false }, +] + +const usageRows = [ + { account_number: '1930', usage_count: 12 }, + { account_number: '3001', usage_count: 4 }, +] + +describe('GET /api/bookkeeping/accounts/usage', () => { + it('returns 401 when not authenticated', async () => { + unauthenticated() + const res = await usageGET(createMockRequest('/api/bookkeeping/accounts/usage'), routeParams) + expect(res.status).toBe(401) + }) + + it('returns per-account usage counts from the RPC, company-scoped', async () => { + const { supabase, calls } = createCapturingSupabase([{ data: usageRows }]) + auth(supabase) + + const { status, body } = await parseJsonResponse<{ data: typeof usageRows }>( + await usageGET(createMockRequest('/api/bookkeeping/accounts/usage'), routeParams), + ) + + expect(status).toBe(200) + expect(body.data).toEqual(usageRows) + expect(calls.filter((c) => c.method === 'rpc').map((c) => c.args)).toContainEqual([ + 'get_account_usage_counts', + { p_company_id: 'company-1' }, + ]) + }) + + it('returns 500 when the RPC fails', async () => { + const { supabase } = createCapturingSupabase([{ error: { message: 'boom' } }]) + auth(supabase) + const res = await usageGET(createMockRequest('/api/bookkeeping/accounts/usage'), routeParams) + expect(res.status).toBe(500) + }) +}) + +describe('POST /api/bookkeeping/accounts/prune', () => { + it('returns 401 when not authenticated', async () => { + unauthenticated() + const req = createMockRequest('/api/bookkeeping/accounts/prune', { + method: 'POST', + body: { dry_run: true }, + }) + const res = await prunePOST(req, routeParams) + expect(res.status).toBe(401) + }) + + it('returns 400 when dry_run is false and account_numbers is missing', async () => { + const { supabase } = createCapturingSupabase([]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts/prune', { + method: 'POST', + body: { dry_run: false }, + }) + const { status } = await parseJsonResponse(await prunePOST(req, routeParams)) + expect(status).toBe(400) + }) + + it('dry_run returns unused non-system accounts as deletable, the rest as used', async () => { + const { supabase } = createCapturingSupabase([ + { data: chartAccounts }, // chart_of_accounts page + { data: usageRows }, // usage RPC + ]) + auth(supabase) + + const req = createMockRequest('/api/bookkeeping/accounts/prune', { + method: 'POST', + body: { dry_run: true }, + }) + const { status, body } = await parseJsonResponse<{ + data: { + deletable: Array<{ account_number: string; in_bas_reference: boolean }> + used: Array<{ account_number: string; usage_count: number }> + } + }>(await prunePOST(req, routeParams)) + + expect(status).toBe(200) + const deletableNumbers = body.data.deletable.map((a) => a.account_number).sort() + // Unused + non-system: the seeded 5410 and the imported 19301. + expect(deletableNumbers).toEqual(['19301', '5410']) + // BAS-vs-custom marker drives the dialog's default selection. + expect(body.data.deletable.find((a) => a.account_number === '5410')?.in_bas_reference).toBe(true) + expect(body.data.deletable.find((a) => a.account_number === '19301')?.in_bas_reference).toBe(false) + // Used + system accounts land in the informational remainder. + const usedNumbers = body.data.used.map((a) => a.account_number) + expect(usedNumbers).toContain('1930') + expect(usedNumbers).toContain('3001') + expect(body.data.used.find((a) => a.account_number === '3001')?.usage_count).toBe(4) + }) + + it('execute deletes only re-verified unused accounts and skips the rest', async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: chartAccounts }, + { data: usageRows }, + { data: null }, // delete chunk + ]) + auth(supabase) + + // Client asks for a used account (3001), a system account (1930), an + // unknown number (9999) and two legitimately deletable ones. + const req = createMockRequest('/api/bookkeeping/accounts/prune', { + method: 'POST', + body: { dry_run: false, account_numbers: ['3001', '1930', '9999', '5410', '19301'] }, + }) + const { status, body } = await parseJsonResponse<{ + data: { deleted: string[]; skipped: string[]; not_found: string[] } + }>(await prunePOST(req, routeParams)) + + expect(status).toBe(200) + expect(body.data.deleted.sort()).toEqual(['19301', '5410']) + expect(body.data.skipped.sort()).toEqual(['1930', '3001']) + expect(body.data.not_found).toEqual(['9999']) + + // The DELETE is company-scoped, guards system accounts, and only carries + // the re-verified numbers. + const inCalls = calls.filter((c) => c.method === 'in').map((c) => c.args) + expect(inCalls).toContainEqual(['account_number', ['5410', '19301']]) + const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args) + expect(eqCalls).toContainEqual(['is_system_account', false]) + }) + + it('execute with nothing deletable deletes nothing and reports the skips', async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: chartAccounts }, + { data: usageRows }, + ]) + auth(supabase) + + const req = createMockRequest('/api/bookkeeping/accounts/prune', { + method: 'POST', + body: { dry_run: false, account_numbers: ['3001'] }, + }) + const { status, body } = await parseJsonResponse<{ + data: { deleted: string[]; skipped: string[] } + }>(await prunePOST(req, routeParams)) + + expect(status).toBe(200) + expect(body.data.deleted).toEqual([]) + expect(body.data.skipped).toEqual(['3001']) + expect(calls.filter((c) => c.method === 'delete')).toHaveLength(0) + }) + + it('returns 403 when the member lacks write permission', async () => { + const { supabase } = createCapturingSupabase([]) + auth(supabase) + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + const req = createMockRequest('/api/bookkeeping/accounts/prune', { + method: 'POST', + body: { dry_run: true }, + }) + const res = await prunePOST(req, routeParams) + expect(res.status).toBe(403) + }) +}) diff --git a/app/api/bookkeeping/accounts/__tests__/reference.test.ts b/app/api/bookkeeping/accounts/__tests__/reference.test.ts new file mode 100644 index 00000000..0e098c71 --- /dev/null +++ b/app/api/bookkeeping/accounts/__tests__/reference.test.ts @@ -0,0 +1,121 @@ +/** + * Tests for GET /api/bookkeeping/accounts/reference and /bas-lookup. + * + * reference: the chart query must carry a stable unique order — a full-BAS + * chart exceeds fetchAllRows' 1000-row page size and unordered .range() + * paging can duplicate/skip rows on page boundaries. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET as referenceGET } from '../reference/route' +import { GET as basLookupGET } from '../bas-lookup/route' + +const routeParams = { params: Promise.resolve({}) } + +function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) { + const calls: { method: string; args: unknown[] }[] = [] + let idx = 0 + const makeBuilder = () => { + const result = results[idx++] ?? { data: null, error: null } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b: any = {} + for (const m of ['select', 'eq', 'order', 'range', 'maybeSingle', 'single']) { + b[m] = (...args: unknown[]) => { + calls.push({ method: m, args }) + return b + } + } + b.then = (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null, count: null }) + return b + } + return { + supabase: { from: () => makeBuilder() }, + calls, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/bookkeeping/accounts/reference', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await referenceGET(createMockRequest('/api/bookkeeping/accounts/reference'), routeParams) + expect(res.status).toBe(401) + }) + + it('pages the chart with a stable account_number order and merges activation status', async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: [{ account_number: '1930', is_active: true, is_system_account: false }] }, + ]) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + + const { status, body } = await parseJsonResponse<{ + data: Array<{ account_number: string; is_activated: boolean }> + }>(await referenceGET(createMockRequest('/api/bookkeeping/accounts/reference'), routeParams)) + + expect(status).toBe(200) + const activated = body.data.find((a) => a.account_number === '1930') + expect(activated?.is_activated).toBe(true) + // Paging-stability regression guard. + expect(calls.filter((c) => c.method === 'order').map((c) => c.args[0])).toContain( + 'account_number' + ) + }) +}) + +describe('GET /api/bookkeeping/accounts/bas-lookup', () => { + beforeEach(() => { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await basLookupGET(createMockRequest('/api/bookkeeping/accounts/bas-lookup')) + expect(res.status).toBe(401) + }) + + it('resolves known BAS numbers and flags unknown ones', async () => { + const req = createMockRequest('/api/bookkeeping/accounts/bas-lookup', { + searchParams: { numbers: '1930,0000' }, + }) + const { status, body } = await parseJsonResponse<{ + data: Array<{ account_number: string; known: boolean }> + }>(await basLookupGET(req)) + + expect(status).toBe(200) + expect(body.data.find((a) => a.account_number === '1930')?.known).toBe(true) + expect(body.data.find((a) => a.account_number === '0000')?.known).toBe(false) + }) + + it('rejects an oversized numbers list with 400', async () => { + const many = Array.from({ length: 2001 }, (_, i) => String(10000 + i)).join(',') + const req = createMockRequest('/api/bookkeeping/accounts/bas-lookup', { + searchParams: { numbers: many }, + }) + const { status } = await parseJsonResponse(await basLookupGET(req)) + expect(status).toBe(400) + }) +}) diff --git a/app/api/bookkeeping/accounts/activate/route.ts b/app/api/bookkeeping/accounts/activate/route.ts index 3b1d9aa0..70212e76 100644 --- a/app/api/bookkeeping/accounts/activate/route.ts +++ b/app/api/bookkeeping/accounts/activate/route.ts @@ -1,8 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { z } from 'zod' +import { withRouteContext } from '@/lib/api/with-route-context' import { getBASReference } from '@/lib/bookkeeping/bas-reference' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' /** * POST /api/bookkeeping/accounts/activate @@ -12,100 +11,101 @@ import { requireWritePermission } from '@/lib/auth/require-write' * - Reactivates (is_active=true) accounts that already exist but are inactive. * - Skips anything already active. * - Returns { activated, reactivated, skipped, unknown } so callers can react. + * + * Strings that aren't known BAS numbers are reported in `unknown` (not + * rejected) so activate-and-retry flows can surface them; the schema only + * bounds type and size. */ -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +const ActivateSchema = z.object({ + account_numbers: z.array(z.string().min(1).max(10)).min(1).max(2000), +}) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const POST = withRouteContext( + 'bookkeeping.accounts.activate', + async (request, ctx) => { + const { supabase, companyId, user } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const body = await request.json() - const accountNumbers: string[] = body.account_numbers - - if (!Array.isArray(accountNumbers) || accountNumbers.length === 0) { - return NextResponse.json({ error: 'account_numbers array required' }, { status: 400 }) - } - - const uniqueNumbers = [...new Set(accountNumbers)] - - // Fetch existing rows with current is_active state - const { data: existing, error: fetchError } = await supabase - .from('chart_of_accounts') - .select('account_number, is_active') - .eq('company_id', companyId) - .in('account_number', uniqueNumbers) - - if (fetchError) { - return NextResponse.json({ error: fetchError.message }, { status: 500 }) - } - - const existingByNumber = new Map( - (existing || []).map((a) => [a.account_number, a.is_active]) - ) - - const toReactivate: string[] = [] - const toInsert: Array> = [] - const unknown: string[] = [] - let skipped = 0 - - for (const num of uniqueNumbers) { - if (existingByNumber.has(num)) { - if (existingByNumber.get(num) === true) { - skipped += 1 - } else { - toReactivate.push(num) - } - continue + const raw = await request.json().catch(() => null) + const parsed = ActivateSchema.safeParse(raw) + if (!parsed.success) { + return NextResponse.json({ error: 'account_numbers array required' }, { status: 400 }) } - const row = buildInsertRow(num, user.id, companyId) - if (row) { - toInsert.push(row) - } else { - unknown.push(num) - } - } - let reactivatedRows: { account_number: string }[] = [] - if (toReactivate.length > 0) { - const { data, error } = await supabase + const uniqueNumbers = [...new Set(parsed.data.account_numbers)] + + // Fetch existing rows with current is_active state + const { data: existing, error: fetchError } = await supabase .from('chart_of_accounts') - .update({ is_active: true }) + .select('account_number, is_active') .eq('company_id', companyId) - .in('account_number', toReactivate) - .select('account_number') - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - reactivatedRows = data || [] - } + .in('account_number', uniqueNumbers) - let insertedRows: { account_number: string }[] = [] - if (toInsert.length > 0) { - const { data, error } = await supabase - .from('chart_of_accounts') - .insert(toInsert) - .select('account_number') - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) + if (fetchError) { + return NextResponse.json({ error: fetchError.message }, { status: 500 }) } - insertedRows = data || [] - } - return NextResponse.json({ - data: [...insertedRows, ...reactivatedRows], - activated: insertedRows.length, - reactivated: reactivatedRows.length, - skipped, - unknown, - }) -} + const existingByNumber = new Map( + (existing || []).map((a) => [a.account_number, a.is_active]) + ) + + const toReactivate: string[] = [] + const toInsert: Array> = [] + const unknown: string[] = [] + let skipped = 0 + + for (const num of uniqueNumbers) { + if (existingByNumber.has(num)) { + if (existingByNumber.get(num) === true) { + skipped += 1 + } else { + toReactivate.push(num) + } + continue + } + const row = buildInsertRow(num, user.id, companyId) + if (row) { + toInsert.push(row) + } else { + unknown.push(num) + } + } + + let reactivatedRows: { account_number: string }[] = [] + if (toReactivate.length > 0) { + const { data, error } = await supabase + .from('chart_of_accounts') + .update({ is_active: true }) + .eq('company_id', companyId) + .in('account_number', toReactivate) + .select('account_number') + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + reactivatedRows = data || [] + } + + let insertedRows: { account_number: string }[] = [] + if (toInsert.length > 0) { + const { data, error } = await supabase + .from('chart_of_accounts') + .insert(toInsert) + .select('account_number') + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + insertedRows = data || [] + } + + return NextResponse.json({ + data: [...insertedRows, ...reactivatedRows], + activated: insertedRows.length, + reactivated: reactivatedRows.length, + skipped, + unknown, + }) + }, + { requireWrite: true }, +) function buildInsertRow(accountNumber: string, userId: string, companyId: string) { const ref = getBASReference(accountNumber) diff --git a/app/api/bookkeeping/accounts/bas-lookup/route.ts b/app/api/bookkeeping/accounts/bas-lookup/route.ts index 18dfe998..cb35ac8e 100644 --- a/app/api/bookkeeping/accounts/bas-lookup/route.ts +++ b/app/api/bookkeeping/accounts/bas-lookup/route.ts @@ -1,5 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' import { getBASReference } from '@/lib/bookkeeping/bas-reference' /** @@ -9,13 +9,13 @@ import { getBASReference } from '@/lib/bookkeeping/bas-reference' * numbers. Used by ActivateAccountsDialog to render human-readable labels * before the user confirms activation. Unknown numbers are returned with * account_name=null so the UI can flag them as non-BAS. + * + * Pure in-memory reference lookup — no tenant data, so no company context is + * resolved; requireAuth() keeps it behind auth (MFA on hosted). */ export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const auth = await requireAuth() + if (auth.error) return auth.error const { searchParams } = new URL(request.url) const raw = searchParams.get('numbers') || '' @@ -23,6 +23,10 @@ export async function GET(request: Request) { if (numbers.length === 0) { return NextResponse.json({ data: [] }) } + // The BAS catalogue is ~1,276 accounts — anything past that is abuse. + if (numbers.length > 2000) { + return NextResponse.json({ error: 'Too many account numbers' }, { status: 400 }) + } const data = numbers.map((num) => { const ref = getBASReference(num) diff --git a/app/api/bookkeeping/accounts/prune/route.ts b/app/api/bookkeeping/accounts/prune/route.ts new file mode 100644 index 00000000..fd913589 --- /dev/null +++ b/app/api/bookkeeping/accounts/prune/route.ts @@ -0,0 +1,162 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { PruneAccountsSchema } from '@/lib/api/schemas' +import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference' +import { fetchAllRows } from '@/lib/supabase/fetch-all' + +// POST /api/bookkeeping/accounts/prune — bulk cleanup of unused accounts +// ("Rensa oanvända konton"), for charts bloated by an import from an old +// system. +// +// Two phases: +// { dry_run: true } +// → returns the deletable set (non-system accounts with zero journal +// usage) plus the used remainder, without changing anything. +// { dry_run: false, account_numbers: [...] } +// → deletes the requested accounts, re-verifying every guard server-side. +// The client list is a selection from the preview, not an authority: +// anything failing re-check at execute time is skipped and reported, +// never deleted and never an error. +// +// A used account can never be deleted through this path — its verifikat are +// immutable under BFL and their lines must keep resolving to an account. +// Deactivation (PUT is_active=false on the single-account route) remains the +// only way to hide those. Draft usage also blocks deletion: a draft line +// still references the account. Opening balances need no separate check — +// IB is booked as a verifikat (source_type 'opening_balance'), so the journal +// usage count covers it. (The account_balances cache table was dropped in +// migration 20240101000027.) +// +// Like the sibling single-account DELETE, there is a small window between +// the usage re-check and the delete where a concurrent posting could slip +// in; journal_entry_lines reference accounts by number (account_id is ON +// DELETE SET NULL), so the entry itself is never damaged — the account row +// would just need re-adding from the BAS catalog. +// +// Response shapes are legacy `{ data }` / `{ error: string }` — consumed by +// the kontoplan UI alongside the sibling account routes. + +interface ChartAccountRow { + account_number: string + account_name: string + account_class: number + plan_type: string | null + is_active: boolean + is_system_account: boolean +} + +const DELETE_CHUNK_SIZE = 200 + +export const POST = withRouteContext( + 'bookkeeping.accounts.prune', + async (request, ctx) => { + const { supabase, companyId, log } = ctx + + const validation = await validateBody(request, PruneAccountsSchema, { + log, + operation: 'bookkeeping.accounts.prune', + }) + if (!validation.success) return validation.response + const { dry_run, account_numbers } = validation.data + + try { + // A full imported chart can exceed PostgREST's 1000-row page — paginate. + const accounts = (await fetchAllRows(({ from, to }) => + supabase + .from('chart_of_accounts') + .select( + 'account_number, account_name, account_class, plan_type, is_active, is_system_account', + ) + .eq('company_id', companyId) + .order('account_number') + .range(from, to), + )) as ChartAccountRow[] + + const { data: usage, error: usageError } = await supabase.rpc( + 'get_account_usage_counts', + { p_company_id: companyId }, + ) + if (usageError) { + return NextResponse.json({ error: usageError.message }, { status: 500 }) + } + + const usageByAccount = new Map( + (usage ?? []).map((u: { account_number: string; usage_count: number }) => [ + u.account_number, + Number(u.usage_count), + ]), + ) + + const isDeletable = (a: ChartAccountRow) => + !a.is_system_account && !usageByAccount.has(a.account_number) + + if (dry_run) { + const deletable = accounts.filter(isDeletable).map((a) => ({ + account_number: a.account_number, + account_name: a.account_name, + account_class: a.account_class, + plan_type: a.plan_type, + is_active: a.is_active, + in_bas_reference: isStandardBASAccount(a.account_number), + })) + const used = accounts + .filter((a) => !isDeletable(a)) + .map((a) => ({ + account_number: a.account_number, + account_name: a.account_name, + is_system_account: a.is_system_account, + usage_count: usageByAccount.get(a.account_number) ?? 0, + })) + return NextResponse.json({ data: { deletable, used } }) + } + + // Execute: intersect the requested selection with the freshly computed + // deletable set — guards are re-verified here, not trusted from the + // preview the client saw. + const requested = [...new Set(account_numbers ?? [])] + const deletableSet = new Set(accounts.filter(isDeletable).map((a) => a.account_number)) + const existingSet = new Set(accounts.map((a) => a.account_number)) + + const toDelete = requested.filter((n) => deletableSet.has(n)) + const skipped = requested.filter((n) => existingSet.has(n) && !deletableSet.has(n)) + const notFound = requested.filter((n) => !existingSet.has(n)) + + for (let i = 0; i < toDelete.length; i += DELETE_CHUNK_SIZE) { + const chunk = toDelete.slice(i, i + DELETE_CHUNK_SIZE) + const { error: deleteError } = await supabase + .from('chart_of_accounts') + .delete() + .eq('company_id', companyId) + .eq('is_system_account', false) + .in('account_number', chunk) + if (deleteError) { + // Report what was already deleted so the UI can refresh honestly. + return NextResponse.json( + { + error: deleteError.message, + data: { deleted: toDelete.slice(0, i), skipped, not_found: notFound }, + }, + { status: 500 }, + ) + } + } + + log.info('unused accounts pruned', { + deleted: toDelete.length, + skipped: skipped.length, + notFound: notFound.length, + }) + + return NextResponse.json({ + data: { deleted: toDelete, skipped, not_found: notFound }, + }) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to prune accounts' }, + { status: 500 }, + ) + } + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/accounts/reference/route.ts b/app/api/bookkeeping/accounts/reference/route.ts index e547a49b..d421f1dd 100644 --- a/app/api/bookkeeping/accounts/reference/route.ts +++ b/app/api/bookkeeping/accounts/reference/route.ts @@ -1,8 +1,7 @@ -import { createClient } from '@/lib/supabase/server' -import { fetchAllRows } from '@/lib/supabase/fetch-all' import { NextResponse } from 'next/server' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { withRouteContext } from '@/lib/api/with-route-context' import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference' -import { requireCompanyId } from '@/lib/company/context' /** * GET /api/bookkeeping/accounts/reference @@ -10,23 +9,19 @@ import { requireCompanyId } from '@/lib/company/context' * Returns the full BAS reference catalog merged with the user's activation status. * Each reference account includes: is_activated (exists in user's chart), is_active, is_system_account, is_custom. */ -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext('bookkeeping.accounts.reference', async (_request, ctx) => { + const { supabase, companyId } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - // Fetch user's chart of accounts (paginated to avoid 1000-row limit) + // Paginated with a stable unique order — a full-BAS chart exceeds the + // 1000-row page size, and unordered .range() paging can duplicate or skip + // rows on page boundaries (see fetch-all.ts ordering invariant). try { const userAccounts = await fetchAllRows<{ account_number: string; is_active: boolean; is_system_account: boolean }>(({ from, to }) => supabase .from('chart_of_accounts') .select('account_number, is_active, is_system_account') .eq('company_id', companyId) + .order('account_number', { ascending: true }) .range(from, to) ) @@ -62,4 +57,4 @@ export async function GET() { } catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 }) } -} +}) diff --git a/app/api/bookkeeping/accounts/route.ts b/app/api/bookkeeping/accounts/route.ts index 469bf5b3..a50ca43c 100644 --- a/app/api/bookkeeping/accounts/route.ts +++ b/app/api/bookkeeping/accounts/route.ts @@ -1,24 +1,28 @@ -import { createClient } from '@/lib/supabase/server' -import { fetchAllRows } from '@/lib/supabase/fetch-all' import { NextResponse } from 'next/server' -import { validateBody } from '@/lib/api/validate' +import { z } from 'zod' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody, validateQuery } from '@/lib/api/validate' import { CreateAccountSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +// Response shapes are legacy `{ data }` / `{ error: string }` — several pages +// (import, supplier-invoices, article form) consume the list directly. - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +const ListQuerySchema = z.object({ + class: z.coerce.number().int().min(1).max(8).optional(), + active: z.enum(['true', 'false']).optional(), +}) - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext('bookkeeping.accounts.list', async (request, ctx) => { + const { supabase, companyId, log } = ctx - const { searchParams } = new URL(request.url) - const accountClass = searchParams.get('class') - const activeOnly = searchParams.get('active') !== 'false' + const validated = validateQuery(request, ListQuerySchema, { + log, + operation: 'bookkeeping.accounts.list', + }) + if (!validated.success) return validated.response + const accountClass = validated.data.class + const activeOnly = validated.data.active !== 'false' try { const data = await fetchAllRows(({ from, to }) => { @@ -32,8 +36,8 @@ export async function GET(request: Request) { query = query.eq('is_active', true) } - if (accountClass) { - query = query.eq('account_class', parseInt(accountClass)) + if (accountClass !== undefined) { + query = query.eq('account_class', accountClass) } return query.range(from, to) @@ -41,57 +45,57 @@ export async function GET(request: Request) { return NextResponse.json({ data }) } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 }) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, + { status: 500 }, + ) } -} +}) -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext( + 'bookkeeping.accounts.create', + async (request, ctx) => { + const { supabase, companyId, user, log } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const validation = await validateBody(request, CreateAccountSchema) - if (!validation.success) return validation.response - const body = validation.data - - const companyId = await requireCompanyId(supabase, user.id) - - const { data, error } = await supabase - .from('chart_of_accounts') - .insert({ - user_id: user.id, - company_id: companyId, - account_number: body.account_number, - account_name: body.account_name, - account_class: parseInt(body.account_number[0]), - account_group: body.account_number.substring(0, 2), - account_type: body.account_type, - normal_balance: body.normal_balance, - plan_type: body.plan_type || 'k1', - is_system_account: false, - description: body.description || null, - default_vat_code: body.default_vat_code || null, - sru_code: body.sru_code || null, - sort_order: parseInt(body.account_number), + const validation = await validateBody(request, CreateAccountSchema, { + log, + operation: 'bookkeeping.accounts.create', }) - .select() - .single() + if (!validation.success) return validation.response + const body = validation.data - if (error) { - if (error.code === '23505') { - return NextResponse.json( - { error: `Kontonummer ${body.account_number} finns redan i din kontoplan.` }, - { status: 409 }, - ) + const { data, error } = await supabase + .from('chart_of_accounts') + .insert({ + user_id: user.id, + company_id: companyId, + account_number: body.account_number, + account_name: body.account_name, + account_class: parseInt(body.account_number[0]), + account_group: body.account_number.substring(0, 2), + account_type: body.account_type, + normal_balance: body.normal_balance, + plan_type: body.plan_type || 'k1', + is_system_account: false, + description: body.description || null, + default_vat_code: body.default_vat_code || null, + sru_code: body.sru_code || null, + sort_order: parseInt(body.account_number), + }) + .select() + .single() + + if (error) { + if (error.code === '23505') { + return NextResponse.json( + { error: `Kontonummer ${body.account_number} finns redan i din kontoplan.` }, + { status: 409 }, + ) + } + return NextResponse.json({ error: error.message }, { status: 500 }) } - return NextResponse.json({ error: error.message }, { status: 500 }) - } - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/accounts/usage/route.ts b/app/api/bookkeeping/accounts/usage/route.ts new file mode 100644 index 00000000..b9144ff7 --- /dev/null +++ b/app/api/bookkeeping/accounts/usage/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' + +// GET /api/bookkeeping/accounts/usage — per-account posting counts for the +// active company, from the get_account_usage_counts RPC. Accounts that have +// never been posted to are absent from the result; that absence is the +// "unused" signal the kontoplan UI and the prune flow key on. +// +// Response shapes are legacy `{ data }` / `{ error: string }` — consumed by +// the kontoplan UI alongside the sibling account routes. + +export const GET = withRouteContext('bookkeeping.accounts.usage', async (_request, ctx) => { + const { supabase, companyId } = ctx + + const { data, error } = await supabase.rpc('get_account_usage_counts', { + p_company_id: companyId, + }) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data: data ?? [] }) +}) diff --git a/app/api/bookkeeping/accruals/__tests__/list.test.ts b/app/api/bookkeeping/accruals/__tests__/list.test.ts new file mode 100644 index 00000000..d977c6df --- /dev/null +++ b/app/api/bookkeeping/accruals/__tests__/list.test.ts @@ -0,0 +1,66 @@ +/** + * Tests for GET /api/bookkeeping/accruals — status filter validation and the + * due_count derivation. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { GET } from '../route' + +const routeParams = { params: Promise.resolve({}) } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +}) + +describe('GET /api/bookkeeping/accruals', () => { + it('returns 400 for an unknown status filter', async () => { + const req = createMockRequest('/api/bookkeeping/accruals', { + searchParams: { status: 'garbage' }, + }) + const { status } = await parseJsonResponse(await GET(req, routeParams)) + expect(status).toBe(400) + }) + + it('lists schedules and counts due pending installments', async () => { + enqueue({ + data: [ + { + id: 'sched-1', + status: 'active', + created_at: '2026-01-01T00:00:00Z', + installments: [ + { id: 'i1', period_month: '2020-01-01', status: 'pending' }, + { id: 'i2', period_month: '2099-01-01', status: 'pending' }, + { id: 'i3', period_month: '2020-02-01', status: 'posted' }, + ], + }, + ], + }) + + const { status, body } = await parseJsonResponse<{ data: unknown[]; due_count: number }>( + await GET(createMockRequest('/api/bookkeeping/accruals'), routeParams) + ) + + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + // Only the past-month pending installment counts as due. + expect(body.due_count).toBe(1) + }) +}) diff --git a/app/api/bookkeeping/accruals/route.ts b/app/api/bookkeeping/accruals/route.ts index d0c92d10..d86e1439 100644 --- a/app/api/bookkeeping/accruals/route.ts +++ b/app/api/bookkeeping/accruals/route.ts @@ -1,12 +1,18 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' +import { validateQuery } from '@/lib/api/validate' import { errorResponse } from '@/lib/errors/get-structured-error' import { firstOfMonth } from '@/lib/bookkeeping/accruals/compute' import type { AccrualSchedule, AccrualScheduleInstallment } from '@/types' ensureInitialized() +const ListQuerySchema = z.object({ + status: z.enum(['active', 'completed', 'cancelled', 'all']).default('active'), +}) + /** * GET /api/bookkeeping/accruals?status=active|completed|cancelled|all * @@ -19,8 +25,12 @@ export const GET = withRouteContext( async (request, ctx) => { const { supabase, companyId, log, requestId } = ctx - const { searchParams } = new URL(request.url) - const status = searchParams.get('status') || 'active' + const validated = validateQuery(request, ListQuerySchema, { + log, + operation: 'accruals.list', + }) + if (!validated.success) return validated.response + const { status } = validated.data let query = supabase .from('accrual_schedules') diff --git a/app/api/bookkeeping/fiscal-periods/[id]/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/__tests__/route.test.ts index a61c07b5..caaef0dd 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/__tests__/route.test.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/__tests__/route.test.ts @@ -6,6 +6,7 @@ vi.mock('@/lib/supabase/server', () => ({ })) vi.mock('@/lib/company/context', () => ({ requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), diff --git a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/__tests__/route.test.ts new file mode 100644 index 00000000..e335c5d9 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/__tests__/route.test.ts @@ -0,0 +1,88 @@ +/** + * Tests for POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner — + * input-bound validation. The schablonintäkt rate feeds the avsättning cap + * base (IL 30 kap 25 % limit), so an unbounded rate would let a caller + * inflate the legal ceiling; these tests lock the bounds in. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { POST } from '../route' + +const idParams = { params: Promise.resolve({ id: 'period-1' }) } + +function post(body: unknown) { + return POST( + createMockRequest('/api/bookkeeping/fiscal-periods/period-1/bokslutsdispositioner', { + method: 'POST', + body, + }), + idParams, + ) +} + +beforeEach(() => { + vi.clearAllMocks() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +describe('POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await post({ items: [{ kind: 'bolagsskatt' }] }) + expect(res.status).toBe(401) + }) + + it('rejects an inflated schablonintäkt rate (cap-base attack) with 400', async () => { + const { status } = await parseJsonResponse( + await post({ + items: [{ kind: 'periodiseringsfond_avsattning', schablonintaktRate: 100 }], + }), + ) + expect(status).toBe(400) + }) + + it('rejects a negative desiredAmount with 400', async () => { + const { status } = await parseJsonResponse( + await post({ + items: [{ kind: 'periodiseringsfond_avsattning', desiredAmount: -50000 }], + }), + ) + expect(status).toBe(400) + }) + + it('rejects negative återföring amounts with 400', async () => { + const { status } = await parseJsonResponse( + await post({ + items: [{ kind: 'periodiseringsfond_ateforing', returns: { '2129': -10000 } }], + }), + ) + expect(status).toBe(400) + }) + + it('rejects an empty items array with 400', async () => { + const { status } = await parseJsonResponse(await post({ items: [] })) + expect(status).toBe(400) + }) +}) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts index 53c48563..4ab31ef7 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts @@ -103,14 +103,16 @@ const ItemSchema = z.discriminatedUnion('kind', [ kind: z.literal('periodiseringsfond_avsattning'), /** Optional override for the SLR-based schablonintäkt rate; defaults to * the server-side constant. Used both to compute the cap base and to - * feed back into bolagsskatt's adjustment if present in the same batch. */ - schablonintaktRate: z.number().optional(), - desiredAmount: z.number().optional(), + * feed back into bolagsskatt's adjustment if present in the same batch. + * Bounded to a sane range — an inflated rate would inflate the cap base + * and let the caller exceed the legal 25 % avsättning limit (IL 30 kap). */ + schablonintaktRate: z.number().min(0).max(0.2).optional(), + desiredAmount: z.number().positive().optional(), }), z.object({ kind: z.literal('periodiseringsfond_ateforing'), - returns: z.record(z.string(), z.number()).default({}), - schablonintaktRate: z.number().default(DEFAULT_SCHABLONINTAKT_RATE), + returns: z.record(z.string(), z.number().nonnegative()).default({}), + schablonintaktRate: z.number().min(0).max(0.2).default(DEFAULT_SCHABLONINTAKT_RATE), }), z.object({ kind: z.literal('overavskrivningar'), diff --git a/app/api/bookkeeping/fiscal-periods/[id]/close/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/close/__tests__/route.test.ts new file mode 100644 index 00000000..c9d35610 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/close/__tests__/route.test.ts @@ -0,0 +1,79 @@ +/** + * Tests for POST /api/bookkeeping/fiscal-periods/[id]/close. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/core/bookkeeping/period-service', () => ({ + closePeriod: vi.fn(), +})) + +import { closePeriod } from '@/lib/core/bookkeeping/period-service' +import { POST } from '../route' + +const mockClosePeriod = vi.mocked(closePeriod) +const idParams = { params: Promise.resolve({ id: 'period-1' }) } + +beforeEach(() => { + vi.clearAllMocks() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +describe('POST /api/bookkeeping/fiscal-periods/[id]/close', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams) + expect(res.status).toBe(401) + }) + + it('returns 403 when the caller lacks write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'forbidden' }, { status: 403 }), + }) + const res = await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams) + expect(res.status).toBe(403) + expect(mockClosePeriod).not.toHaveBeenCalled() + }) + + it('maps a service refusal to 400 with the message', async () => { + mockClosePeriod.mockRejectedValue(new Error('Period contains draft entries')) + const { status, body } = await parseJsonResponse<{ error: string }>( + await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams) + ) + expect(status).toBe(400) + expect(body.error).toBe('Period contains draft entries') + }) + + it('closes the period on the happy path', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockClosePeriod.mockResolvedValue({ id: 'period-1', is_closed: true } as any) + const { status, body } = await parseJsonResponse<{ data: { is_closed: boolean } }>( + await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams) + ) + expect(status).toBe(200) + expect(body.data.is_closed).toBe(true) + expect(mockClosePeriod).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'period-1') + }) +}) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts index 9988395a..0cb7aaec 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts @@ -1,33 +1,25 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { closePeriod } from '@/lib/core/bookkeeping/period-service' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +// Response shapes are legacy `{ error: string }` — kept for the year-end UI. +// closePeriod throws plain Errors for every refusal (period not found, drafts +// remaining, already closed); they all map to 400 as before. +export const POST = withRouteContext( + 'period.close', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - try { - const period = await closePeriod(supabase, companyId, user.id, id) - return NextResponse.json({ data: period }) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to close period' }, - { status: 400 } - ) - } -} + try { + const period = await closePeriod(supabase, companyId, user.id, id) + return NextResponse.json({ data: period }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to close period' }, + { status: 400 } + ) + } + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/entry-count/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/entry-count/__tests__/route.test.ts index cb55c1b7..5c38cc44 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/entry-count/__tests__/route.test.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/entry-count/__tests__/route.test.ts @@ -5,6 +5,7 @@ vi.mock('@/lib/supabase/server', () => ({ })) vi.mock('@/lib/company/context', () => ({ requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) import { createClient } from '@/lib/supabase/server' diff --git a/app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts index 6ab7badc..a5452761 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts @@ -1,42 +1,34 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'period.entry_count', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data: period, error: fetchError } = await supabase + .from('fiscal_periods') + .select('id') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() - const companyId = await requireCompanyId(supabase, user.id) + if (fetchError || !period) { + return NextResponse.json({ error: 'Räkenskapsår hittades inte' }, { status: 404 }) + } - const { data: period, error: fetchError } = await supabase - .from('fiscal_periods') - .select('id') - .eq('id', id) - .eq('company_id', companyId) - .maybeSingle() + const { count, error: countError } = await supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('fiscal_period_id', id) + .in('status', ['posted', 'reversed']) - if (fetchError || !period) { - return NextResponse.json({ error: 'Räkenskapsår hittades inte' }, { status: 404 }) - } + if (countError) { + return NextResponse.json({ error: countError.message }, { status: 500 }) + } - const { count, error: countError } = await supabase - .from('journal_entries') - .select('id', { count: 'exact', head: true }) - .eq('company_id', companyId) - .eq('fiscal_period_id', id) - .in('status', ['posted', 'reversed']) - - if (countError) { - return NextResponse.json({ error: countError.message }, { status: 500 }) - } - - return NextResponse.json({ data: { posted_count: count ?? 0 } }) -} + return NextResponse.json({ data: { posted_count: count ?? 0 } }) + }, +) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/route.ts index b29e5cd8..e1cb5e82 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/route.ts @@ -1,9 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { validatePeriodDuration, parseDateParts } from '@/lib/bookkeeping/validate-period-duration' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { z } from 'zod' const UpdateFiscalPeriodSchema = z.object({ @@ -12,22 +10,14 @@ const UpdateFiscalPeriodSchema = z.object({ period_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Slutdatum måste vara i format ÅÅÅÅ-MM-DD').optional(), }) -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { +// Response shapes are legacy `{ error: string }` (Swedish) — the fiscal-year +// settings UI renders them directly. Only the auth/company layer was moved +// into withRouteContext. +export const PATCH = withRouteContext( + 'period.update', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) + const { supabase, companyId } = ctx const validation = await validateBody(request, UpdateFiscalPeriodSchema) if (!validation.success) return validation.response @@ -166,4 +156,6 @@ export async function PATCH( } return NextResponse.json({ data: updated }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts index afdf05fb..738f66f3 100644 --- a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts +++ b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts @@ -6,6 +6,7 @@ vi.mock('@/lib/supabase/server', () => ({ })) vi.mock('@/lib/company/context', () => ({ requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), diff --git a/app/api/bookkeeping/fiscal-periods/period-status/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/period-status/__tests__/route.test.ts new file mode 100644 index 00000000..79e54795 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/period-status/__tests__/route.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for GET /api/bookkeeping/fiscal-periods/period-status. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/core/bookkeeping/period-service', () => ({ + resolvePeriodStatusForDate: vi.fn(), +})) + +import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service' +import { GET } from '../route' + +const mockResolve = vi.mocked(resolvePeriodStatusForDate) +const routeParams = { params: Promise.resolve({}) } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +}) + +describe('GET /api/bookkeeping/fiscal-periods/period-status', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const req = createMockRequest('/api/bookkeeping/fiscal-periods/period-status', { + searchParams: { date: '2026-01-15' }, + }) + const res = await GET(req, routeParams) + expect(res.status).toBe(401) + }) + + it('returns 400 for a malformed date', async () => { + const req = createMockRequest('/api/bookkeeping/fiscal-periods/period-status', { + searchParams: { date: '15/01/2026' }, + }) + const { status } = await parseJsonResponse(await GET(req, routeParams)) + expect(status).toBe(400) + expect(mockResolve).not.toHaveBeenCalled() + }) + + it('returns the status with the covering period name', async () => { + mockResolve.mockResolvedValue({ + status: 'open', + period_id: 'period-1', + lock_date: null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + enqueue({ data: { name: 'Räkenskapsår 2026' } }) + + const req = createMockRequest('/api/bookkeeping/fiscal-periods/period-status', { + searchParams: { date: '2026-01-15' }, + }) + const { status, body } = await parseJsonResponse<{ + data: { status: string; period_name: string } + }>(await GET(req, routeParams)) + + expect(status).toBe(200) + expect(body.data.status).toBe('open') + expect(body.data.period_name).toBe('Räkenskapsår 2026') + expect(mockResolve).toHaveBeenCalledWith(expect.anything(), 'company-1', '2026-01-15') + }) +}) diff --git a/app/api/bookkeeping/fiscal-periods/period-status/route.ts b/app/api/bookkeeping/fiscal-periods/period-status/route.ts index f5129c61..3f9059d7 100644 --- a/app/api/bookkeeping/fiscal-periods/period-status/route.ts +++ b/app/api/bookkeeping/fiscal-periods/period-status/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service' -import { requireCompanyId } from '@/lib/company/context' /** * GET /api/bookkeeping/fiscal-periods/period-status?date=YYYY-MM-DD @@ -11,20 +10,14 @@ import { requireCompanyId } from '@/lib/company/context' * the covering period's label so the UI can show "flyttas till <år>" before a * write is attempted. Mirrors resolvePeriodStatusForDate / the DB triggers. */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const GET = withRouteContext('period.status_for_date', async (request, ctx) => { + const { supabase, companyId } = ctx const date = new URL(request.url).searchParams.get('date') if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { return NextResponse.json({ error: 'Ogiltigt datum (förväntat ÅÅÅÅ-MM-DD)' }, { status: 400 }) } - const companyId = await requireCompanyId(supabase, user.id) - try { const status = await resolvePeriodStatusForDate(supabase, companyId, date) @@ -58,4 +51,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/bookkeeping/fiscal-periods/route.ts b/app/api/bookkeeping/fiscal-periods/route.ts index 6b70cb29..50a7b895 100644 --- a/app/api/bookkeeping/fiscal-periods/route.ts +++ b/app/api/bookkeeping/fiscal-periods/route.ts @@ -1,24 +1,15 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration' import { validateBody } from '@/lib/api/validate' import { CreateFiscalPeriodSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -import { createLogger } from '@/lib/logger' -const log = createLogger('api/bookkeeping/fiscal-periods') +// Response shapes are legacy `{ error: string }` (plus one envelope code for +// the blocked-by-open-periods dialog) — kept for the räkenskapsår UI. -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext('period.list', async (_request, ctx) => { + const { supabase, companyId } = ctx const { data, error } = await supabase .from('fiscal_periods') @@ -31,20 +22,12 @@ export async function GET() { } return NextResponse.json({ data }) -} +}) -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) +export const POST = withRouteContext( + 'period.create', + async (request, ctx) => { + const { supabase, companyId, user, log } = ctx const validation = await validateBody(request, CreateFiscalPeriodSchema) if (!validation.success) return validation.response @@ -231,13 +214,23 @@ export async function POST(request: Request) { const isPrepend = body.period_end < earliest.period_start const periodToRelink = isPrepend ? earliest : successor if (periodToRelink) { - await supabase + const { error: relinkError } = await supabase .from('fiscal_periods') .update({ previous_period_id: data.id }) .eq('id', periodToRelink.id) .eq('company_id', companyId) + if (relinkError) { + // The period WAS created — don't fail the request, but a broken + // continuity chain (BFNAR 2013:2) must never be silent. + log.error('failed to relink continuity chain after period create', relinkError, { + createdPeriodId: data.id, + relinkPeriodId: periodToRelink.id, + }) + } } } return NextResponse.json({ data }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/journal-entries/[id]/chain/route.ts b/app/api/bookkeeping/journal-entries/[id]/chain/route.ts index 81780ec7..b8cbac77 100644 --- a/app/api/bookkeeping/journal-entries/[id]/chain/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/chain/route.ts @@ -1,20 +1,10 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'bookkeeping.journal_entry.chain', + async (_request, { supabase, companyId }, { params }) => { const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) // Fetch the requested entry with lines const { data: entry, error } = await supabase @@ -127,4 +117,5 @@ export async function GET( } return NextResponse.json({ data: { entry, chain, is_last_in_series: isLastInSeries } }) -} + }, +) diff --git a/app/api/bookkeeping/journal-entries/[id]/no-document-required/route.ts b/app/api/bookkeeping/journal-entries/[id]/no-document-required/route.ts index 0c25500d..2cbc17e3 100644 --- a/app/api/bookkeeping/journal-entries/[id]/no-document-required/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/no-document-required/route.ts @@ -1,7 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { z } from 'zod' import { validateBody } from '@/lib/api/validate' @@ -9,22 +7,10 @@ const SetNoDocSchema = z.object({ reason: z.string().trim().max(200).nullable().optional(), }) -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'bookkeeping.journal_entry.no_doc_required.set', + async (request, { supabase, companyId, user }, { params }) => { const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) const result = await validateBody(request, SetNoDocSchema) if (!result.success) return result.response @@ -57,24 +43,14 @@ export async function POST( } return NextResponse.json({ data: { exempted: true } }) -} + }, + { requireWrite: true }, +) -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'bookkeeping.journal_entry.no_doc_required.unset', + async (_request, { supabase, companyId }, { params }) => { const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) // Authorization is company-scoped, not user-scoped: any non-viewer member // of the active company may revoke any exemption in that company. The flag @@ -92,4 +68,6 @@ export async function DELETE( } return NextResponse.json({ data: { exempted: false } }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/journal-entries/[id]/notes/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/notes/__tests__/route.test.ts new file mode 100644 index 00000000..da5982de --- /dev/null +++ b/app/api/bookkeeping/journal-entries/[id]/notes/__tests__/route.test.ts @@ -0,0 +1,81 @@ +/** + * Tests for PATCH /api/bookkeeping/journal-entries/[id]/notes. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { PATCH } from '../route' + +const idParams = { params: Promise.resolve({ id: 'entry-1' }) } + +function patch(body: unknown) { + return PATCH( + createMockRequest('/api/bookkeeping/journal-entries/entry-1/notes', { + method: 'PATCH', + body, + }), + idParams, + ) +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +describe('PATCH /api/bookkeeping/journal-entries/[id]/notes', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await patch({ notes: 'hej' }) + expect(res.status).toBe(401) + }) + + it('rejects an over-long note with 400', async () => { + const { status } = await parseJsonResponse(await patch({ notes: 'x'.repeat(2001) })) + expect(status).toBe(400) + }) + + it('returns 404 instead of phantom success when no row matches', async () => { + enqueue({ data: null }) // update matched zero rows + + const { status, body } = await parseJsonResponse<{ error: string }>( + await patch({ notes: 'En anteckning' }) + ) + expect(status).toBe(404) + expect(body.error).toBe('Verifikationen hittades inte.') + }) + + it('updates the note on the happy path', async () => { + enqueue({ data: { id: 'entry-1' } }) + + const { status, body } = await parseJsonResponse<{ data: { updated: boolean } }>( + await patch({ notes: 'En anteckning' }) + ) + expect(status).toBe(200) + expect(body.data.updated).toBe(true) + }) +}) diff --git a/app/api/bookkeeping/journal-entries/[id]/notes/route.ts b/app/api/bookkeeping/journal-entries/[id]/notes/route.ts index 76f4326a..e61770a1 100644 --- a/app/api/bookkeeping/journal-entries/[id]/notes/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/notes/route.ts @@ -1,7 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { z } from 'zod' import { validateBody } from '@/lib/api/validate' @@ -9,35 +7,35 @@ const UpdateNotesSchema = z.object({ notes: z.string().max(2000).nullable(), }) -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +// Notes are annotation metadata alongside the verifikat (not räkenskaps- +// information) — the immutability trigger governs what may change on posted +// entries; this route just scopes and validates. +export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( + 'bookkeeping.journal_entry.notes', + async (request, { supabase, companyId }, { params }) => { + const { id } = await params - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const result = await validateBody(request, UpdateNotesSchema) + if (!result.success) return result.response - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const { data, error } = await supabase + .from('journal_entries') + .update({ notes: result.data.notes }) + .eq('id', id) + .eq('company_id', companyId) + .select('id') + .maybeSingle() - const companyId = await requireCompanyId(supabase, user.id) + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + // Zero rows = the entry doesn't exist in this company — report it instead + // of a phantom success. + if (!data) { + return NextResponse.json({ error: 'Verifikationen hittades inte.' }, { status: 404 }) + } - const result = await validateBody(request, UpdateNotesSchema) - if (!result.success) return result.response - - const { error } = await supabase - .from('journal_entries') - .update({ notes: result.data.notes }) - .eq('id', id) - .eq('company_id', companyId) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - - return NextResponse.json({ data: { updated: true } }) -} + return NextResponse.json({ data: { updated: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/journal-entries/[id]/route.ts b/app/api/bookkeeping/journal-entries/[id]/route.ts index 03dfdce3..efc61889 100644 --- a/app/api/bookkeeping/journal-entries/[id]/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/route.ts @@ -1,7 +1,4 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { ensureInitialized } from '@/lib/init' import { eventBus } from '@/lib/events/bus' import { getErrorMessage } from '@/lib/errors/get-error-message' @@ -17,50 +14,30 @@ const logger = createLogger('journal-entries') ensureInitialized() -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'bookkeeping.journal_entry.get', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data, error } = await supabase + .from('journal_entries') + .select('*, lines:journal_entry_lines(*)') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (error) { + return NextResponse.json({ error: error.message }, { status: 404 }) + } - const { data, error } = await supabase - .from('journal_entries') - .select('*, lines:journal_entry_lines(*)') - .eq('id', id) - .eq('company_id', companyId) - .single() + return NextResponse.json({ data }) + }, +) - if (error) { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - - return NextResponse.json({ data }) -} - -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'bookkeeping.journal_entry.delete', + async (_request, { supabase, companyId, user }, { params }) => { + const { id } = await params // Read source_type/source_id BEFORE deleting so we can revert the linked // invoice/supplier_invoice status afterwards. The GL row gets cancelled by @@ -107,13 +84,14 @@ export async function DELETE( }) return NextResponse.json({ data }) -} + }, + { requireWrite: true }, +) /** * PATCH: edit a DRAFT verifikat in place (header + lines). Only drafts are * editable; updateDraftEntry rejects committed entries with a 409, and the DB - * immutability trigger is the backstop. Uses withRouteContext (MFA + write gate): - * the GET/DELETE above predate that wrapper and are intentionally left as-is. + * immutability trigger is the backstop. */ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( 'bookkeeping.journal_entry.update', diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index 15abfaf5..1c1dc0c6 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -1,25 +1,19 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { createDraftEntry, createJournalEntry } from '@/lib/bookkeeping/engine' import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { CreateJournalEntrySchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard' ensureInitialized() -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +// Query params are hand-parsed with per-param clamping/regex validation (see +// each param's comment) rather than a Zod schema; response shapes are legacy +// `{ data, count }` / `{ error: string }` for the verifikat list UI. +export const GET = withRouteContext('bookkeeping.journal_entries.list', async (request, ctx) => { + const { supabase, companyId } = ctx const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -187,20 +181,12 @@ export async function GET(request: Request) { } return NextResponse.json({ data, count }) -} +}) -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) +export const POST = withRouteContext( + 'bookkeeping.journal_entries.create', + async (request, ctx) => { + const { supabase, companyId, user } = ctx const validation = await validateBody(request, CreateJournalEntrySchema) if (!validation.success) return validation.response @@ -222,4 +208,6 @@ export async function POST(request: Request) { { status: 400 } ) } -} + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/mapping-rules/evaluate/route.ts b/app/api/bookkeeping/mapping-rules/evaluate/route.ts index 6719e602..c53a87d1 100644 --- a/app/api/bookkeeping/mapping-rules/evaluate/route.ts +++ b/app/api/bookkeeping/mapping-rules/evaluate/route.ts @@ -1,22 +1,17 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine' import { validateBody } from '@/lib/api/validate' import { EvaluateMappingRulesSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' import type { Transaction } from '@/types' -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext('mapping_rules.evaluate', async (request, ctx) => { + const { supabase, companyId, log } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, EvaluateMappingRulesSchema) + const validation = await validateBody(request, EvaluateMappingRulesSchema, { + log, + operation: 'mapping_rules.evaluate', + }) if (!validation.success) return validation.response const body = validation.data @@ -37,6 +32,8 @@ export async function POST(request: Request) { transaction = data as Transaction } else { + // Schema-validated (amount required, passthrough for optional signal + // fields) — the mapping engine only reads the fields it knows. transaction = body as unknown as Transaction } @@ -49,4 +46,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/bookkeeping/mapping-rules/route.ts b/app/api/bookkeeping/mapping-rules/route.ts index 76f9dd3e..e94475a2 100644 --- a/app/api/bookkeeping/mapping-rules/route.ts +++ b/app/api/bookkeeping/mapping-rules/route.ts @@ -1,19 +1,10 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { CreateMappingRuleSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext('mapping_rules.list', async (_request, ctx) => { + const { supabase, companyId } = ctx const { data, error } = await supabase .from('mapping_rules') @@ -27,52 +18,51 @@ export async function GET() { } return NextResponse.json({ data }) -} +}) -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext( + 'mapping_rules.create', + async (request, ctx) => { + const { supabase, companyId, user, log } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const result = await validateBody(request, CreateMappingRuleSchema) - if (!result.success) return result.response - const body = result.data - - const { data, error } = await supabase - .from('mapping_rules') - .insert({ - user_id: user.id, - company_id: companyId, - rule_name: body.rule_name, - rule_type: body.rule_type, - priority: body.priority || 10, - mcc_codes: body.mcc_codes || null, - merchant_pattern: body.merchant_pattern || null, - description_pattern: body.description_pattern || null, - amount_min: body.amount_min || null, - amount_max: body.amount_max || null, - debit_account: body.debit_account, - credit_account: body.credit_account, - vat_treatment: body.vat_treatment || null, - risk_level: body.risk_level || 'NONE', - default_private: body.default_private || false, - requires_review: body.requires_review || false, - confidence_score: body.confidence_score || 0.9, + const result = await validateBody(request, CreateMappingRuleSchema, { + log, + operation: 'mapping_rules.create', }) - .select() - .single() + if (!result.success) return result.response + const body = result.data - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + const { data, error } = await supabase + .from('mapping_rules') + .insert({ + user_id: user.id, + company_id: companyId, + rule_name: body.rule_name, + rule_type: body.rule_type, + // ?? not || — the schema allows 0 for priority and confidence_score, + // and || would silently coerce those to the defaults. + priority: body.priority ?? 10, + mcc_codes: body.mcc_codes ?? null, + merchant_pattern: body.merchant_pattern ?? null, + description_pattern: body.description_pattern ?? null, + amount_min: body.amount_min ?? null, + amount_max: body.amount_max ?? null, + debit_account: body.debit_account, + credit_account: body.credit_account, + vat_treatment: body.vat_treatment ?? null, + risk_level: body.risk_level ?? 'NONE', + default_private: body.default_private ?? false, + requires_review: body.requires_review ?? false, + confidence_score: body.confidence_score ?? 0.9, + }) + .select() + .single() - return NextResponse.json({ data }) -} + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/no-doc-required/__tests__/route.test.ts b/app/api/bookkeeping/no-doc-required/__tests__/route.test.ts new file mode 100644 index 00000000..c75893d9 --- /dev/null +++ b/app/api/bookkeeping/no-doc-required/__tests__/route.test.ts @@ -0,0 +1,74 @@ +/** + * Tests for GET /api/bookkeeping/no-doc-required — the exemption-set list. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET } from '../route' + +const routeParams = { params: Promise.resolve({}) } + +function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) { + const calls: { method: string; args: unknown[] }[] = [] + let idx = 0 + const makeBuilder = () => { + const result = results[idx++] ?? { data: null, error: null } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b: any = {} + for (const m of ['select', 'eq', 'order', 'range']) { + b[m] = (...args: unknown[]) => { + calls.push({ method: m, args }) + return b + } + } + b.then = (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null, count: null }) + return b + } + return { supabase: { from: () => makeBuilder() }, calls } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/bookkeeping/no-doc-required', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await GET(createMockRequest('/api/bookkeeping/no-doc-required'), routeParams) + expect(res.status).toBe(401) + }) + + it('lists exemptions with a stable paging order', async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: [{ journal_entry_id: 'e1', reason: 'SIE-import' }] }, + ]) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + + const { status, body } = await parseJsonResponse<{ data: unknown[] }>( + await GET(createMockRequest('/api/bookkeeping/no-doc-required'), routeParams) + ) + + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + // Paging-stability regression guard (fetch-all.ts ordering invariant). + expect(calls.filter((c) => c.method === 'order').map((c) => c.args[0])).toContain( + 'journal_entry_id' + ) + }) +}) diff --git a/app/api/bookkeeping/no-doc-required/route.ts b/app/api/bookkeeping/no-doc-required/route.ts index 033e2df2..88cd3375 100644 --- a/app/api/bookkeeping/no-doc-required/route.ts +++ b/app/api/bookkeeping/no-doc-required/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { fetchAllRows } from '@/lib/supabase/fetch-all' /** @@ -9,24 +8,20 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all' * - exclude exempted entries from the "Saknade underlag" filter * - show a muted "no doc needed" indicator instead of the warning triangle */ -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext('journal_entry.no_doc_required.list', async (_request, ctx) => { + const { supabase, companyId } = ctx + // Stable unique order for .range() paging — bulk exemption after a large + // migration can push this table past the 1000-row page size. const rows = await fetchAllRows<{ journal_entry_id: string; reason: string | null }>( ({ from, to }) => supabase .from('journal_entry_no_doc_required') .select('journal_entry_id, reason') .eq('company_id', companyId) + .order('journal_entry_id', { ascending: true }) .range(from, to) ) return NextResponse.json({ data: rows }) -} +}) diff --git a/app/api/bookkeeping/voucher-gaps/route.ts b/app/api/bookkeeping/voucher-gaps/route.ts index cfb56b06..8d81a830 100644 --- a/app/api/bookkeeping/voucher-gaps/route.ts +++ b/app/api/bookkeeping/voucher-gaps/route.ts @@ -1,21 +1,19 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody, validateQuery } from '@/lib/api/validate' import { VoucherGapQuerySchema, SaveGapExplanationSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +// Voucher gap detection + explanations (BFNAR 2013:2 — gaps in voucher +// sequences must be documented). Response shapes are legacy `{ data }` / +// `{ error: string }`. - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const GET = withRouteContext('voucher_gaps.list', async (request, ctx) => { + const { supabase, companyId, log } = ctx - const companyId = await requireCompanyId(supabase, user.id) - - const validation = validateQuery(request, VoucherGapQuerySchema) + const validation = validateQuery(request, VoucherGapQuerySchema, { + log, + operation: 'voucher_gaps.list', + }) if (!validation.success) return validation.response const { fiscal_period_id, voucher_series } = validation.data @@ -30,7 +28,11 @@ export async function GET(request: Request) { seriesQuery = seriesQuery.eq('voucher_series', voucher_series) } - const { data: seriesRows } = await seriesQuery + const { data: seriesRows, error: seriesError } = await seriesQuery + if (seriesError) { + log.error('voucher series lookup failed', seriesError) + return NextResponse.json({ error: seriesError.message }, { status: 500 }) + } if (!seriesRows || seriesRows.length === 0) { return NextResponse.json({ @@ -53,15 +55,20 @@ export async function GET(request: Request) { p_series: row.voucher_series, }) - if (!gapsError && gaps && gaps.length > 0) { - for (const gap of gaps as Array<{ gap_start: number; gap_end: number }>) { - allGaps.push({ - series: row.voucher_series, - gap_start: gap.gap_start, - gap_end: gap.gap_end, - explanation: null, - }) - } + // A failing detection MUST surface — silently dropping the series would + // render "no gaps" on a compliance view when the check didn't run. + if (gapsError) { + log.error('detect_voucher_gaps failed', gapsError, { series: row.voucher_series }) + return NextResponse.json({ error: gapsError.message }, { status: 500 }) + } + + for (const gap of (gaps ?? []) as Array<{ gap_start: number; gap_end: number }>) { + allGaps.push({ + series: row.voucher_series, + gap_start: gap.gap_start, + gap_end: gap.gap_end, + explanation: null, + }) } } @@ -102,52 +109,49 @@ export async function GET(request: Request) { unexplainedGaps: unexplained, }, }) -} +}) -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext( + 'voucher_gaps.explain', + async (request, ctx) => { + const { supabase, companyId, user, log } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const validation = await validateBody(request, SaveGapExplanationSchema, { + log, + operation: 'voucher_gaps.explain', + }) + if (!validation.success) return validation.response + const { fiscal_period_id, voucher_series, gap_start, gap_end, explanation } = validation.data - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, SaveGapExplanationSchema) - if (!validation.success) return validation.response - const { fiscal_period_id, voucher_series, gap_start, gap_end, explanation } = validation.data - - // Upsert explanation (RLS enforces owner/admin role) - const { data, error } = await supabase - .from('voucher_gap_explanations') - .upsert( - { - company_id: companyId, - user_id: user.id, - fiscal_period_id, - voucher_series, - gap_start, - gap_end, - explanation, - }, - { onConflict: 'company_id,fiscal_period_id,voucher_series,gap_start,gap_end' } - ) - .select() - .single() - - if (error) { - if (error.code === '42501') { - return NextResponse.json( - { error: 'Only company owners and admins can document gap explanations' }, - { status: 403 } + // Upsert explanation (RLS enforces owner/admin role) + const { data, error } = await supabase + .from('voucher_gap_explanations') + .upsert( + { + company_id: companyId, + user_id: user.id, + fiscal_period_id, + voucher_series, + gap_start, + gap_end, + explanation, + }, + { onConflict: 'company_id,fiscal_period_id,voucher_series,gap_start,gap_end' } ) - } - return NextResponse.json({ error: error.message }, { status: 500 }) - } + .select() + .single() - return NextResponse.json({ data }) -} + if (error) { + if (error.code === '42501') { + return NextResponse.json( + { error: 'Only company owners and admins can document gap explanations' }, + { status: 403 } + ) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/calendar/feed/[token]/route.ts b/app/api/calendar/feed/[token]/route.ts index e1089491..cde71f76 100644 --- a/app/api/calendar/feed/[token]/route.ts +++ b/app/api/calendar/feed/[token]/route.ts @@ -1,6 +1,9 @@ import { createClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { generateCalendarFeed } from '@/lib/calendar/ics-generator' +import { createLogger } from '@/lib/logger' + +const log = createLogger('api/calendar/feed-token') // In-memory rate limiting: token -> { count, resetAt } const rateLimitMap = new Map() @@ -141,7 +144,7 @@ export async function GET( }, }) } catch (error) { - console.error('Error generating ICS feed:', error) + log.error('Error generating ICS feed', error as Error, { feedId: feed.id }) return new NextResponse('Failed to generate calendar feed', { status: 500 }) } } diff --git a/app/api/calendar/feed/__tests__/route.test.ts b/app/api/calendar/feed/__tests__/route.test.ts new file mode 100644 index 00000000..7cd45288 --- /dev/null +++ b/app/api/calendar/feed/__tests__/route.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for /api/calendar/feed (settings CRUD). + * + * The PUT hardening matters most: the previous implementation passed the raw + * JSON body into .update(), letting a caller set feed_token (token fixation + * on a public URL). The strict schema must reject any key beyond the two + * content toggles. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET, PUT } from '../route' + +const routeParams = { params: Promise.resolve({}) } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +describe('GET /api/calendar/feed', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await GET(createMockRequest('/api/calendar/feed'), routeParams) + expect(res.status).toBe(401) + }) + + it('returns the feed with generated URLs', async () => { + enqueue({ data: { id: 'feed-1', feed_token: 'tok-123', include_invoices: true } }) + + const { status, body } = await parseJsonResponse<{ + data: { webcalUrl: string; httpsUrl: string } + }>(await GET(createMockRequest('/api/calendar/feed'), routeParams)) + + expect(status).toBe(200) + expect(body.data.httpsUrl).toContain('/api/calendar/feed/tok-123') + expect(body.data.webcalUrl).toMatch(/^webcal:\/\//) + }) +}) + +describe('PUT /api/calendar/feed', () => { + it('rejects an attempt to set feed_token (token fixation) with 400', async () => { + const req = createMockRequest('/api/calendar/feed', { + method: 'PUT', + body: { feed_token: '11111111-1111-1111-1111-111111111111' }, + }) + const { status } = await parseJsonResponse(await PUT(req, routeParams)) + expect(status).toBe(400) + }) + + it('rejects an empty body with 400', async () => { + const req = createMockRequest('/api/calendar/feed', { method: 'PUT', body: {} }) + const { status } = await parseJsonResponse(await PUT(req, routeParams)) + expect(status).toBe(400) + }) + + it('updates the content toggles', async () => { + enqueue({ data: { id: 'feed-1', feed_token: 'tok-123', include_invoices: false } }) + + const req = createMockRequest('/api/calendar/feed', { + method: 'PUT', + body: { include_invoices: false }, + }) + const { status, body } = await parseJsonResponse<{ data: { include_invoices: boolean } }>( + await PUT(req, routeParams) + ) + expect(status).toBe(200) + expect(body.data.include_invoices).toBe(false) + }) +}) diff --git a/app/api/calendar/feed/route.ts b/app/api/calendar/feed/route.ts index aa0b577a..ccab3b68 100644 --- a/app/api/calendar/feed/route.ts +++ b/app/api/calendar/feed/route.ts @@ -1,23 +1,37 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -import type { UpdateCalendarFeedInput } from '@/types' +import { z } from 'zod' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' + +// Only the two content toggles are user-settable. Strict: the previous +// implementation passed the raw JSON body into .update(), which would have +// let a caller set feed_token (token fixation on a public URL), expires_at, +// or access_count. +const UpdateFeedSchema = z + .object({ + include_tax_deadlines: z.boolean().optional(), + include_invoices: z.boolean().optional(), + }) + .strict() + .refine( + (v) => v.include_tax_deadlines !== undefined || v.include_invoices !== undefined, + { message: 'Nothing to update' }, + ) + +function feedUrls(feedToken: string) { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se' + return { + webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feedToken}`, + httpsUrl: `${baseUrl}/api/calendar/feed/${feedToken}`, + } +} /** * GET /api/calendar/feed * Get current user's calendar feed settings */ -export async function GET() { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext('calendar_feed.get', async (_request, ctx) => { + const { supabase, companyId } = ctx const { data: feed, error } = await supabase .from('calendar_feeds') @@ -30,167 +44,123 @@ export async function GET() { return NextResponse.json({ error: error.message }, { status: 500 }) } - // Generate the feed URL - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se' - if (feed) { return NextResponse.json({ - data: { - ...feed, - // Generate webcal:// URL for Apple Calendar - webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`, - // Generate https:// URL for other calendars - httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`, - }, + data: { ...feed, ...feedUrls(feed.feed_token) }, }) } return NextResponse.json({ data: null }) -} +}) /** * POST /api/calendar/feed * Create a new calendar feed for the current user */ -export async function POST() { - const supabase = await createClient() +export const POST = withRouteContext( + 'calendar_feed.create', + async (_request, ctx) => { + const { supabase, companyId, user } = ctx - const { data: { user } } = await supabase.auth.getUser() + // Check if feed already exists + const { data: existingFeed } = await supabase + .from('calendar_feeds') + .select('id') + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (existingFeed) { + return NextResponse.json( + { error: 'Calendar feed already exists' }, + { status: 409 } + ) + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Create new feed + const { data: feed, error } = await supabase + .from('calendar_feeds') + .insert({ + user_id: user.id, + company_id: companyId, + is_active: true, + include_tax_deadlines: true, + include_invoices: true, + }) + .select() + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - // Check if feed already exists - const { data: existingFeed } = await supabase - .from('calendar_feeds') - .select('id') - .eq('company_id', companyId) - .single() - - if (existingFeed) { - return NextResponse.json( - { error: 'Calendar feed already exists' }, - { status: 409 } - ) - } - - // Create new feed - const { data: feed, error } = await supabase - .from('calendar_feeds') - .insert({ - user_id: user.id, - company_id: companyId, - is_active: true, - include_tax_deadlines: true, - include_invoices: true, + return NextResponse.json({ + data: { ...feed, ...feedUrls(feed.feed_token) }, }) - .select() - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se' - - return NextResponse.json({ - data: { - ...feed, - webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`, - httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`, - }, - }) -} + }, + { requireWrite: true }, +) /** * PUT /api/calendar/feed * Update calendar feed settings */ -export async function PUT(request: Request) { - const supabase = await createClient() +export const PUT = withRouteContext( + 'calendar_feed.update', + async (request, ctx) => { + const { supabase, companyId, log } = ctx - const { data: { user } } = await supabase.auth.getUser() + const validation = await validateBody(request, UpdateFeedSchema, { + log, + operation: 'calendar_feed.update', + }) + if (!validation.success) return validation.response - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data: feed, error } = await supabase + .from('calendar_feeds') + .update(validation.data) + .eq('company_id', companyId) + .select() + .single() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const body: UpdateCalendarFeedInput = await request.json() - - const { data: feed, error } = await supabase - .from('calendar_feeds') - .update(body) - .eq('company_id', companyId) - .select() - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se' - - return NextResponse.json({ - data: { - ...feed, - webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`, - httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`, - }, - }) -} + return NextResponse.json({ + data: { ...feed, ...feedUrls(feed.feed_token) }, + }) + }, + { requireWrite: true }, +) /** * DELETE /api/calendar/feed * Regenerate calendar feed token (invalidates old URL) */ -export async function DELETE() { - const supabase = await createClient() +export const DELETE = withRouteContext( + 'calendar_feed.rotate_token', + async (_request, ctx) => { + const { supabase, companyId } = ctx - const { data: { user } } = await supabase.auth.getUser() + // Generate a new token by updating with a new UUID + const { data: feed, error } = await supabase + .from('calendar_feeds') + .update({ + feed_token: crypto.randomUUID(), + access_count: 0, + last_accessed_at: null, + }) + .eq('company_id', companyId) + .select() + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - // Generate a new token by updating with a new UUID - const { data: feed, error } = await supabase - .from('calendar_feeds') - .update({ - feed_token: crypto.randomUUID(), - access_count: 0, - last_accessed_at: null, + return NextResponse.json({ + data: { ...feed, ...feedUrls(feed.feed_token) }, }) - .eq('company_id', companyId) - .select() - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se' - - return NextResponse.json({ - data: { - ...feed, - webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`, - httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`, - }, - }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/cash-accounts/route.ts b/app/api/cash-accounts/route.ts index 54cb6854..478059c1 100644 --- a/app/api/cash-accounts/route.ts +++ b/app/api/cash-accounts/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { getActiveCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { listForCompany } from '@/lib/cash-accounts/service' /** @@ -15,19 +14,12 @@ import { listForCompany } from '@/lib/cash-accounts/service' * Query params: * - enabled_only=true → only accounts with enabled=true (default returns all) */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await getActiveCompanyId(supabase, user.id) - if (!companyId) { - return NextResponse.json({ error: 'No company context' }, { status: 400 }) - } +export const GET = withRouteContext('cash_accounts.list', async (request, ctx) => { + const { supabase, companyId } = ctx const url = new URL(request.url) const enabledOnly = url.searchParams.get('enabled_only') === 'true' const accounts = await listForCompany(supabase, companyId, { enabledOnly }) return NextResponse.json({ data: accounts }) -} +}) diff --git a/app/api/company/[id]/delete/route.ts b/app/api/company/[id]/delete/route.ts index dbdccfc5..2c69db0a 100644 --- a/app/api/company/[id]/delete/route.ts +++ b/app/api/company/[id]/delete/route.ts @@ -25,10 +25,11 @@ const DeleteCompanySchema = z.object({ * * Rules: * - Only callers with role='owner' in company_members may delete. - * - The body must include confirm_name matching the company's display name. - * The UI shows company_settings.company_name (companies.name may be stale), - * so we validate against that, falling back to companies.name. Either value - * is accepted so the confirm gate never blocks a legitimate deletion. + * - The body must include confirm_name matching the company's display name + * exactly as the UI shows it: company_settings.company_name, falling back + * to companies.name only when no settings row exists. ONLY that single + * name is accepted (see step 3) — accepting alternates would weaken the + * confirmation gate on an irreversible action. * - Already-archived companies return 404 (treated as not found). */ export async function POST( @@ -137,7 +138,9 @@ export async function POST( // 6. Write audit log row. companies has no auto-audit trigger, so do it // explicitly. Service client bypasses audit_log RLS (no INSERT policy). - await service.from('audit_log').insert({ + // The archive already happened — don't fail the request, but an audit + // write failing on an irreversible action must never be silent. + const { error: auditError } = await service.from('audit_log').insert({ user_id: user.id, company_id: companyId, action: 'DELETE', @@ -148,6 +151,12 @@ export async function POST( new_state: { archived_at: archivedAt, archived_by: user.id }, description: `Company archived: ${company.name}`, }) + if (auditError) { + log.error('Failed to write audit_log row for company archive', { + companyId, + error: auditError.message, + }) + } // 7. Emit event await eventBus.emit({ diff --git a/app/api/company/current/__tests__/route.test.ts b/app/api/company/current/__tests__/route.test.ts new file mode 100644 index 00000000..32153563 --- /dev/null +++ b/app/api/company/current/__tests__/route.test.ts @@ -0,0 +1,89 @@ +/** + * Tests for /api/company/current — GET (cross-tab sync) and PATCH (K2/K3). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +const getActiveCompanyIdMock = vi.fn() +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: (...args: unknown[]) => getActiveCompanyIdMock(...args), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET, PATCH } from '../route' + +const routeParams = { params: Promise.resolve({}) } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + getActiveCompanyIdMock.mockResolvedValue('company-1') +}) + +describe('GET /api/company/current', () => { + it('returns 401 with no-store when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await GET() + expect(res.status).toBe(401) + expect(res.headers.get('Cache-Control')).toBe('private, no-store') + }) + + it('returns null companyId when the user has no active company', async () => { + getActiveCompanyIdMock.mockResolvedValue(null) + const { status, body } = await parseJsonResponse<{ companyId: string | null }>(await GET()) + expect(status).toBe(200) + expect(body.companyId).toBeNull() + }) +}) + +describe('PATCH /api/company/current', () => { + it('rejects K3 for enskild firma with 400', async () => { + enqueue({ data: { entity_type: 'enskild_firma' } }) + + const req = createMockRequest('/api/company/current', { + method: 'PATCH', + body: { accounting_framework: 'k3' }, + }) + const { status, body } = await parseJsonResponse<{ error: string }>( + await PATCH(req, routeParams) + ) + expect(status).toBe(400) + expect(body.error).toContain('aktiebolag') + }) + + it('updates the framework for an aktiebolag', async () => { + enqueue({ data: { entity_type: 'aktiebolag' } }) // entity check + enqueue({ data: { id: 'company-1', accounting_framework: 'k3', entity_type: 'aktiebolag' } }) // update + enqueue({ data: null }) // K3 latent-tax account upsert + + const req = createMockRequest('/api/company/current', { + method: 'PATCH', + body: { accounting_framework: 'k3' }, + }) + const { status, body } = await parseJsonResponse<{ + data: { accounting_framework: string } + }>(await PATCH(req, routeParams)) + + expect(status).toBe(200) + expect(body.data.accounting_framework).toBe('k3') + }) +}) diff --git a/app/api/company/current/route.ts b/app/api/company/current/route.ts index ed05df96..5776127d 100644 --- a/app/api/company/current/route.ts +++ b/app/api/company/current/route.ts @@ -1,6 +1,6 @@ -import { createClient } from '@/lib/supabase/server' -import { getActiveCompanyId, requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { getActiveCompanyId } from '@/lib/company/context' +import { requireAuth } from '@/lib/auth/require-auth' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { AccountingFrameworkSchema } from '@/lib/api/schemas' import { getBASReference } from '@/lib/bookkeeping/bas-reference' @@ -27,20 +27,17 @@ const K3_LATENT_TAX_ACCOUNTS = ['2240', '8940'] as const * * Never cached: the whole point is that the response reflects the current * authoritative value in user_preferences. + * + * Uses requireAuth() directly (not withRouteContext): a null companyId is a + * valid answer here — the wrapper would short-circuit it into an error. */ export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json( - { error: 'Unauthorized' }, - { - status: 401, - headers: { 'Cache-Control': 'private, no-store' }, - }, - ) + const auth = await requireAuth() + if (auth.error) { + auth.error.headers.set('Cache-Control', 'private, no-store') + return auth.error } + const { user, supabase } = auth const companyId = await getActiveCompanyId(supabase, user.id) @@ -72,17 +69,10 @@ const PatchBodySchema = z.object({ * entity_type='aktiebolag'. The handler rejects K3 for non-AB to prevent * impossible chart-of-accounts states downstream. */ -export async function PATCH(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) +export const PATCH = withRouteContext( + 'company.update_current', + async (request, ctx) => { + const { supabase, companyId, user } = ctx const validation = await validateBody(request, PatchBodySchema) if (!validation.success) return validation.response @@ -188,4 +178,6 @@ export async function PATCH(request: Request) { } return NextResponse.json({ data }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/company/members/[id]/route.ts b/app/api/company/members/[id]/route.ts index d82ce09f..fd32f126 100644 --- a/app/api/company/members/[id]/route.ts +++ b/app/api/company/members/[id]/route.ts @@ -1,7 +1,6 @@ -import { createClient, createServiceClient } from '@/lib/supabase/server' +import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' /** * DELETE /api/company/members/[id] @@ -9,18 +8,10 @@ import { requireWritePermission } from '@/lib/auth/require-write' * Only company owners and admins can remove members. * Cannot remove team-sourced members (they must be removed from the team). */ -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'company_members.remove', + async (_request, ctx, { params }) => { + const { companyId, user } = ctx const { id: memberId } = await params const serviceClient = await createServiceClient() @@ -87,4 +78,6 @@ export async function DELETE( } return NextResponse.json({ data: { removed: memberId } }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/company/members/invite/[id]/route.ts b/app/api/company/members/invite/[id]/route.ts index 75ec17bf..3f66954a 100644 --- a/app/api/company/members/invite/[id]/route.ts +++ b/app/api/company/members/invite/[id]/route.ts @@ -1,25 +1,16 @@ -import { createClient, createServiceClient } from '@/lib/supabase/server' +import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' /** * DELETE /api/company/members/invite/[id] * Revoke a pending company invitation. * Only company owners and admins can revoke. */ -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'company_members.revoke_invite', + async (_request, ctx, { params }) => { + const { companyId, user } = ctx const { id: inviteId } = await params const serviceClient = await createServiceClient() @@ -63,4 +54,6 @@ export async function DELETE( } return NextResponse.json({ data: { revoked: inviteId } }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/company/members/invite/__tests__/route.test.ts b/app/api/company/members/invite/__tests__/route.test.ts new file mode 100644 index 00000000..f48a7b9d --- /dev/null +++ b/app/api/company/members/invite/__tests__/route.test.ts @@ -0,0 +1,142 @@ +/** + * Tests for POST /api/company/members/invite. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: () => serviceSupabase, +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +vi.mock('@/lib/auth/invite-tokens', () => ({ + generateInviteToken: () => ({ token: 'tok-plain', hash: 'tok-hash' }), + getInviteExpiry: () => new Date('2026-08-01T00:00:00Z'), +})) + +const sendEmailMock = vi.fn() +const isConfiguredMock = vi.fn() +vi.mock('@/lib/email/service', () => ({ + getEmailService: () => ({ isConfigured: isConfiguredMock, sendEmail: sendEmailMock }), +})) + +vi.mock('@/lib/email/invite-templates', () => ({ + generateInviteEmailSubject: () => 'subject', + generateInviteEmailHtml: () => '

html

', + generateInviteEmailText: () => 'text', +})) + +import { POST } from '../route' + +const routeParams = { params: Promise.resolve({}) } + +function post(body: unknown) { + return POST( + createMockRequest('/api/company/members/invite', { method: 'POST', body }), + routeParams, + ) +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1', email: 'owner@example.com' }, + supabase: {}, + error: null, + }) + requireWriteMock.mockResolvedValue({ ok: true }) + isConfiguredMock.mockReturnValue(true) + sendEmailMock.mockResolvedValue({ success: true, messageId: 'msg-1' }) +}) + +describe('POST /api/company/members/invite', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await post({ email: 'x@y.se' }) + expect(res.status).toBe(401) + }) + + it('refuses non-admin members with 403', async () => { + enqueue({ data: { role: 'member' } }) // caller membership + + const { status, body } = await parseJsonResponse<{ error: string }>( + await post({ email: 'x@y.se' }) + ) + expect(status).toBe(403) + expect(body.error).toBe('Behörighet saknas.') + }) + + it('rejects an invalid email with 400', async () => { + enqueue({ data: { role: 'owner' } }) + const { status } = await parseJsonResponse(await post({ email: 'not-an-email' })) + expect(status).toBe(400) + }) + + it('rejects an unknown role with 400', async () => { + enqueue({ data: { role: 'owner' } }) + const { status } = await parseJsonResponse( + await post({ email: 'x@y.se', role: 'superuser' }) + ) + expect(status).toBe(400) + }) + + it('creates the invitation and reports email_sent', async () => { + enqueue({ data: { role: 'owner' } }) // caller membership + enqueue({ data: [] }) // existing members + enqueue({ data: null }) // existing invite + enqueue({ data: { name: 'Acme AB' } }) // company name + enqueue({ data: null }) // insert invitation + + const { status, body } = await parseJsonResponse<{ + data: { email: string; email_sent: boolean } + }>(await post({ email: 'Client@Example.com', role: 'viewer' })) + + expect(status).toBe(200) + expect(body.data.email).toBe('client@example.com') // normalized + expect(body.data.email_sent).toBe(true) + expect(sendEmailMock).toHaveBeenCalledWith( + expect.objectContaining({ to: 'client@example.com' }) + ) + }) + + it('reports email_sent=false when the send fails (invite still created)', async () => { + enqueue({ data: { role: 'owner' } }) + enqueue({ data: [] }) + enqueue({ data: null }) + enqueue({ data: { name: 'Acme AB' } }) + enqueue({ data: null }) + sendEmailMock.mockResolvedValue({ success: false, error: 'smtp down' }) + + const { status, body } = await parseJsonResponse<{ + data: { email_sent: boolean; status: string } + }>(await post({ email: 'client@example.com' })) + + expect(status).toBe(200) + expect(body.data.status).toBe('pending') + expect(body.data.email_sent).toBe(false) + }) +}) diff --git a/app/api/company/members/invite/route.ts b/app/api/company/members/invite/route.ts index 8af4dc3a..0f03b76e 100644 --- a/app/api/company/members/invite/route.ts +++ b/app/api/company/members/invite/route.ts @@ -1,8 +1,9 @@ -import { createClient, createServiceClient } from '@/lib/supabase/server' +import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { z } from 'zod' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' import { generateInviteToken, getInviteExpiry } from '@/lib/auth/invite-tokens' import { getEmailService } from '@/lib/email/service' import { @@ -17,168 +18,163 @@ import { // init'd route in the process. ensureInitialized() +const InviteSchema = z.object({ + email: z.string().trim().toLowerCase().pipe(z.string().email('Ogiltig e-postadress.')), + role: z.enum(['admin', 'member', 'viewer']).default('viewer'), +}) + /** * POST /api/company/members/invite * Invite a user to the current company (e.g., a client as viewer). * Only company owners and admins can invite. */ -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext( + 'company_members.invite', + async (request, ctx) => { + const { companyId, user, log } = ctx + const serviceClient = await createServiceClient() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Check caller has permission (owner/admin — stricter than requireWrite) + const { data: callerMembership } = await serviceClient + .from('company_members') + .select('role') + .eq('company_id', companyId) + .eq('user_id', user.id) + .single() - const companyId = await requireCompanyId(supabase, user.id) - const serviceClient = await createServiceClient() - - // Check caller has permission - const { data: callerMembership } = await serviceClient - .from('company_members') - .select('role') - .eq('company_id', companyId) - .eq('user_id', user.id) - .single() - - if (!callerMembership || !['owner', 'admin'].includes(callerMembership.role)) { - return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 }) - } - - const body = await request.json() - const email = (body.email as string || '').trim().toLowerCase() - const role = (body.role as string) || 'viewer' - - if (!email || !email.includes('@')) { - return NextResponse.json({ error: 'Ogiltig e-postadress.' }, { status: 400 }) - } - - if (!['admin', 'member', 'viewer'].includes(role)) { - return NextResponse.json({ error: 'Ogiltig roll.' }, { status: 400 }) - } - - // Check if email is already a member of this company - const { data: existingMembers } = await serviceClient - .from('company_members') - .select('id, user_id') - .eq('company_id', companyId) - - if (existingMembers && existingMembers.length > 0) { - const memberUserIds = existingMembers.map((m) => m.user_id) - const { data: memberProfiles } = await serviceClient - .from('profiles') - .select('id, email') - .in('id', memberUserIds) - - const alreadyMember = memberProfiles?.some( - (p) => p.email?.toLowerCase() === email - ) - if (alreadyMember) { - return NextResponse.json({ error: 'Denna person är redan medlem.' }, { status: 409 }) - } - } - - // Check for existing pending invite - const { data: existingInvite } = await serviceClient - .from('company_invitations') - .select('id, status') - .eq('company_id', companyId) - .eq('email', email) - .single() - - if (existingInvite && existingInvite.status === 'pending') { - return NextResponse.json({ error: 'En inbjudan har redan skickats till denna e-post.' }, { status: 409 }) - } - - // Get company name for the email - const { data: company } = await serviceClient - .from('companies') - .select('name') - .eq('id', companyId) - .single() - - // Generate token - const { token, hash } = generateInviteToken() - const expiresAt = getInviteExpiry() - - // Upsert invitation - if (existingInvite) { - const { error } = await serviceClient - .from('company_invitations') - .update({ - token_hash: hash, - invited_by: user.id, - status: 'pending', - expires_at: expiresAt.toISOString(), - role, - }) - .eq('id', existingInvite.id) - - if (error) { - return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 }) - } - } else { - const { error } = await serviceClient - .from('company_invitations') - .insert({ - company_id: companyId, - email, - role, - token_hash: hash, - invited_by: user.id, - status: 'pending', - expires_at: expiresAt.toISOString(), - }) - - if (error) { - return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 }) - } - } - - // Send email - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' - const emailService = getEmailService() - if (emailService.isConfigured()) { - const inviteUrl = `${appUrl}/invite/${token}` - - const emailData = { - companyName: company?.name || 'Företag', - inviterEmail: user.email || '', - inviteUrl, + if (!callerMembership || !['owner', 'admin'].includes(callerMembership.role)) { + return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 }) } - console.log('[company/members/invite] sending email', { - to: email, - company: emailData.companyName, - from: user.email, + const validation = await validateBody(request, InviteSchema, { + log, + operation: 'company_members.invite', }) + if (!validation.success) return validation.response + const { email, role } = validation.data - const result = await emailService.sendEmail({ - to: email, - subject: generateInviteEmailSubject(emailData), - html: generateInviteEmailHtml(emailData), - text: generateInviteEmailText(emailData), - }) + // Check if email is already a member of this company + const { data: existingMembers } = await serviceClient + .from('company_members') + .select('id, user_id') + .eq('company_id', companyId) - if (result.success) { - console.log('[company/members/invite] email sent', { - to: email, - messageId: result.messageId, - }) + if (existingMembers && existingMembers.length > 0) { + const memberUserIds = existingMembers.map((m) => m.user_id) + const { data: memberProfiles } = await serviceClient + .from('profiles') + .select('id, email') + .in('id', memberUserIds) + + const alreadyMember = memberProfiles?.some( + (p) => p.email?.toLowerCase() === email + ) + if (alreadyMember) { + return NextResponse.json({ error: 'Denna person är redan medlem.' }, { status: 409 }) + } + } + + // Check for existing pending invite + const { data: existingInvite } = await serviceClient + .from('company_invitations') + .select('id, status') + .eq('company_id', companyId) + .eq('email', email) + .single() + + if (existingInvite && existingInvite.status === 'pending') { + return NextResponse.json({ error: 'En inbjudan har redan skickats till denna e-post.' }, { status: 409 }) + } + + // Get company name for the email + const { data: company } = await serviceClient + .from('companies') + .select('name') + .eq('id', companyId) + .single() + + // Generate token + const { token, hash } = generateInviteToken() + const expiresAt = getInviteExpiry() + + // Upsert invitation + if (existingInvite) { + const { error } = await serviceClient + .from('company_invitations') + .update({ + token_hash: hash, + invited_by: user.id, + status: 'pending', + expires_at: expiresAt.toISOString(), + role, + }) + .eq('id', existingInvite.id) + + if (error) { + return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 }) + } } else { - console.error('[company/members/invite] email send failed:', result.error) + const { error } = await serviceClient + .from('company_invitations') + .insert({ + company_id: companyId, + email, + role, + token_hash: hash, + invited_by: user.id, + status: 'pending', + expires_at: expiresAt.toISOString(), + }) + + if (error) { + return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 }) + } } - } else { - console.warn('[company/members/invite] email service not configured: skipping send', { - to: email, + + // Send email. email_sent is surfaced in the response so the UI can tell + // the user when the invitation exists but the mail never went out: + // previously a send failure was invisible (invite looked sent). + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + const emailService = getEmailService() + let emailSent = false + if (emailService.isConfigured()) { + const inviteUrl = `${appUrl}/invite/${token}` + + const emailData = { + companyName: company?.name || 'Företag', + inviterEmail: user.email || '', + inviteUrl, + } + + const result = await emailService.sendEmail({ + to: email, + subject: generateInviteEmailSubject(emailData), + html: generateInviteEmailHtml(emailData), + text: generateInviteEmailText(emailData), + }) + + if (result.success) { + emailSent = true + log.info('invite email sent', { to: email, messageId: result.messageId }) + } else { + log.error('invite email send failed', new Error(result.error ?? 'unknown'), { to: email }) + } + } else { + log.warn('email service not configured: invite email skipped', { to: email }) + } + + // In development, return the invite URL directly (no email service) + const isDev = process.env.NODE_ENV === 'development' + const devInviteUrl = isDev ? `${appUrl}/invite/${token}` : undefined + + return NextResponse.json({ + data: { + email, + status: 'pending', + email_sent: emailSent, + ...(isDev && { inviteUrl: devInviteUrl }), + }, }) - } - - // In development, return the invite URL directly (no email service) - const isDev = process.env.NODE_ENV === 'development' - const devInviteUrl = isDev ? `${appUrl}/invite/${token}` : undefined - - return NextResponse.json({ - data: { email, status: 'pending', ...(isDev && { inviteUrl: devInviteUrl }) }, - }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/company/members/route.ts b/app/api/company/members/route.ts index 946ee84f..45a636cd 100644 --- a/app/api/company/members/route.ts +++ b/app/api/company/members/route.ts @@ -1,17 +1,15 @@ -import { createClient, createServiceClient } from '@/lib/supabase/server' +import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' /** * GET /api/company/members * Returns members and pending invitations for the current company. + * Service client on purpose: profiles/emails of other members aren't readable + * through the caller's RLS context; every query still scopes by companyId. */ -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext('company_members.list', async (_request, ctx) => { + const { companyId, user } = ctx const serviceClient = await createServiceClient() // Fetch members (source column may not exist if migration not yet applied) @@ -77,4 +75,4 @@ export async function GET() { canInvite, }, }) -} +}) diff --git a/app/api/company/route.ts b/app/api/company/route.ts index 10c77b41..bd86ee21 100644 --- a/app/api/company/route.ts +++ b/app/api/company/route.ts @@ -1,5 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' /** * GET /api/company?owned=true&archived=false @@ -9,12 +9,13 @@ import { NextResponse } from 'next/server' * - archived=false → only non-archived companies (default) * * Used by the account danger zone to show a blockers list before - * allowing account deletion. + * allowing account deletion. User-level (spans ALL memberships), so it uses + * requireAuth() directly — no single active-company context applies. */ export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const auth = await requireAuth() + if (auth.error) return auth.error + const { user, supabase } = auth const url = new URL(request.url) const ownedOnly = url.searchParams.get('owned') === 'true' diff --git a/app/api/currency/rate/route.ts b/app/api/currency/rate/route.ts index 16997579..9dda02a4 100644 --- a/app/api/currency/rate/route.ts +++ b/app/api/currency/rate/route.ts @@ -1,26 +1,17 @@ import { NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { fetchExchangeRate } from '@/lib/currency/riksbanken' -import { getActiveCompanyId } from '@/lib/company/context' import { guardSandbox } from '@/lib/sandbox/guard' import type { Currency } from '@/types' const VALID_CURRENCIES: Currency[] = ['EUR', 'USD', 'GBP', 'NOK', 'DKK'] -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +// Riksbanken's open API is IP rate-limited — the sandbox guard keeps demo +// traffic from eating that budget (withRouteContext already refuses +// sessions without an active company). +export const GET = withRouteContext('currency.rate', async (request, ctx) => { + const { supabase, companyId } = ctx - const companyId = await getActiveCompanyId(supabase, user.id) - // Refuse the request when no active company resolves rather than letting - // a session without one slip past the sandbox guard. Riksbanken's open - // API is IP rate-limited; we don't want demo traffic eating that budget. - if (!companyId) { - return NextResponse.json({ error: 'No active company' }, { status: 400 }) - } const blocked = await guardSandbox(supabase, companyId) if (blocked) return blocked @@ -32,6 +23,12 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Invalid currency' }, { status: 400 }) } + // Reject malformed dates up front — an Invalid Date would otherwise reach + // the Riksbanken request as "NaN-NaN-NaN". + if (dateStr && !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + return NextResponse.json({ error: 'Invalid date (expected YYYY-MM-DD)' }, { status: 400 }) + } + const date = dateStr ? new Date(dateStr) : undefined const rate = await fetchExchangeRate(currency, date) @@ -40,4 +37,4 @@ export async function GET(request: Request) { } return NextResponse.json({ data: rate }) -} +}) diff --git a/app/api/deadlines/[id]/__tests__/route.test.ts b/app/api/deadlines/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..91c39265 --- /dev/null +++ b/app/api/deadlines/[id]/__tests__/route.test.ts @@ -0,0 +1,123 @@ +/** + * Tests for /api/deadlines/[id] — validated PUT and count-checked DELETE. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { PUT, DELETE } from '../route' + +const idParams = { params: Promise.resolve({ id: 'deadline-1' }) } + +function createCapturingSupabase( + results: { data?: unknown; error?: unknown; count?: number | null }[] +) { + let idx = 0 + const makeBuilder = () => { + const result = results[idx++] ?? { data: null, error: null, count: null } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b: any = {} + for (const m of ['select', 'eq', 'update', 'delete', 'single', 'maybeSingle']) { + b[m] = () => b + } + b.then = (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null, count: result.count ?? null }) + return b + } + return { from: () => makeBuilder() } +} + +beforeEach(() => { + vi.clearAllMocks() + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +function auth(supabase: unknown) { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +describe('PUT /api/deadlines/[id]', () => { + it('rejects a malformed body (bad due_date) with 400', async () => { + auth(createCapturingSupabase([])) + const req = createMockRequest('/api/deadlines/deadline-1', { + method: 'PUT', + body: { due_date: 'banana' }, + }) + const { status } = await parseJsonResponse(await PUT(req, idParams)) + expect(status).toBe(400) + }) + + it('rejects an empty body with 400', async () => { + auth(createCapturingSupabase([])) + const req = createMockRequest('/api/deadlines/deadline-1', { method: 'PUT', body: {} }) + const { status } = await parseJsonResponse(await PUT(req, idParams)) + expect(status).toBe(400) + }) + + it('maps zero-rows to 404', async () => { + auth(createCapturingSupabase([{ error: { code: 'PGRST116', message: 'no rows' } }])) + const req = createMockRequest('/api/deadlines/deadline-1', { + method: 'PUT', + body: { title: 'Momsdeklaration Q3' }, + }) + const { status } = await parseJsonResponse(await PUT(req, idParams)) + expect(status).toBe(404) + }) + + it('updates the deadline', async () => { + auth(createCapturingSupabase([{ data: { id: 'deadline-1', title: 'Momsdeklaration Q3' } }])) + const req = createMockRequest('/api/deadlines/deadline-1', { + method: 'PUT', + body: { title: 'Momsdeklaration Q3' }, + }) + const { status, body } = await parseJsonResponse<{ data: { title: string } }>( + await PUT(req, idParams) + ) + expect(status).toBe(200) + expect(body.data.title).toBe('Momsdeklaration Q3') + }) +}) + +describe('DELETE /api/deadlines/[id]', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams) + expect(res.status).toBe(401) + }) + + it('returns 404 instead of phantom success when no row matches', async () => { + auth(createCapturingSupabase([{ count: 0 }])) + const { status } = await parseJsonResponse( + await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams) + ) + expect(status).toBe(404) + }) + + it('deletes the deadline', async () => { + auth(createCapturingSupabase([{ count: 1 }])) + const { status, body } = await parseJsonResponse<{ success: boolean }>( + await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams) + ) + expect(status).toBe(200) + expect(body.success).toBe(true) + }) +}) diff --git a/app/api/deadlines/[id]/complete/route.ts b/app/api/deadlines/[id]/complete/route.ts index 9d079385..cd554cbd 100644 --- a/app/api/deadlines/[id]/complete/route.ts +++ b/app/api/deadlines/[id]/complete/route.ts @@ -1,63 +1,49 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' /** * POST /api/deadlines/[id]/complete * Toggle completion status of a deadline */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'deadline.toggle_complete', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const { - data: { user }, - } = await supabase.auth.getUser() + // First, get current deadline state + const { data: existing, error: fetchError } = await supabase + .from('deadlines') + .select('is_completed') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - // First, get current deadline state - const { data: existing, error: fetchError } = await supabase - .from('deadlines') - .select('is_completed') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (fetchError) { - if (fetchError.code === 'PGRST116') { - return NextResponse.json({ error: 'Deadline not found' }, { status: 404 }) + if (fetchError) { + if (fetchError.code === 'PGRST116') { + return NextResponse.json({ error: 'Deadline not found' }, { status: 404 }) + } + return NextResponse.json({ error: fetchError.message }, { status: 500 }) } - return NextResponse.json({ error: fetchError.message }, { status: 500 }) - } - // Toggle completion - const newCompletedState = !existing.is_completed - const { data, error } = await supabase - .from('deadlines') - .update({ - is_completed: newCompletedState, - completed_at: newCompletedState ? new Date().toISOString() : null, - }) - .eq('id', id) - .eq('company_id', companyId) - .select('*, customer:customers(id, name)') - .single() + // Toggle completion + const newCompletedState = !existing.is_completed + const { data, error } = await supabase + .from('deadlines') + .update({ + is_completed: newCompletedState, + completed_at: newCompletedState ? new Date().toISOString() : null, + }) + .eq('id', id) + .eq('company_id', companyId) + .select('*, customer:customers(id, name)') + .single() - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/deadlines/[id]/route.ts b/app/api/deadlines/[id]/route.ts index 0285b55a..f5720af9 100644 --- a/app/api/deadlines/[id]/route.ts +++ b/app/api/deadlines/[id]/route.ts @@ -1,147 +1,118 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -import type { CreateDeadlineInput } from '@/types' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { CreateDeadlineSchema } from '@/lib/api/schemas' + +// Sparse update: every Create field, optional. Validated — the previous +// implementation type-asserted the raw JSON, so malformed values reached +// Postgres and malformed JSON crashed the handler. +const UpdateDeadlineSchema = CreateDeadlineSchema.partial() /** * GET /api/deadlines/[id] * Get a single deadline by ID */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'deadline.get', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const { - data: { user }, - } = await supabase.auth.getUser() + const { data, error } = await supabase + .from('deadlines') + .select('*, customer:customers(id, name)') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - const { data, error } = await supabase - .from('deadlines') - .select('*, customer:customers(id, name)') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (error) { - if (error.code === 'PGRST116') { - return NextResponse.json({ error: 'Deadline not found' }, { status: 404 }) + if (error) { + if (error.code === 'PGRST116') { + return NextResponse.json({ error: 'Deadline not found' }, { status: 404 }) + } + return NextResponse.json({ error: error.message }, { status: 500 }) } - return NextResponse.json({ error: error.message }, { status: 500 }) - } - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, +) /** * PUT /api/deadlines/[id] * Update a deadline */ -export async function PUT( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>( + 'deadline.update', + async (request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId, log } = ctx - const { - data: { user }, - } = await supabase.auth.getUser() + const validation = await validateBody(request, UpdateDeadlineSchema, { + log, + operation: 'deadline.update', + }) + if (!validation.success) return validation.response + const body = validation.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + // Build update object + const updateData: Record = {} + if (body.title !== undefined) updateData.title = body.title + if (body.due_date !== undefined) updateData.due_date = body.due_date + if (body.due_time !== undefined) updateData.due_time = body.due_time + if (body.deadline_type !== undefined) updateData.deadline_type = body.deadline_type + if (body.priority !== undefined) updateData.priority = body.priority + if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null + if (body.notes !== undefined) updateData.notes = body.notes - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const body: Partial = await request.json() - - // First, get existing deadline to verify ownership - const { data: _existing, error: fetchError } = await supabase - .from('deadlines') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (fetchError) { - if (fetchError.code === 'PGRST116') { - return NextResponse.json({ error: 'Deadline not found' }, { status: 404 }) + if (Object.keys(updateData).length === 0) { + return NextResponse.json({ error: 'Nothing to update' }, { status: 400 }) } - return NextResponse.json({ error: fetchError.message }, { status: 500 }) - } - // Build update object - const updateData: Record = {} - if (body.title !== undefined) updateData.title = body.title - if (body.due_date !== undefined) updateData.due_date = body.due_date - if (body.due_time !== undefined) updateData.due_time = body.due_time - if (body.deadline_type !== undefined) updateData.deadline_type = body.deadline_type - if (body.priority !== undefined) updateData.priority = body.priority - if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null - if (body.notes !== undefined) updateData.notes = body.notes + const { data, error } = await supabase + .from('deadlines') + .update(updateData) + .eq('id', id) + .eq('company_id', companyId) + .select('*, customer:customers(id, name)') + .single() - // Update the deadline - const { data, error } = await supabase - .from('deadlines') - .update(updateData) - .eq('id', id) - .eq('company_id', companyId) - .select('*, customer:customers(id, name)') - .single() + if (error) { + // PGRST116 = zero rows — the deadline doesn't exist in this company. + if (error.code === 'PGRST116') { + return NextResponse.json({ error: 'Deadline not found' }, { status: 404 }) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) /** * DELETE /api/deadlines/[id] * Delete a deadline */ -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'deadline.delete', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const { - data: { user }, - } = await supabase.auth.getUser() + const { error, count } = await supabase + .from('deadlines') + .delete({ count: 'exact' }) + .eq('id', id) + .eq('company_id', companyId) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + // Zero rows = wrong id / another company's deadline — not a success. + if (count === 0) { + return NextResponse.json({ error: 'Deadline not found' }, { status: 404 }) + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const { error } = await supabase - .from('deadlines') - .delete() - .eq('id', id) - .eq('company_id', companyId) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/deadlines/[id]/status/route.ts b/app/api/deadlines/[id]/status/route.ts index 86360c01..8788e5b2 100644 --- a/app/api/deadlines/[id]/status/route.ts +++ b/app/api/deadlines/[id]/status/route.ts @@ -1,114 +1,81 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { z } from 'zod' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import type { DeadlineStatus } from '@/types' +const ALL_STATUSES = [ + 'upcoming', + 'action_needed', + 'in_progress', + 'submitted', + 'confirmed', + 'overdue', +] as const satisfies readonly DeadlineStatus[] + +const PatchStatusSchema = z.object({ + status: z.enum(ALL_STATUSES), +}) + /** * PATCH /api/deadlines/[id]/status * Manually update a deadline's status */ -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( + 'deadline.set_status', + async (request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId, log } = ctx - const { data: { user } } = await supabase.auth.getUser() + const validation = await validateBody(request, PatchStatusSchema, { + log, + operation: 'deadline.set_status', + }) + if (!validation.success) return validation.response - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const result = await updateDeadlineStatus(supabase, id, companyId, validation.data.status) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const { id } = await params - - const body = await request.json() - const newStatus = body.status as DeadlineStatus - - if (!newStatus) { - return NextResponse.json({ error: 'Status is required' }, { status: 400 }) - } - - const validStatuses: DeadlineStatus[] = [ - 'upcoming', - 'action_needed', - 'in_progress', - 'submitted', - 'confirmed', - 'overdue', - ] - - if (!validStatuses.includes(newStatus)) { - return NextResponse.json({ error: 'Invalid status' }, { status: 400 }) - } - - const result = await updateDeadlineStatus(supabase, id, companyId, newStatus) - - if (!result.success) { - return NextResponse.json({ error: result.error }, { status: 400 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) /** * GET /api/deadlines/[id]/status * Get current status and valid transitions */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'deadline.get_status', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const { data: { user } } = await supabase.auth.getUser() + const { data: deadline, error } = await supabase + .from('deadlines') + .select('status, is_completed, due_date') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - const { id } = await params - - const { data: deadline, error } = await supabase - .from('deadlines') - .select('status, is_completed, due_date') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (error || !deadline) { - return NextResponse.json({ error: 'Deadline not found' }, { status: 404 }) - } - - // Calculate valid transitions from current status - const validTransitions: DeadlineStatus[] = [] - const allStatuses: DeadlineStatus[] = [ - 'upcoming', - 'action_needed', - 'in_progress', - 'submitted', - 'confirmed', - 'overdue', - ] - - for (const status of allStatuses) { - if (isValidTransition(deadline.status, status)) { - validTransitions.push(status) + if (error || !deadline) { + return NextResponse.json({ error: 'Deadline not found' }, { status: 404 }) } - } - return NextResponse.json({ - currentStatus: deadline.status, - isCompleted: deadline.is_completed, - dueDate: deadline.due_date, - validTransitions, - }) -} + // Calculate valid transitions from current status + const validTransitions = ALL_STATUSES.filter((status) => + isValidTransition(deadline.status, status) + ) + + return NextResponse.json({ + currentStatus: deadline.status, + isCompleted: deadline.is_completed, + dueDate: deadline.due_date, + validTransitions, + }) + }, +) diff --git a/app/api/deadlines/route.ts b/app/api/deadlines/route.ts index 828b0805..e2f70d6a 100644 --- a/app/api/deadlines/route.ts +++ b/app/api/deadlines/route.ts @@ -1,9 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { CreateDeadlineSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' /** * GET /api/deadlines @@ -14,18 +12,8 @@ import { requireWritePermission } from '@/lib/auth/require-write' * - from: ISO date string (optional) * - to: ISO date string (optional) */ -export async function GET(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext('deadline.list', async (request, ctx) => { + const { supabase, companyId } = ctx // Parse query params const { searchParams } = new URL(request.url) @@ -66,27 +54,16 @@ export async function GET(request: Request) { } return NextResponse.json({ data }) -} +}) /** * POST /api/deadlines * Create a new deadline */ -export async function POST(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) +export const POST = withRouteContext( + 'deadline.create', + async (request, ctx) => { + const { supabase, companyId, user } = ctx const validation = await validateBody(request, CreateDeadlineSchema) if (!validation.success) return validation.response @@ -114,4 +91,6 @@ export async function POST(request: Request) { } return NextResponse.json({ data }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/dimensions/route.ts b/app/api/dimensions/route.ts index 8870afe8..4e3adaa0 100644 --- a/app/api/dimensions/route.ts +++ b/app/api/dimensions/route.ts @@ -15,6 +15,7 @@ import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' +import { fetchAllRows } from '@/lib/supabase/fetch-all' import { validateBody } from '@/lib/api/validate' import { CreateDimensionSchema } from '@/lib/api/schemas' import { errorResponse } from '@/lib/errors/get-structured-error' @@ -71,19 +72,28 @@ export const GET = withRouteContext( return errorResponse(dimsError, log, { requestId }) } - const { data: values, error: valuesError } = await supabase - .from('dimension_values') - .select('id, dimension_id, code, name, is_active, start_date, end_date') - .eq('company_id', companyId) - .order('code', { ascending: true }) - - if (valuesError) { - log.error('dimension value list failed', valuesError) + // Paginated: import-existing can mint one value row per historical code + // (thousands for project-heavy SIE histories), which exceeds PostgREST's + // 1000-row cap and would silently drop codes from the register/pickers. + // Secondary order on id gives the stable total order .range() requires. + let values: DimensionValueRow[] + try { + values = await fetchAllRows(({ from, to }) => + supabase + .from('dimension_values') + .select('id, dimension_id, code, name, is_active, start_date, end_date') + .eq('company_id', companyId) + .order('code', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to), + ) + } catch (valuesError) { + log.error('dimension value list failed', valuesError as Error) return errorResponse(valuesError, log, { requestId }) } const valuesByDimension = new Map[]>() - for (const v of (values ?? []) as DimensionValueRow[]) { + for (const v of values) { const bucket = valuesByDimension.get(v.dimension_id) ?? [] bucket.push({ id: v.id, diff --git a/app/api/documents/counts/route.ts b/app/api/documents/counts/route.ts index 3e040984..952cd374 100644 --- a/app/api/documents/counts/route.ts +++ b/app/api/documents/counts/route.ts @@ -1,22 +1,13 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' /** * GET /api/documents/counts?journal_entry_ids=id1,id2,... * Returns attachment counts per journal entry ID. * Max 50 IDs per request. */ -export async function GET(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext('document.counts', async (request, ctx) => { + const { supabase, companyId } = ctx const { searchParams } = new URL(request.url) const idsParam = searchParams.get('journal_entry_ids') @@ -55,4 +46,4 @@ export async function GET(request: Request) { } return NextResponse.json({ data: counts }) -} +}) diff --git a/app/api/documents/route.ts b/app/api/documents/route.ts index 5d2481b4..446c593a 100644 --- a/app/api/documents/route.ts +++ b/app/api/documents/route.ts @@ -44,7 +44,17 @@ export const POST = withRouteContext( const opLog = log.child({ filename: file.name, sizeBytes: file.size }) try { - const uploadSource = (formData.get('upload_source') as string) || 'file_upload' + // Whitelist the source — arbitrary strings from formData would otherwise + // land in the upload_source column; unknown values fall back to the + // default rather than 400ing an otherwise-valid upload. + const VALID_SOURCES: DocumentUploadSource[] = [ + 'camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system', + ] + const rawSource = formData.get('upload_source') + const uploadSource: DocumentUploadSource = + typeof rawSource === 'string' && VALID_SOURCES.includes(rawSource as DocumentUploadSource) + ? (rawSource as DocumentUploadSource) + : 'file_upload' const journalEntryId = formData.get('journal_entry_id') as string | null const journalEntryLineId = formData.get('journal_entry_line_id') as string | null @@ -55,7 +65,7 @@ export const POST = withRouteContext( buffer, type: file.type, }, { - upload_source: uploadSource as DocumentUploadSource, + upload_source: uploadSource, journal_entry_id: journalEntryId || undefined, journal_entry_line_id: journalEntryLineId || undefined, }) @@ -106,8 +116,12 @@ export const GET = withRouteContext( const { searchParams } = new URL(request.url) const journalEntryId = searchParams.get('journal_entry_id') const currentOnly = searchParams.get('current_only') !== 'false' - const limit = parseInt(searchParams.get('limit') || '50') - const offset = parseInt(searchParams.get('offset') || '0') + // Clamp pagination — parseInt('abc') is NaN and unbounded limits let a + // caller demand the whole table in one page. + const rawLimit = parseInt(searchParams.get('limit') || '50', 10) + const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 500) : 50 + const rawOffset = parseInt(searchParams.get('offset') || '0', 10) + const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0 let query = supabase .from('document_attachments') diff --git a/app/api/extensions/enable-banking/callback/__tests__/route.test.ts b/app/api/extensions/enable-banking/callback/__tests__/route.test.ts index b43ed48b..dfc8ce96 100644 --- a/app/api/extensions/enable-banking/callback/__tests__/route.test.ts +++ b/app/api/extensions/enable-banking/callback/__tests__/route.test.ts @@ -9,9 +9,11 @@ vi.mock('@/extensions/general/enable-banking/lib/api-client', () => ({ })) // Use hoisted to safely create mock objects referenced in vi.mock factories -const { mockFrom } = vi.hoisted(() => { +const { mockFrom, mockUpsertFromPsd2, mockAllocate } = vi.hoisted(() => { const mockFrom = vi.fn() - return { mockFrom } + const mockUpsertFromPsd2 = vi.fn() + const mockAllocate = vi.fn() + return { mockFrom, mockUpsertFromPsd2, mockAllocate } }) vi.mock('@/lib/supabase/server', () => ({ @@ -20,6 +22,20 @@ vi.mock('@/lib/supabase/server', () => ({ }), })) +const CURRENCY_DEFAULTS: Record = { + SEK: '1930', + EUR: '1932', + USD: '1933', + GBP: '1934', +} + +vi.mock('@/lib/cash-accounts/service', () => ({ + upsertFromPsd2: (...args: unknown[]) => mockUpsertFromPsd2(...args), + allocatePsd2LedgerAccount: (...args: unknown[]) => mockAllocate(...args), + defaultLedgerForCurrency: (currency: string) => + CURRENCY_DEFAULTS[currency.toUpperCase()] ?? '1930', +})) + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000') import { GET } from '../route' @@ -46,6 +62,27 @@ function mockChain(result: { data?: unknown; error?: unknown }) { describe('GET /api/extensions/enable-banking/callback', () => { beforeEach(() => { vi.clearAllMocks() + mockUpsertFromPsd2.mockResolvedValue(undefined) + // Allocator stand-in mirroring the real behavior: currency default first, + // then the next free 1931–1959 slot (skipping other currency defaults). + mockAllocate.mockImplementation( + async ( + _supabase: unknown, + _companyId: unknown, + _userId: unknown, + input: { currency: string; exclude?: ReadonlySet }, + ) => { + const preferred = CURRENCY_DEFAULTS[input.currency.toUpperCase()] ?? '1930' + const exclude = input.exclude ?? new Set() + if (!exclude.has(preferred)) return preferred + const reserved = new Set(Object.values(CURRENCY_DEFAULTS)) + for (let n = 1931; n <= 1959; n++) { + const candidate = String(n) + if (!reserved.has(candidate) && !exclude.has(candidate)) return candidate + } + return null + }, + ) }) it('rejects when state does not match any pending connection', async () => { @@ -115,13 +152,79 @@ describe('GET /api/extensions/enable-banking/callback', () => { // Verify the update payload: status=pending_selection, no last_synced_at, // and every account defaults to enabled=true so the picker can simply // mirror current state without back-filling. - expect(capturedUpdates).toHaveLength(1) + // Two updates: the connection write, then the accounts_data follow-up + // persisting the allocated ledgers. + expect(capturedUpdates).toHaveLength(2) const payload = capturedUpdates[0] expect(payload.status).toBe('pending_selection') expect(payload).not.toHaveProperty('last_synced_at') const accountsData = payload.accounts_data as Array<{ uid: string; enabled: boolean }> expect(accountsData).toHaveLength(2) expect(accountsData.every(a => a.enabled === true)).toBe(true) + + // Two same-currency accounts must NOT collide on the same BAS slot — the + // second SEK account gets the next free 19xx sub-account, and the + // assignment is persisted to accounts_data for the picker to pre-fill. + const persisted = capturedUpdates[1].accounts_data as Array<{ + uid: string + ledger_account?: string + }> + expect(persisted.find(a => a.uid === 'acc-1')?.ledger_account).toBe('1930') + expect(persisted.find(a => a.uid === 'acc-2')?.ledger_account).toBe('1931') + + // The mirror wrote the same distinct assignments into cash_accounts. + expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(2) + const mirrorLedgers = mockUpsertFromPsd2.mock.calls.map( + (c) => (c[2] as { ledger_account: string }).ledger_account, + ) + expect(mirrorLedgers).toEqual(['1930', '1931']) + }) + + it('preserves existing mirrored ledgers on reconnect instead of re-deriving them', async () => { + let callIndex = 0 + mockFrom.mockImplementation((table: string) => { + callIndex++ + if (callIndex === 1) { + return mockChain({ data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1' }, error: null }) + } + if (table === 'cash_accounts') { + // Already mirrored on a previous connect — acc-1 was remapped to 1935 + // by the user; a reconnect must not clobber it back to 1930. + return mockChain({ + data: [{ external_uid: 'acc-1', ledger_account: '1935' }], + error: null, + }) + } + const chain: Record = {} + chain.update = vi.fn(() => chain) + chain.eq = vi.fn().mockReturnValue(chain) + chain.select = vi.fn().mockReturnValue(chain) + chain.single = vi.fn().mockResolvedValue({ + data: { id: 'conn-1', bank_name: 'TestBank', company_id: 'company-1', user_id: 'user-1' }, + error: null, + }) + chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null }) + return chain + }) + + mockCreateSession.mockResolvedValue({ + session_id: 'sess-2', + accounts: [ + { uid: 'acc-1', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' }, + ], + access: { valid_until: '2024-12-31T00:00:00Z' }, + aspsp: { name: 'TestBank', country: 'SE' }, + }) + + const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' })) + + expect(response.status).toBe(307) + // No allocation for an already-mirrored account; the upsert reuses 1935. + expect(mockAllocate).not.toHaveBeenCalled() + expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1) + expect( + (mockUpsertFromPsd2.mock.calls[0][2] as { ledger_account: string }).ledger_account, + ).toBe('1935') }) it('redirects with error when bank returns error param (no state)', async () => { @@ -154,6 +257,32 @@ describe('GET /api/extensions/enable-banking/callback', () => { expect(mockFrom).toHaveBeenCalledWith('bank_connections') }) + it('forwards bank_error_code and psu_type when the denied state matches a pending connection', async () => { + mockFrom.mockImplementation(() => + mockChain({ + data: { id: 'conn-1', user_id: 'user-1', bank_name: 'Handelsbanken', psu_type: 'business' }, + error: null, + }) + ) + + const response = await GET(makeRequest({ + error: 'server_error', + state: 'pending-state', + })) + + expect(response.status).toBe(307) + const location = response.headers.get('location') || '' + expect(location).toContain('/settings/banking?') + // error_description is null for server_error, so the code doubles as message + expect(location).toContain('bank_error=server_error') + expect(location).toContain('bank_name=Handelsbanken') + // The code is forwarded for every error, not just access_denied, together + // with the connection's psu_type — the settings page keys the Handelsbanken + // corporate fullmakt guidance off this exact combination. + expect(location).toContain('bank_error_code=server_error') + expect(location).toContain('psu_type=business') + }) + it('redirects with error when code or state is missing', async () => { const response = await GET(makeRequest({ code: 'auth-code' })) diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index f9a6b528..1cd69433 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -4,7 +4,11 @@ import { ensureInitialized } from '@/lib/init' import { createSession, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client' import type { StoredAccount } from '@/extensions/general/enable-banking/types' import { eventBus } from '@/lib/events/bus' -import { upsertFromPsd2 } from '@/lib/cash-accounts/service' +import { + upsertFromPsd2, + allocatePsd2LedgerAccount, + defaultLedgerForCurrency, +} from '@/lib/cash-accounts/service' // This route emits bank_connection.consent_granted / .cash_account_mirror_failed // (ASVS V16 / GDPR Art.30 audit events). ensureInitialized() must run at module @@ -13,19 +17,6 @@ import { upsertFromPsd2 } from '@/lib/cash-accounts/service' // redirect route is the first event-emitting code path to execute. ensureInitialized() -// Suggested BAS account per currency. Mirrors the AccountPickerDialog defaults -// (SEK→1930, EUR→1932, USD→1933, GBP→1934). The user can re-map in the picker -// after this callback redirects them. -const CURRENCY_DEFAULTS: Record = { - SEK: '1930', - EUR: '1932', - USD: '1933', - GBP: '1934', -} - -function defaultLedgerForCurrency(currency: string): string { - return CURRENCY_DEFAULTS[currency.toUpperCase()] ?? '1930' -} /** * GET /api/extensions/enable-banking/callback @@ -46,7 +37,12 @@ export async function GET(request: Request) { if (error) { const errorMessage = errorDescription || error - console.error('[enable-banking] Bank authorization denied', { + // access_denied is the user cancelling at the bank — an expected outcome, + // not a runtime error. Only bank-side failures stay at error level. + const isUserCancel = + error === 'access_denied' || /cancelled by user/i.test(errorDescription ?? '') + const logDenied = isUserCancel ? console.warn : console.error + logDenied('[enable-banking] Bank authorization denied', { error, error_description: errorDescription, has_state: !!state, @@ -62,13 +58,13 @@ export async function GET(request: Request) { // (which stays 'expired' during the round-trip) is also handled. const { data: pendingConn } = await supabase .from('bank_connections') - .select('id, user_id, bank_name') + .select('id, user_id, bank_name, psu_type') .eq('oauth_state', state) .in('status', ['pending', 'expired', 'error']) .single() if (pendingConn) { - console.error('[enable-banking] Authorization denied details', { + logDenied('[enable-banking] Authorization denied details', { connection_id: pendingConn.id, user_id: pendingConn.user_id, bank_name: pendingConn.bank_name, @@ -88,11 +84,15 @@ export async function GET(request: Request) { .update({ status: isSessionExpiry ? 'expired' : 'error', error_message: errorMessage, oauth_state: null }) .eq('id', pendingConn.id) - // Include bank name and error code in redirect so the UI can offer PSU type retry + // Include bank name, error code, and psu_type in the redirect so the + // UI can render targeted guidance (e.g. PSU-type retry on + // access_denied, or the Handelsbanken corporate fullmakt steps on + // server_error for a business connect). const params = new URLSearchParams({ bank_error: errorMessage, ...(pendingConn.bank_name ? { bank_name: pendingConn.bank_name } : {}), - ...(error === 'access_denied' ? { bank_error_code: error } : {}), + bank_error_code: error, + ...(pendingConn.psu_type ? { psu_type: pendingConn.psu_type } : {}), }) return NextResponse.redirect(`${baseUrl}/settings/banking?${params.toString()}`) } @@ -206,12 +206,40 @@ export async function GET(request: Request) { throw new Error(`Failed to update connection: ${updateError.message}`) } - // Mirror each PSD2 account into cash_accounts so routing decisions read from - // the canonical entity table. The user picks a ledger_account in the - // AccountPickerDialog after this redirect; until then we route SEK→1930, - // EUR→1932, USD→1933, GBP→1934 by convention. + // Mirror each PSD2 account into cash_accounts so routing decisions read + // from the canonical entity table. Accounts already mirrored (reconnect) + // keep their ledger_account — re-deriving it here would clobber the + // user's remaps. New accounts each get a free BAS class-19 slot: a bank + // returning N same-currency accounts must not collide on the UNIQUE + // (company_id, ledger_account) constraint by all defaulting to 1930. + const { data: mirroredRows } = await supabase + .from('cash_accounts') + .select('external_uid, ledger_account') + .eq('company_id', updatedConnection.company_id) + .eq('bank_connection_id', updatedConnection.id) + const existingLedgerByUid = new Map( + ((mirroredRows ?? []) as Array<{ external_uid: string; ledger_account: string }>).map( + (r) => [r.external_uid, r.ledger_account], + ), + ) + const assignedLedgers = new Set(existingLedgerByUid.values()) + let accountsDataDirty = false + for (const account of accountsMetadata) { - const targetLedger = defaultLedgerForCurrency(account.currency) + let targetLedger = existingLedgerByUid.get(account.uid) + if (!targetLedger) { + targetLedger = + (await allocatePsd2LedgerAccount(supabase, updatedConnection.company_id, updatedConnection.user_id, { + currency: account.currency, + accountName: account.name, + exclude: assignedLedgers, + })) ?? defaultLedgerForCurrency(account.currency) + } + assignedLedgers.add(targetLedger) + if (account.ledger_account !== targetLedger) { + account.ledger_account = targetLedger + accountsDataDirty = true + } try { await upsertFromPsd2(supabase, updatedConnection.company_id, { bank_connection_id: updatedConnection.id, @@ -256,6 +284,22 @@ export async function GET(request: Request) { } } + // Persist the allocated ledgers into accounts_data so the AccountPicker + // pre-fills the actual assignments instead of colliding currency + // defaults. Non-fatal: cash_accounts is the routing source of truth. + if (accountsDataDirty) { + const { error: accountsDataError } = await supabase + .from('bank_connections') + .update({ accounts_data: accountsMetadata }) + .eq('id', updatedConnection.id) + if (accountsDataError) { + console.warn('[enable-banking] Failed to persist allocated ledgers to accounts_data', { + connectionId: updatedConnection.id, + error: accountsDataError.message, + }) + } + } + // Audit trail: PSD2 consent has been exchanged and account metadata stored. // ASVS V16 requires this transition to be logged as a security event; emit // here so the event_log handler persists it (30-day TTL). diff --git a/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts index c18ef02e..0bc384e7 100644 --- a/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts +++ b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts @@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init' import { verifyCronSecret } from '@/lib/auth/cron' import { agiGetKvittenser } from '@/extensions/general/skatteverket/lib/agi-client' import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client' +import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store' import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format' import { hasCapability } from '@/lib/entitlements/has-capability' import { CAPABILITY } from '@/lib/entitlements/keys' @@ -103,7 +104,7 @@ export async function GET(request: Request) { // operator's token is reused only for the company that owns the AGI. const { data: token } = await supabase .from('skatteverket_tokens') - .select('user_id') + .select('user_id, status') .eq('company_id', companyId) .maybeSingle() @@ -112,6 +113,15 @@ export async function GET(request: Request) { continue } + // A connection flagged needs_reconsent cannot heal on its own (SKV's + // per-flow refresh tokens live 65 minutes) — skip quietly instead of + // failing the same pending declaration every run until the user + // re-consents. + if (token.status === 'needs_reconsent') { + results.push({ declarationId, companyId, period, status: 'expired_token', error: 'needs_reconsent' }) + continue + } + const { data: settings } = await supabase .from('company_settings') .select('org_number, entity_type') @@ -208,8 +218,23 @@ export async function GET(request: Request) { if ( err instanceof SkatteverketAuthError && - (err.code === 'REFRESH_EXHAUSTED' || err.code === 'SESSION_EXPIRED' || err.code === 'TOKEN_CORRUPTED' || err.code === 'MISSING_SCOPE') + (RECONSENT_ERROR_CODES as readonly string[]).includes(err.code) ) { + // Persist the health flag so both crons stop retrying this + // connection and the UI can prompt for re-consent proactively. + const { data: tokenRow } = await supabase + .from('skatteverket_tokens') + .select('user_id') + .eq('company_id', companyId) + .maybeSingle() + if (tokenRow?.user_id) { + await markNeedsReconsent(supabase, tokenRow.user_id as string, err.code) + } + results.push({ declarationId, companyId, period, status: 'expired_token', error: err.code }) + continue + } + if (err instanceof SkatteverketAuthError && err.code === 'TOKEN_REVOKED') { + // skvRequest already deleted the token row. results.push({ declarationId, companyId, period, status: 'expired_token', error: err.code }) continue } diff --git a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts index f396a837..11ea8674 100644 --- a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts +++ b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts @@ -9,6 +9,7 @@ import { syncSkattekonto, SKATTEKONTO_LAST_SYNCED_AT_KEY } from '@/extensions/ge import { computeSkattekontoDrift, maybeAlertDrift } from '@/extensions/general/skatteverket/lib/skattekonto-drift' import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client' import { SkatteverketSkattekontoError } from '@/extensions/general/skatteverket/lib/skattekonto-client' +import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store' ensureInitialized() @@ -47,11 +48,16 @@ export async function GET(request: Request) { const supabase = createClient(supabaseUrl, supabaseServiceKey) - // Find all companies with a connected token. The token row is keyed by - // user_id but carries company_id (added in the multi-tenant refactor). + // Find all companies with a connected, believed-working token. The token + // row is keyed by user_id but carries company_id (multi-tenant refactor). + // Rows flagged needs_reconsent are excluded: SKV's per-flow refresh tokens + // live 65 minutes, so a connection that failed with a terminal auth error + // can never heal on its own — retrying it every night only produced a + // per-company error log until the user re-consents (which resets status). const { data: tokens, error: tokensError } = await supabase .from('skatteverket_tokens') .select('user_id, company_id, expires_at, refresh_count') + .eq('status', 'active') .order('expires_at', { ascending: true }) .limit(50) @@ -146,12 +152,21 @@ export async function GET(request: Request) { } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' - // Expired token / refresh exhausted is a known outcome: surface it - // distinctly so ops can dashboard "X companies need to reconnect". + // Terminal auth states are a known outcome: surface them distinctly + // so ops can dashboard "X companies need to reconnect", persist the + // health flag so this cron stops retrying the row, and let the UI + // prompt for re-consent proactively. if ( err instanceof SkatteverketAuthError && - (err.code === 'REFRESH_EXHAUSTED' || err.code === 'SESSION_EXPIRED' || err.code === 'TOKEN_CORRUPTED') + (RECONSENT_ERROR_CODES as readonly string[]).includes(err.code) ) { + await markNeedsReconsent(supabase, userId, err.code) + results.push({ userId, companyId, status: 'expired', error: err.code }) + continue + } + // TOKEN_REVOKED auto-deletes the row inside skvRequest — treat it as + // the same quiet "reconnect needed" outcome, not a runtime error. + if (err instanceof SkatteverketAuthError && err.code === 'TOKEN_REVOKED') { results.push({ userId, companyId, status: 'expired', error: err.code }) continue } diff --git a/app/api/import/opening-balance/__tests__/correct.test.ts b/app/api/import/opening-balance/__tests__/correct.test.ts index c961b35b..b12d2c25 100644 --- a/app/api/import/opening-balance/__tests__/correct.test.ts +++ b/app/api/import/opening-balance/__tests__/correct.test.ts @@ -126,8 +126,59 @@ describe('POST /api/import/opening-balance/correct', () => { expect((body.error as unknown as { code: string }).code).toBe('OB_PERIOD_LOCKED') }) + it('returns 409 when the company lock date covers the period start', async () => { + enqueue({ data: openPeriodWithOB() }) // period (period_start 2026-01-01) + enqueue({ data: { bookkeeping_locked_through: '2026-02-28' } }) // company lock-date pre-flight + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(409) + const err = body.error as unknown as { code: string; details?: { lockDate?: string; entryDate?: string } } + expect(err.code).toBe('OB_COMPANY_LOCK_DATE') + expect(err.details?.lockDate).toBe('2026-02-28') + expect(err.details?.entryDate).toBe('2026-01-01') + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + expect(mockReverseEntry).not.toHaveBeenCalled() + }) + + it('proceeds when the company lock date is before the period start', async () => { + enqueue({ data: openPeriodWithOB() }) // period (period_start 2026-01-01) + enqueue({ data: { bookkeeping_locked_through: '2025-12-31' } }) // company lock-date pre-flight + enqueue({ count: 0 }) // year-end check + enqueue({ error: null }) // replace_period_opening_balance_link RPC + + mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 5 }) + mockReverseEntry.mockResolvedValue({ id: 'entry-storno' }) + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body.data.success).toBe(true) + }) + + it('maps a raced lock-date trigger rejection to OB_COMPANY_LOCK_DATE instead of a retryable 500', async () => { + enqueue({ data: openPeriodWithOB() }) // period + enqueue({ data: { bookkeeping_locked_through: null } }) // pre-flight passes (lock set after it) + enqueue({ count: 0 }) // year-end check + + // The enforce_company_lock_date trigger fired inside the engine. + mockCreateJournalEntry.mockRejectedValue( + new Error('Database operation "create_draft_entry" failed: Bokföringen är låst t.o.m. 2026-02-28. Kan inte skapa verifikation med datum 2026-01-01.'), + ) + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(409) + expect((body.error as unknown as { code: string }).code).toBe('OB_COMPANY_LOCK_DATE') + expect(mockReverseEntry).not.toHaveBeenCalled() + }) + it('returns 409 when the period has no opening balances to correct', async () => { enqueue({ data: openPeriodWithOB({ opening_balances_set: false, opening_balance_entry_id: null }) }) + enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) const { status, body } = await parseJsonResponse(res) @@ -138,6 +189,7 @@ describe('POST /api/import/opening-balance/correct', () => { it('returns 409 when a year-end close exists on the period', async () => { enqueue({ data: openPeriodWithOB() }) // period + enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight enqueue({ count: 1 }) // year-end entry count const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) @@ -151,6 +203,7 @@ describe('POST /api/import/opening-balance/correct', () => { it('returns 400 for unbalanced corrected lines', async () => { enqueue({ data: openPeriodWithOB() }) // period + enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight enqueue({ count: 0 }) // year-end check const res = await POST(makeRequest({ @@ -168,6 +221,7 @@ describe('POST /api/import/opening-balance/correct', () => { it('books a corrected IB, stornoes the old one, and relinks on success', async () => { enqueue({ data: openPeriodWithOB() }) // period + enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight enqueue({ count: 0 }) // year-end check enqueue({ error: null }) // replace_period_opening_balance_link RPC @@ -204,6 +258,7 @@ describe('POST /api/import/opening-balance/correct', () => { it('returns 500 OB_CORRECT_FAILED if the relink RPC fails', async () => { enqueue({ data: openPeriodWithOB() }) // period + enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight enqueue({ count: 0 }) // year-end check enqueue({ error: { message: 'relink boom' } }) // RPC failure diff --git a/app/api/import/opening-balance/correct/__tests__/route.test.ts b/app/api/import/opening-balance/correct/__tests__/route.test.ts index 8740b8f4..c688f84d 100644 --- a/app/api/import/opening-balance/correct/__tests__/route.test.ts +++ b/app/api/import/opening-balance/correct/__tests__/route.test.ts @@ -105,6 +105,7 @@ describe('POST /api/import/opening-balance/correct: atomicity, audit, BFL refere // FIX 3 (BFL 5 kap 5§): the corrected entry references the original voucher. it('references the original verifikationsnummer in the corrected entry description', async () => { enqueue({ data: openPeriodWithOB({ opening_balance_entry: { voucher_series: 'B', voucher_number: 7 } }) }) // period + enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight enqueue({ count: 0 }) // year-end check enqueue({ error: null }) // replace_period_opening_balance_link RPC @@ -134,6 +135,7 @@ describe('POST /api/import/opening-balance/correct: atomicity, audit, BFL refere // after the new entry was already created. it('compensates by stornoing the new entry when reverseEntry throws, returning OB_CORRECT_FAILED', async () => { enqueue({ data: openPeriodWithOB() }) // period + enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight enqueue({ count: 0 }) // year-end check // No RPC enqueue: step B throws before the relink is reached. @@ -163,6 +165,7 @@ describe('POST /api/import/opening-balance/correct: atomicity, audit, BFL refere // FIX 1 + FIX 2: relink RPC error triggers compensation and a durable audit. it('compensates and emits a durable audit when the relink RPC returns an error', async () => { enqueue({ data: openPeriodWithOB() }) // period + enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight enqueue({ count: 0 }) // year-end check enqueue({ error: { message: 'relink boom' } }) // RPC failure @@ -194,6 +197,7 @@ describe('POST /api/import/opening-balance/correct: atomicity, audit, BFL refere // return the envelope and audit the compensation failure (never rethrow). it('audits a compensation failure and still returns OB_CORRECT_FAILED', async () => { enqueue({ data: openPeriodWithOB() }) // period + enqueue({ data: { bookkeeping_locked_through: null } }) // company lock-date pre-flight enqueue({ count: 0 }) // year-end check enqueue({ error: { message: 'relink boom' } }) // RPC failure diff --git a/app/api/import/opening-balance/correct/route.ts b/app/api/import/opening-balance/correct/route.ts index 1c2fbd13..192efe1b 100644 --- a/app/api/import/opening-balance/correct/route.ts +++ b/app/api/import/opening-balance/correct/route.ts @@ -75,6 +75,26 @@ export const POST = withRouteContext( return errorResponseFromCode('OB_PERIOD_LOCKED', opLog, { requestId }) } + // Company-wide lock date pre-flight. The enforce_company_lock_date + // trigger blocks BOTH the corrected IB and the storno of the old one + // (each books at period_start), and a trigger rejection surfaces as a + // retryable-500 BOOKKEEPING_DATABASE_ERROR with a generic message — + // which invites blind retries (prod incident: 3× against a covering + // lock date). Pre-flight it and return an actionable 409 instead. + const { data: settings } = await supabase + .from('company_settings') + .select('bookkeeping_locked_through') + .eq('company_id', companyId) + .maybeSingle() + + const lockDate = settings?.bookkeeping_locked_through as string | null + if (lockDate && period.period_start <= lockDate) { + return errorResponseFromCode('OB_COMPANY_LOCK_DATE', opLog, { + requestId, + details: { lockDate, entryDate: period.period_start }, + }) + } + if (!period.opening_balances_set || !period.opening_balance_entry_id) { return errorResponseFromCode('OB_CORRECT_NO_EXISTING', opLog, { requestId }) } @@ -240,6 +260,16 @@ export const POST = withRouteContext( }, }) } catch (err) { + // Belt-and-braces: if the lock-date trigger fired anyway (lock date set + // between the pre-flight and the write), return the same actionable + // envelope instead of the generic retryable BOOKKEEPING_DATABASE_ERROR. + const message = err instanceof Error ? err.message : '' + if (/Bokföringen är låst/i.test(message)) { + return errorResponseFromCode('OB_COMPANY_LOCK_DATE', opLog, { + requestId, + details: { reason: message }, + }) + } if (isBookkeepingError(err)) { return errorResponse(err, opLog, { requestId }) } diff --git a/app/api/log/route.ts b/app/api/log/route.ts index 92b0b387..686ee81b 100644 --- a/app/api/log/route.ts +++ b/app/api/log/route.ts @@ -42,7 +42,10 @@ export async function POST(request: Request) { // Route through the structured logger so message + extra are PII-redacted // (personnummer / IBAN / tokens via REDACT_KEYS) before reaching Vercel logs. - log.error('client onboarding error', { clientMessage: message, extra }) + // warn, never error: this is client-supplied telemetry (mostly form + // validation misses) — client input must not be able to emit error-level + // lines that land in Vercel's runtime-error clustering. + log.warn('client onboarding error', { clientMessage: message, extra }) return NextResponse.json({ ok: true }) } catch { diff --git a/app/api/payslip/[token]/pdf/__tests__/route.test.ts b/app/api/payslip/[token]/pdf/__tests__/route.test.ts new file mode 100644 index 00000000..83dc5644 --- /dev/null +++ b/app/api/payslip/[token]/pdf/__tests__/route.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase, createMockRequest, createMockRouteParams } from '@/tests/helpers' + +vi.mock('@/lib/auth/api-keys', () => ({ createServiceClientNoCookies: vi.fn() })) +vi.mock('@/lib/salary/payslips/links', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolvePayslipToken: vi.fn(), + } +}) +vi.mock('@react-pdf/renderer', () => ({ + renderToBuffer: vi.fn(async () => Buffer.from('%PDF-fake')), +})) +vi.mock('@/lib/salary/pdf/payslip-template', () => ({ PayslipPDF: vi.fn(() => null) })) +vi.mock('@/lib/salary/payslips/build-payslip-data', () => ({ + buildPayslipData: vi.fn(() => ({})), + payslipFileName: vi.fn(() => 'lonespec_Test_2026-06.pdf'), +})) + +import { GET } from '../route' +import { createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { resolvePayslipToken } from '@/lib/salary/payslips/links' + +// Distinct valid-format tokens per test — the route's rate-limit map is +// module-level state shared across this file. +const token = (c: string) => c.repeat(43) + +describe('GET /api/payslip/[token]/pdf', () => { + beforeEach(() => { + vi.clearAllMocks() + const { supabase } = createQueuedMockSupabase() + vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never) + }) + + it('returns 400 for malformed tokens', async () => { + const request = createMockRequest('/api/payslip/x/pdf') + const response = await GET(request, createMockRouteParams({ token: 'not-a-token' })) + expect(response.status).toBe(400) + expect(vi.mocked(resolvePayslipToken)).not.toHaveBeenCalled() + }) + + it('returns 404 for an unknown token', async () => { + vi.mocked(resolvePayslipToken).mockResolvedValue({ ok: false, reason: 'not_found' }) + const request = createMockRequest('/api/payslip/t/pdf') + const response = await GET(request, createMockRouteParams({ token: token('B') })) + expect(response.status).toBe(404) + }) + + it('returns 410 for expired and revoked tokens', async () => { + vi.mocked(resolvePayslipToken).mockResolvedValue({ ok: false, reason: 'expired' }) + let response = await GET( + createMockRequest('/api/payslip/t/pdf'), + createMockRouteParams({ token: token('C') }), + ) + expect(response.status).toBe(410) + + vi.mocked(resolvePayslipToken).mockResolvedValue({ ok: false, reason: 'revoked' }) + response = await GET( + createMockRequest('/api/payslip/t/pdf'), + createMockRouteParams({ token: token('D') }), + ) + expect(response.status).toBe(410) + }) + + it('rate limits repeated requests per token', async () => { + vi.mocked(resolvePayslipToken).mockResolvedValue({ ok: false, reason: 'not_found' }) + const t = token('E') + let last: Response | null = null + for (let i = 0; i < 21; i++) { + last = await GET( + createMockRequest('/api/payslip/t/pdf'), + createMockRouteParams({ token: t }), + ) + } + expect(last?.status).toBe(429) + }) + + it('streams the PDF with no-store for a live token', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never) + vi.mocked(resolvePayslipToken).mockResolvedValue({ + ok: true, + link: { + id: 'link-1', + company_id: 'company-1', + salary_run_id: 'run-1', + employee_id: 'emp-1', + token_hash: 'h', + expires_at: new Date(Date.now() + 60_000).toISOString(), + revoked_at: null, + access_count: 0, + }, + }) + enqueueMany([ + { data: { id: 'run-1', period_year: 2026, period_month: 6, payment_date: '2026-06-25' } }, + { data: { employee: { first_name: 'Anna', last_name: 'A', personnummer: 'enc' }, line_items: [] } }, + { data: { name: 'Bolaget AB', org_number: null } }, + ]) + + const response = await GET( + createMockRequest('/api/payslip/t/pdf'), + createMockRouteParams({ token: token('F') }), + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/pdf') + expect(response.headers.get('Cache-Control')).toBe('no-store') + expect(response.headers.get('Content-Disposition')).toContain('lonespec_Test_2026-06.pdf') + }) +}) diff --git a/app/api/payslip/[token]/pdf/route.ts b/app/api/payslip/[token]/pdf/route.ts new file mode 100644 index 00000000..9c9f277f --- /dev/null +++ b/app/api/payslip/[token]/pdf/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from 'next/server' +import { renderToBuffer } from '@react-pdf/renderer' +import { createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { resolvePayslipToken, isValidPayslipTokenFormat } from '@/lib/salary/payslips/links' +import { buildPayslipData, payslipFileName } from '@/lib/salary/payslips/build-payslip-data' +import { PayslipPDF } from '@/lib/salary/pdf/payslip-template' + +// In-memory rate limiting per token (pattern from /api/calendar/feed). +const rateLimitMap = new Map() +const RATE_LIMIT_WINDOW_MS = 60_000 +const RATE_LIMIT_MAX = 20 + +let lastCleanup = Date.now() +function cleanupRateLimitMap() { + const now = Date.now() + if (now - lastCleanup < 5 * 60_000) return + lastCleanup = now + for (const [key, value] of rateLimitMap) { + if (now > value.resetAt) rateLimitMap.delete(key) + } +} + +/** + * GET /api/payslip/[token]/pdf + * + * Public payslip PDF download. The token IS the authentication — the code + * path is the only guard (strict hash equality, revocation/expiry checks, + * per-token rate limit). Salary PII: masked personnummer only, no-store, + * and the raw token is never logged. + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ token: string }> }, +) { + const { token } = await params + + if (!isValidPayslipTokenFormat(token)) { + return new NextResponse('Invalid token', { status: 400 }) + } + + cleanupRateLimitMap() + const nowMs = Date.now() + const rateEntry = rateLimitMap.get(token) + if (rateEntry && nowMs < rateEntry.resetAt) { + if (rateEntry.count >= RATE_LIMIT_MAX) { + return new NextResponse('Too many requests', { status: 429 }) + } + rateEntry.count++ + } else { + rateLimitMap.set(token, { count: 1, resetAt: nowMs + RATE_LIMIT_WINDOW_MS }) + } + + const serviceClient = createServiceClientNoCookies() + const resolved = await resolvePayslipToken(serviceClient, token) + + if (!resolved.ok) { + if (resolved.reason === 'expired' || resolved.reason === 'revoked') { + return new NextResponse('Link no longer valid', { status: 410 }) + } + return new NextResponse('Not found', { status: 404 }) + } + + const { link } = resolved + + const [{ data: run }, { data: sre }, { data: company }] = await Promise.all([ + serviceClient + .from('salary_runs') + .select('*') + .eq('id', link.salary_run_id) + .eq('company_id', link.company_id) + .single(), + serviceClient + .from('salary_run_employees') + .select('*, employee:employees(first_name, last_name, personnummer, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)') + .eq('salary_run_id', link.salary_run_id) + .eq('employee_id', link.employee_id) + .single(), + serviceClient + .from('companies') + .select('name, org_number') + .eq('id', link.company_id) + .single(), + ]) + + if (!run || !sre || !company) { + return new NextResponse('Not found', { status: 404 }) + } + + const emp = sre.employee as unknown as { + first_name: string + last_name: string + personnummer: string + employment_type: string + tax_table_number: number | null + tax_column: number + clearing_number: string | null + bank_account_number: string | null + } + + const data = buildPayslipData({ run, sre, employee: emp, company }) + const fileName = payslipFileName(run, emp) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const buffer = await renderToBuffer(PayslipPDF({ data }) as any) + + return new Response(buffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${fileName}"`, + 'Cache-Control': 'no-store', + }, + }) +} diff --git a/app/api/pending-operations/[id]/__tests__/route.test.ts b/app/api/pending-operations/[id]/__tests__/route.test.ts index f6f0a684..f5d21448 100644 --- a/app/api/pending-operations/[id]/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/__tests__/route.test.ts @@ -30,6 +30,11 @@ vi.mock('@/lib/bookkeeping/category-mapping', () => ({ getCategoryAccountMapping: (...args: unknown[]) => accountMappingMock(...args), })) +const buildLinesMock = vi.fn() +vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ + buildTransactionEntryLines: (...args: unknown[]) => buildLinesMock(...args), +})) + import { PATCH } from '../route' const mockUser = { id: 'user-1' } @@ -52,6 +57,7 @@ beforeEach(() => { vatDebitAccount: '2641', vatCreditAccount: null, }) + buildLinesMock.mockReturnValue([]) }) describe('PATCH /api/pending-operations/[id]', () => { @@ -211,6 +217,58 @@ describe('PATCH /api/pending-operations/[id]', () => { expect(mappingMock).toHaveBeenCalledTimes(1) }) + it('re-derives the full journal lines from the new mapping (stale-preview guard)', async () => { + enqueue({ + data: { + id: 'op-1', + company_id: 'company-1', + operation_type: 'categorize_transaction', + status: 'pending', + params: { transaction_id: 'tx-1', category: 'expense_other', vat_treatment: null }, + preview_data: { + debit_account: '6990', + credit_account: '1930', + amount: 500, + // Stale lines from staging — must be replaced, not spread through. + lines: [{ account_number: '6990', debit_amount: 400, credit_amount: 0 }], + }, + title: 'Kategorisera: X', + }, + }) + enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -500, currency: 'SEK' } }) + enqueue({ data: { entity_type: 'aktiebolag' } }) + enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } }) + + const mapping = { + debit_account: '5420', + credit_account: '1930', + vat_lines: [ + { account_number: '2641', debit_amount: 100, credit_amount: 0, description: 'Ingående moms 25%' }, + ], + } + mappingMock.mockReturnValue(mapping) + buildLinesMock.mockReturnValue([ + { account_number: '2641', debit_amount: 100, credit_amount: 0, line_description: 'Ingående moms 25%' }, + { account_number: '5420', debit_amount: 400, credit_amount: 0, line_description: 'Kostnad' }, + { account_number: '1930', debit_amount: 0, credit_amount: 500, line_description: 'X' }, + ]) + + const res = await PATCH( + createMockRequest('/api/pending-operations/op-1', { + method: 'PATCH', + body: { category: 'expense_software' }, + }), + createMockRouteParams({ id: 'op-1' }), + ) + + expect(res.status).toBe(200) + expect(buildLinesMock).toHaveBeenCalledTimes(1) + expect(buildLinesMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'tx-1' }), + mapping, + ) + }) + it('preserves a staged vat_amount override when the new treatment still carries VAT', async () => { enqueue({ data: { diff --git a/app/api/pending-operations/[id]/route.ts b/app/api/pending-operations/[id]/route.ts index f953a6da..0311d11b 100644 --- a/app/api/pending-operations/[id]/route.ts +++ b/app/api/pending-operations/[id]/route.ts @@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' import { buildMappingResultFromCategory, getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping' +import { buildTransactionEntryLines } from '@/lib/bookkeeping/transaction-entries' import { getVatRate } from '@/lib/bookkeeping/vat-entries' import type { EntityType, Transaction, TransactionCategory, VatTreatment } from '@/types' @@ -191,6 +192,14 @@ export async function PATCH( credit_account: mapping.credit_account, amount: Math.abs((tx as Transaction).amount), currency: (tx as Transaction).currency, + // Re-derive the exact journal lines (net cost line, VAT, gross bank) — + // spreading oldPreview would otherwise leave stale lines from staging. + lines: buildTransactionEntryLines(tx as Transaction, mapping).map((l) => ({ + account_number: l.account_number, + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + description: l.line_description ?? '', + })), vat_lines: (mapping.vat_lines ?? []).map((v) => ({ account: v.account_number, amount: v.debit_amount || v.credit_amount, diff --git a/app/api/salary/runs/[id]/__tests__/route.test.ts b/app/api/salary/runs/[id]/__tests__/route.test.ts index 3729c3af..5e6aa2d1 100644 --- a/app/api/salary/runs/[id]/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/__tests__/route.test.ts @@ -28,7 +28,12 @@ vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) -import { DELETE } from '../route' +vi.mock('@/lib/salary/personnummer', () => ({ + decryptPersonnummer: vi.fn((v: string) => v), + maskPersonnummer: vi.fn(() => '19900101-****'), +})) + +import { DELETE, GET } from '../route' import { requireAuth } from '@/lib/auth/require-auth' // ── Test data ──────────────────────────────────────────────── @@ -118,3 +123,149 @@ describe('DELETE /api/salary/runs/[id]', () => { expect(body.data).toEqual({ id: 'run-1', deleted: true }) }) }) + +// ── GET detail additions (previous_run, corrected_by_run_id, deliveries) ──── +// +// GET consumes queue entries in from() order. The run loads first; the rest +// fire together in a Promise.all, so their from() calls resolve in this order: +// 1 run, 2 employees, 3 settings (arbetsgivare), 4 previous-run lookup, +// [corrected_by lookup when status is 'corrected'], deliveries, +// [previous-run employees when found — nested after the lookup's await, so +// it lands last]. + +const GET_RUN = { + id: 'run-2', + company_id: 'company-1', + status: 'draft', + period_year: 2026, + period_month: 7, + payment_date: '2026-07-25', +} + +describe('GET /api/salary/runs/[id] — additive detail fields', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function authed(supabase: unknown) { + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + } + + it('returns previous_run with effective (override-coalesced) values', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + + enqueueMany([ + { data: GET_RUN }, + { data: [] }, // employees in this run + { data: { org_number: null, entity_type: null } }, + { data: { id: 'run-1', period_year: 2026, period_month: 6 } }, // previous-run lookup + { data: [] }, // deliveries + { + // previous-run employees — nested after the lookup's await, resolves last + data: [ + { + employee_id: 'emp-1', + gross_salary: 35000, + tax_withheld: 8000, + tax_withheld_override: 7000, + net_salary: 27000, + }, + ], + }, + ]) + + const response = await GET( + createMockRequest('/api/salary/runs/run-2'), + createMockRouteParams({ id: 'run-2' }), + ) + const { status, body } = await parseJsonResponse<{ + data: { + previous_run: { + id: string + by_employee: Record + } | null + corrected_by_run_id: string | null + payslip_deliveries_summary: { sent: number; failed: number; skipped: number } + } + }>(response) + + expect(status).toBe(200) + expect(body.data.previous_run?.id).toBe('run-1') + // Override coalesced: tax 7000, net compensated +1000 + expect(body.data.previous_run?.by_employee['emp-1']).toEqual({ + gross: 35000, + tax: 7000, + net: 28000, + }) + expect(body.data.corrected_by_run_id).toBeNull() + expect(body.data.payslip_deliveries_summary).toMatchObject({ sent: 0, failed: 0, skipped: 0 }) + }) + + it('returns previous_run null on the first-ever run', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + + enqueueMany([ + { data: GET_RUN }, + { data: [] }, + { data: null }, // settings + { data: null }, // no previous booked run + { data: [] }, // deliveries + ]) + + const response = await GET( + createMockRequest('/api/salary/runs/run-2'), + createMockRouteParams({ id: 'run-2' }), + ) + const { status, body } = await parseJsonResponse<{ data: { previous_run: unknown } }>(response) + + expect(status).toBe(200) + expect(body.data.previous_run).toBeNull() + }) + + it('exposes corrected_by_run_id on corrected originals and counts latest deliveries', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + + enqueueMany([ + { data: { ...GET_RUN, status: 'corrected' } }, + { data: [] }, + { data: null }, // settings + { data: null }, // previous booked run + { data: { id: 'run-correction' } }, // corrected_by lookup + { + data: [ + // newest first (route orders sent_at desc): emp-1 latest = sent + { employee_id: 'emp-1', status: 'sent', sent_at: '2026-07-01T10:00:00Z' }, + { employee_id: 'emp-1', status: 'failed', sent_at: '2026-06-30T10:00:00Z' }, + { employee_id: 'emp-2', status: 'skipped', sent_at: '2026-07-01T10:00:00Z' }, + ], + }, + ]) + + const response = await GET( + createMockRequest('/api/salary/runs/run-2'), + createMockRouteParams({ id: 'run-2' }), + ) + const { status, body } = await parseJsonResponse<{ + data: { + corrected_by_run_id: string | null + payslip_deliveries_summary: { sent: number; failed: number; skipped: number } + } + }>(response) + + expect(status).toBe(200) + expect(body.data.corrected_by_run_id).toBe('run-correction') + // Latest attempt per employee: emp-1 sent (failure superseded), emp-2 skipped + expect(body.data.payslip_deliveries_summary).toMatchObject({ + sent: 1, + failed: 0, + skipped: 1, + }) + }) +}) diff --git a/app/api/salary/runs/[id]/approve/__tests__/route.test.ts b/app/api/salary/runs/[id]/approve/__tests__/route.test.ts index 98abe101..899f9239 100644 --- a/app/api/salary/runs/[id]/approve/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/approve/__tests__/route.test.ts @@ -137,4 +137,82 @@ describe('POST /api/salary/runs/[id]/approve: bank-detail guard', () => { expect(status).toBe(200) }) + + it('flags the bank-detail block as overridable so the UI can offer "Godkänn ändå"', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + + enqueueMany([ + { data: { id: 'run-1', status: 'review', company_id: 'company-1' } }, + { data: [runEmp({ first_name: 'Test', last_name: 'Testsson', net_salary: 24000, tax_withheld: 8000 })] }, + ]) + + const request = createMockRequest('/api/salary/runs/run-1/approve', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + const { status, body } = await parseJsonResponse<{ code: string; overridable: boolean }>(response) + + expect(status).toBe(400) + expect(body.code).toBe('SALARY_APPROVE_BANK_DETAILS_MISSING') + expect(body.overridable).toBe(true) + }) + + it('approves past missing bank details when ?force=true, surfacing the reminder as a warning', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + + enqueueMany([ + { data: { id: 'run-1', status: 'review', company_id: 'company-1' } }, + { data: [runEmp({ first_name: 'Test', last_name: 'Testsson', net_salary: 24000, tax_withheld: 8000 })] }, + { data: { id: 'run-1', status: 'approved' } }, // update + ]) + + const request = createMockRequest('/api/salary/runs/run-1/approve', { + method: 'POST', + searchParams: { force: 'true' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + const { status, body } = await parseJsonResponse<{ data: { status: string }; warnings: string[] }>(response) + + expect(status).toBe(200) + expect(body.data.status).toBe('approved') + expect(body.warnings.some((w) => w.includes('Bankuppgifter saknas'))).toBe(true) + }) + + it('does not let ?force=true bypass a missing calculation (hard block)', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + + enqueueMany([ + { data: { id: 'run-1', status: 'review', company_id: 'company-1' } }, + { + data: [ + { + net_salary: 24000, + tax_withheld: 8000, + tax_withheld_override: null, + calculation_breakdown: null, // never calculated → hard block + employee: { + first_name: 'Test', + last_name: 'Testsson', + clearing_number: '8327', + bank_account_number: '1234567', + email: 'employee@example.com', + }, + }, + ], + }, + ]) + + const request = createMockRequest('/api/salary/runs/run-1/approve', { + method: 'POST', + searchParams: { force: 'true' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + const { status, body } = await parseJsonResponse<{ code: string; overridable: boolean; details: string[] }>(response) + + expect(status).toBe(400) + expect(body.code).toBe('SALARY_APPROVE_BLOCKED') + expect(body.overridable).toBe(false) + expect(body.details[0]).toContain('Beräkning saknas') + }) }) diff --git a/app/api/salary/runs/[id]/approve/route.ts b/app/api/salary/runs/[id]/approve/route.ts index a7180faa..8978269a 100644 --- a/app/api/salary/runs/[id]/approve/route.ts +++ b/app/api/salary/runs/[id]/approve/route.ts @@ -6,11 +6,15 @@ import { effectiveNetPayout } from '@/lib/salary/payment/effective-net' ensureInitialized() -/** review → approved (authorization recorded, with pre-approve validation) */ +/** review → approved (authorization recorded, with pre-approve validation). + * ?force=true bypasses the overridable "bank details missing" guard — approval + * is an authorization step, so the user may approve now and complete bank + * details before generating the payment file (which hard-blocks on its own). */ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( 'salary.run.approve', - async (_request, { supabase, companyId, user }, { params }) => { + async (request, { supabase, companyId, user }, { params }) => { const { id } = await params + const force = new URL(request.url).searchParams.get('force') === 'true' // Verify run exists and is in review status const { data: run, error: runError } = await supabase @@ -31,7 +35,12 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( .select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number, email)') .eq('salary_run_id', id) - const validationErrors: string[] = [] + // Blocking errors can never be overridden (a run with no calculation can't + // be paid at all). Missing bank details are overridable at approval — the + // payment-file generators (pain.001 / BG-LB) enforce them where it actually + // matters, so the user may authorize now and complete details later. + const blockingErrors: string[] = [] + const bankDetailErrors: string[] = [] const warnings: string[] = [] for (const sre of runEmployees || []) { @@ -50,12 +59,12 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( // so no destination account is needed: mirrors the pain.001 / BG-LB // generators, which only include employees with effectiveNet > 0. if (effectiveNetPayout(sre) > 0 && (!emp.clearing_number || !emp.bank_account_number)) { - validationErrors.push(`${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`) + bankDetailErrors.push(`${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`) } // Must have been calculated (calculation_breakdown exists) if (!sre.calculation_breakdown) { - validationErrors.push(`${name}: Beräkning saknas, kör beräkning först`) + blockingErrors.push(`${name}: Beräkning saknas, kör beräkning först`) } // Warning: no email means pay slip cannot be sent @@ -64,15 +73,33 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( } } - if (validationErrors.length > 0) { + if (blockingErrors.length > 0) { return NextResponse.json({ error: 'Valideringsfel: korrigera innan godkännande', - details: validationErrors, + details: blockingErrors, warnings, + code: 'SALARY_APPROVE_BLOCKED', + overridable: false, }, { status: 400 }) } - // All validation passed: approve + // Overridable: block by default so the user is warned, but allow ?force=true + // to approve anyway (the "Godkänn ändå" path). + if (bankDetailErrors.length > 0 && !force) { + return NextResponse.json({ + error: 'Bankuppgifter saknas för en eller flera anställda', + details: bankDetailErrors, + warnings, + code: 'SALARY_APPROVE_BANK_DETAILS_MISSING', + overridable: true, + }, { status: 400 }) + } + + // When forced past missing bank details, keep them visible on the success + // response so the caller can still surface the reminder to complete them. + const approveWarnings = force ? [...bankDetailErrors, ...warnings] : warnings + + // All validation passed (or was explicitly overridden): approve const { data: updatedRun, error } = await supabase .from('salary_runs') .update({ @@ -95,7 +122,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( payload: { salaryRunId: id, approvedBy: user.id, userId: user.id, companyId }, }) - return NextResponse.json({ data: updatedRun, warnings }) + return NextResponse.json({ data: updatedRun, warnings: approveWarnings }) }, { requireWrite: true }, ) diff --git a/app/api/salary/runs/[id]/correct/route.ts b/app/api/salary/runs/[id]/correct/route.ts index 5cc878dc..2a07f6b4 100644 --- a/app/api/salary/runs/[id]/correct/route.ts +++ b/app/api/salary/runs/[id]/correct/route.ts @@ -5,6 +5,7 @@ import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' import { reverseEntry } from '@/lib/bookkeeping/engine' import { bookkeepingErrorResponse, EntryAlreadyReversedError } from '@/lib/bookkeeping/errors' +import { revokeLinksForRun } from '@/lib/salary/payslips/links' ensureInitialized() @@ -77,6 +78,12 @@ export async function POST( .update({ status: 'corrected' }) .eq('id', id) + // The storno replaces the payslips — previously emailed payslip links for + // the original run must stop resolving (they show as "ersatt" to the + // employee). Fresh links are issued when the correction run's payslips + // are sent. + await revokeLinksForRun(supabase, id) + // Create new correction run for same period // Remove the unique constraint conflict by using the original run's unique key // The unique constraint is (company_id, period_year, period_month) so we need diff --git a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts index 9caec56c..98f9c8a5 100644 --- a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts +++ b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts @@ -4,8 +4,7 @@ import { ensureInitialized } from '@/lib/init' import { requireCompanyId } from '@/lib/company/context' import { renderToBuffer } from '@react-pdf/renderer' import { PayslipPDF } from '@/lib/salary/pdf/payslip-template' -import type { PayslipData, PayslipLineItem } from '@/lib/salary/pdf/payslip-template' -import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer' +import { buildPayslipData, payslipFileName } from '@/lib/salary/payslips/build-payslip-data' ensureInitialized() @@ -14,6 +13,9 @@ ensureInitialized() * * Per BFL: Pay slips are räkenskapsinformation/underlag linked to * posted journal entries. Subject to 7-year retention per BFL 7 kap. + * + * Data assembly is shared with the public token surface via + * lib/salary/payslips/build-payslip-data — both must render identical PDFs. */ export async function GET( request: Request, @@ -67,104 +69,8 @@ export async function GET( clearing_number: string | null; bank_account_number: string | null; } - const EMPLOYMENT_LABELS: Record = { - employee: 'Anställd', - company_owner: 'Företagsledare', - board_member: 'Styrelseledamot', - } - - // Build line items for PDF - const lineItems: PayslipLineItem[] = ((sre.line_items || []) as Array>) - .sort((a, b) => ((a.sort_order as number) || 0) - ((b.sort_order as number) || 0)) - .map(li => ({ - description: li.description as string, - quantity: li.quantity as number | undefined, - unitPrice: li.unit_price as number | undefined, - amount: li.amount as number, - })) - - // Build tax reference string - let taxReference = 'Schablon 30%' - if (emp.tax_table_number) { - taxReference = `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}` - } - - // Build breakdown steps from calculation_breakdown, then append rows for - // any manual overrides so the breakdown matches the displayed totals. - // The engine-computed rows stay for transparency ("this is what was - // computed"), and override rows below them show the manual adjustment and - // its reason ("this is what was actually applied"). - const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number }> } | null - const baseSteps = breakdown?.steps ?? [] - const overrideSteps: Array<{ label: string; formula: string; output: number }> = [] - const reason = (sre.override_reason as string | null) || 'manuell justering' - if (sre.tax_withheld_override !== null && sre.tax_withheld_override !== undefined) { - overrideSteps.push({ - label: 'Manuell justering: Skatteavdrag', - formula: reason, - output: Number(sre.tax_withheld_override), - }) - } - if (sre.avgifter_basis_override !== null && sre.avgifter_basis_override !== undefined) { - overrideSteps.push({ - label: 'Manuell justering: Avgiftsunderlag', - formula: reason, - output: Number(sre.avgifter_basis_override), - }) - } - if (sre.avgifter_amount_override !== null && sre.avgifter_amount_override !== undefined) { - overrideSteps.push({ - label: 'Manuell justering: Arbetsgivaravgifter', - formula: reason, - output: Number(sre.avgifter_amount_override), - }) - } - const breakdownSteps = baseSteps.length > 0 || overrideSteps.length > 0 - ? [...baseSteps, ...overrideSteps] - : undefined - - // Build bank account display (masked) - let bankAccount: string | undefined - if (emp.clearing_number && emp.bank_account_number) { - const lastDigits = emp.bank_account_number.slice(-4) - bankAccount = `${emp.clearing_number}-****${lastDigits}` - } - - // Honor advanced-mode per-employee overrides (tax/avgifter) on the payslip - // so the employee sees the same effective values that are booked and AGI- - // reported. - const effectiveTax = sre.tax_withheld_override ?? sre.tax_withheld - const effectiveAvgifter = sre.avgifter_amount_override ?? sre.avgifter_amount - const effectiveNet = sre.net_salary + (sre.tax_withheld - effectiveTax) - - const data: PayslipData = { - companyName: company.name, - companyOrgNumber: company.org_number || '', - employeeName: `${emp.first_name} ${emp.last_name}`, - personnummerMasked: maskPersonnummer(decryptPersonnummer(emp.personnummer)), - employmentType: EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type, - periodYear: run.period_year, - periodMonth: run.period_month, - paymentDate: run.payment_date, - lineItems, - grossSalary: sre.gross_salary, - taxWithheld: effectiveTax, - netSalary: effectiveNet, - taxReference, - avgifterRate: sre.avgifter_rate, - avgifterAmount: effectiveAvgifter, - vacationAccrual: sre.vacation_accrual, - vacationAccrualAvgifter: sre.vacation_accrual_avgifter, - totalEmployerCost: sre.gross_salary + effectiveAvgifter + sre.vacation_accrual + sre.vacation_accrual_avgifter, - ytdGross: sre.ytd_gross, - ytdTax: sre.ytd_tax, - ytdNet: sre.ytd_net, - bankAccount, - breakdownSteps, - } - - const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` - const fileName = `lonespec_${emp.last_name}_${emp.first_name}_${periodLabel}.pdf` + const data = buildPayslipData({ run, sre, employee: emp, company }) + const fileName = payslipFileName(run, emp) // eslint-disable-next-line @typescript-eslint/no-explicit-any const buffer = await renderToBuffer(PayslipPDF({ data }) as any) diff --git a/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts b/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts new file mode 100644 index 00000000..dc5aeadd --- /dev/null +++ b/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, + createMockRouteParams, +} from '@/tests/helpers' + +// The route is wrapped in withRouteContext (auth via requireAuth, company via +// getActiveCompanyId, write gate via requireWritePermission) — mock those. +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) +vi.mock('@/lib/email/service', () => ({ getEmailService: vi.fn() })) +vi.mock('@/lib/branding/service', () => ({ + getBranding: () => ({ appUrl: 'https://app.example.test' }), +})) +vi.mock('@/lib/salary/payslips/links', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + rotateLinkForEmployee: vi.fn(), + } +}) + +import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { getEmailService } from '@/lib/email/service' +import { rotateLinkForEmployee } from '@/lib/salary/payslips/links' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function authed(supabase: unknown) { + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + } as never) +} + +function mockEmail(result: { success: boolean; messageId?: string; error?: string }) { + const sendEmail = vi.fn().mockResolvedValue(result) + vi.mocked(getEmailService).mockReturnValue({ + sendEmail, + isConfigured: () => true, + }) + return sendEmail +} + +const RUN = { + id: 'run-1', + company_id: 'company-1', + status: 'approved', + period_year: 2026, + period_month: 6, + payment_date: '2026-06-25', +} + +describe('POST /api/salary/runs/[id]/payslips/send', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(rotateLinkForEmployee).mockResolvedValue({ token: 'T'.repeat(43) }) + }) + + it('returns 401 when unauthenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + } as never) + + const request = createMockRequest('/api/salary/runs/run-1/payslips/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + expect(response.status).toBe(401) + }) + + it('returns 404 when the run does not exist', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + mockEmail({ success: true }) + enqueueMany([{ data: null }]) + + const request = createMockRequest('/api/salary/runs/run-x/payslips/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-x' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('SALARY_RUN_NOT_FOUND') + }) + + it('returns 400 for a draft run', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + mockEmail({ success: true }) + enqueueMany([{ data: { ...RUN, status: 'draft' } }]) + + const request = createMockRequest('/api/salary/runs/run-1/payslips/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('SALARY_PAYSLIPS_SEND_INVALID_STATUS') + }) + + it('skips employees without email and records the skip', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + const sendEmail = mockEmail({ success: true }) + + enqueueMany([ + { data: RUN }, + { data: { name: 'Bolaget AB', org_number: '5560000000' } }, + { + data: [ + { + employee_id: 'emp-1', + employee: { first_name: 'Anna', last_name: 'A', email: null }, + }, + ], + }, + // delivery insert consumes a default queue entry + ]) + + const request = createMockRequest('/api/salary/runs/run-1/payslips/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + const { status, body } = await parseJsonResponse<{ + data: { sent: number; skipped: number; total: number } + }>(response) + + expect(status).toBe(200) + expect(body.data).toMatchObject({ sent: 0, skipped: 1, total: 1 }) + expect(sendEmail).not.toHaveBeenCalled() + expect(rotateLinkForEmployee).not.toHaveBeenCalled() + }) + + it('rotates a link and emails a URL — never an attachment', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + const sendEmail = mockEmail({ success: true, messageId: 'msg-1' }) + + enqueueMany([ + { data: RUN }, + { data: { name: 'Bolaget AB', org_number: '5560000000' } }, + { + data: [ + { + employee_id: 'emp-1', + employee: { first_name: 'Anna', last_name: 'A', email: 'anna@example.test' }, + }, + ], + }, + ]) + + const request = createMockRequest('/api/salary/runs/run-1/payslips/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + const { status, body } = await parseJsonResponse<{ + data: { sent: number; skipped: number } + }>(response) + + expect(status).toBe(200) + expect(body.data).toMatchObject({ sent: 1, skipped: 0 }) + expect(rotateLinkForEmployee).toHaveBeenCalledWith(supabase, { + companyId: 'company-1', + salaryRunId: 'run-1', + employeeId: 'emp-1', + userId: 'user-1', + }) + + const emailArgs = sendEmail.mock.calls[0][0] + expect(emailArgs.to).toBe('anna@example.test') + expect(emailArgs.html).toContain(`https://app.example.test/payslip/${'T'.repeat(43)}`) + expect(emailArgs.attachments).toBeUndefined() + }) + + it('records provider failures without failing the whole batch', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + mockEmail({ success: false, error: 'rate limited' }) + + enqueueMany([ + { data: RUN }, + { data: { name: 'Bolaget AB', org_number: '5560000000' } }, + { + data: [ + { + employee_id: 'emp-1', + employee: { first_name: 'Anna', last_name: 'A', email: 'anna@example.test' }, + }, + ], + }, + ]) + + const request = createMockRequest('/api/salary/runs/run-1/payslips/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + const { status, body } = await parseJsonResponse<{ + data: { sent: number; errors?: string[] } + }>(response) + + expect(status).toBe(200) + expect(body.data.sent).toBe(0) + expect(body.data.errors).toHaveLength(1) + expect(body.data.errors?.[0]).toContain('rate limited') + }) +}) diff --git a/app/api/salary/runs/[id]/payslips/send/route.ts b/app/api/salary/runs/[id]/payslips/send/route.ts index d0a0ded9..0ab15c16 100644 --- a/app/api/salary/runs/[id]/payslips/send/route.ts +++ b/app/api/salary/runs/[id]/payslips/send/route.ts @@ -1,175 +1,147 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { getEmailService } from '@/lib/email/service' -import { renderToBuffer } from '@react-pdf/renderer' -import { PayslipPDF } from '@/lib/salary/pdf/payslip-template' -import type { PayslipData, PayslipLineItem } from '@/lib/salary/pdf/payslip-template' -import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer' +import { getBranding } from '@/lib/branding/service' +import { rotateLinkForEmployee } from '@/lib/salary/payslips/links' +import { buildPayslipLinkEmail } from '@/lib/salary/payslips/email-template' ensureInitialized() /** - * Send pay slip PDFs to all employees with email addresses. + * Send payslips to all employees with email addresses — as secure LINKS, + * never PDF attachments (salary data + personnummer must not sit in + * inboxes). Each send rotates the employee's link: previously emailed links + * stop resolving. * - * Uses the existing email extension (Resend) for delivery. - * Per BFL 7 kap: Delivery confirmation retained as part of audit trail. + * Per BFL 7 kap.: every attempt (sent/failed/skipped) is persisted to + * salary_payslip_deliveries as the audit trail. */ -export async function POST( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - await params - return NextResponse.json({ error: 'Funktionen är inaktiverad' }, { status: 503 }) -} +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary_run.payslips_send', + async (_request, { supabase, companyId, user, log, requestId }, { params }) => { + const { id } = await params + const emailService = getEmailService() -// Implementation preserved but unreachable: feature disabled at the export above. -// To re-enable, replace the POST export above with this function body. -async function _sendPayslipsImpl( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { data: run } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const emailService = getEmailService() - - // Load salary run - const { data: run } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - if (!['approved', 'paid', 'booked'].includes(run.status)) { - return NextResponse.json({ error: 'Lönespecifikationer kan bara skickas efter godkännande' }, { status: 400 }) - } - - // Load company - const { data: company } = await supabase - .from('companies') - .select('name, org_number') - .eq('id', companyId) - .single() - - if (!company) return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) - - // Load employees with line items - const { data: runEmployees } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, email, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)') - .eq('salary_run_id', id) - - if (!runEmployees || runEmployees.length === 0) { - return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) - } - - const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` - const MONTH_NAMES = ['januari', 'februari', 'mars', 'april', 'maj', 'juni', 'juli', 'augusti', 'september', 'oktober', 'november', 'december'] - const monthName = MONTH_NAMES[run.period_month - 1] - - let sent = 0 - let skipped = 0 - const errors: string[] = [] - - for (const sre of runEmployees) { - const emp = sre.employee as { - first_name: string; last_name: string; personnummer: string; personnummer_last4: string; - employment_type: string; email: string | null; tax_table_number: number | null; - tax_column: number; clearing_number: string | null; bank_account_number: string | null; - } | null - - if (!emp?.email) { - skipped++ - // Persist a 'skipped' record so the audit trail is complete (BFL 7 kap.). - // Use a placeholder address since the column is NOT NULL. - await supabase.from('salary_payslip_deliveries').insert({ - company_id: companyId, - salary_run_id: id, - employee_id: sre.employee_id, - user_id: user.id, - email_address: '(saknas)', - status: 'skipped', - error_message: 'Anställd saknar e-postadress', - }) - continue + if (!run) { + return errorResponseFromCode('SALARY_RUN_NOT_FOUND', log, { requestId }) + } + if (!['approved', 'paid', 'booked'].includes(run.status)) { + return errorResponseFromCode('SALARY_PAYSLIPS_SEND_INVALID_STATUS', log, { requestId }) } - try { - // Build payslip data - const lineItems: PayslipLineItem[] = ((sre.line_items || []) as Array>) - .sort((a, b) => ((a.sort_order as number) || 0) - ((b.sort_order as number) || 0)) - .map(li => ({ - description: li.description as string, - quantity: li.quantity as number | undefined, - unitPrice: li.unit_price as number | undefined, - amount: li.amount as number, - })) + const { data: company } = await supabase + .from('companies') + .select('name, org_number') + .eq('id', companyId) + .single() - let taxReference = 'Schablon 30%' - if (emp.tax_table_number) { - taxReference = `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}` + if (!company) { + return errorResponseFromCode('COMPANY_NOT_FOUND', log, { requestId }) + } + + const { data: runEmployees } = await supabase + .from('salary_run_employees') + .select('employee_id, employee:employees(first_name, last_name, email)') + .eq('salary_run_id', id) + + if (!runEmployees || runEmployees.length === 0) { + return errorResponseFromCode('SALARY_PAYSLIPS_NO_EMPLOYEES', log, { requestId }) + } + + const appUrl = getBranding().appUrl + + let sent = 0 + let skipped = 0 + const errors: string[] = [] + + for (const sre of runEmployees) { + const emp = sre.employee as unknown as { + first_name: string + last_name: string + email: string | null + } | null + + if (!emp?.email) { + skipped++ + // Persist a 'skipped' record so the audit trail is complete + // (BFL 7 kap.). Placeholder address — the column is NOT NULL. + await supabase.from('salary_payslip_deliveries').insert({ + company_id: companyId, + salary_run_id: id, + employee_id: sre.employee_id, + user_id: user.id, + email_address: '(saknas)', + status: 'skipped', + error_message: 'Anställd saknar e-postadress', + }) + continue } - const effectiveTax = sre.tax_withheld_override ?? sre.tax_withheld - const effectiveAvgifter = sre.avgifter_amount_override ?? sre.avgifter_amount - const effectiveNet = sre.net_salary + (sre.tax_withheld - effectiveTax) + try { + const { token } = await rotateLinkForEmployee(supabase, { + companyId, + salaryRunId: id, + employeeId: sre.employee_id, + userId: user.id, + }) - const data: PayslipData = { - companyName: company.name, - companyOrgNumber: company.org_number || '', - employeeName: `${emp.first_name} ${emp.last_name}`, - personnummerMasked: maskPersonnummer(decryptPersonnummer(emp.personnummer)), - employmentType: emp.employment_type, - periodYear: run.period_year, - periodMonth: run.period_month, - paymentDate: run.payment_date, - lineItems, - grossSalary: sre.gross_salary, - taxWithheld: effectiveTax, - netSalary: effectiveNet, - taxReference, - avgifterRate: sre.avgifter_rate, - avgifterAmount: effectiveAvgifter, - vacationAccrual: sre.vacation_accrual, - vacationAccrualAvgifter: sre.vacation_accrual_avgifter, - totalEmployerCost: sre.gross_salary + effectiveAvgifter + sre.vacation_accrual + sre.vacation_accrual_avgifter, - ytdGross: sre.ytd_gross, - ytdTax: sre.ytd_tax, - ytdNet: sre.ytd_net, - } + const email = buildPayslipLinkEmail({ + employeeFirstName: emp.first_name, + companyName: company.name, + periodYear: run.period_year, + periodMonth: run.period_month, + paymentDate: run.payment_date, + url: `${appUrl}/payslip/${token}`, + }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const pdfBuffer = await renderToBuffer(PayslipPDF({ data }) as any) + const sendResult = await emailService.sendEmail({ + to: emp.email, + subject: email.subject, + html: email.html, + text: email.text, + }) - const sendResult = await emailService.sendEmail({ - to: emp.email, - subject: `Lönespecifikation ${monthName} ${run.period_year}: ${company.name}`, - html: `

Hej ${emp.first_name},

-

Bifogat finner du din lönespecifikation för ${monthName} ${run.period_year}.

-

Utbetalningsdag: ${run.payment_date}

-

Med vänliga hälsningar,
${company.name}

`, - text: `Hej ${emp.first_name},\n\nBifogat finner du din lönespecifikation för ${monthName} ${run.period_year}.\n\nUtbetalningsdag: ${run.payment_date}\n\nMed vänliga hälsningar,\n${company.name}`, - attachments: [{ - filename: `lonespec_${emp.last_name}_${emp.first_name}_${periodLabel}.pdf`, - content: Buffer.from(pdfBuffer), - }], - }) + if (!sendResult.success) { + const msg = sendResult.error || 'E-postleverantör returnerade ett fel' + errors.push(`${emp.first_name} ${emp.last_name}: ${msg}`) + await supabase.from('salary_payslip_deliveries').insert({ + company_id: companyId, + salary_run_id: id, + employee_id: sre.employee_id, + user_id: user.id, + email_address: emp.email, + status: 'failed', + provider: 'resend', + error_message: msg.slice(0, 500), + }) + continue + } - if (!sendResult.success) { - const msg = sendResult.error || 'E-postlevereantör returnerade ett fel' + await supabase.from('salary_payslip_deliveries').insert({ + company_id: companyId, + salary_run_id: id, + employee_id: sre.employee_id, + user_id: user.id, + email_address: emp.email, + status: 'sent', + provider: 'resend', + provider_message_id: sendResult.messageId ?? null, + }) + + sent++ + } catch (err) { + const msg = err instanceof Error ? err.message : 'Okänt fel' errors.push(`${emp.first_name} ${emp.last_name}: ${msg}`) + await supabase.from('salary_payslip_deliveries').insert({ company_id: companyId, salary_run_id: id, @@ -180,44 +152,17 @@ async function _sendPayslipsImpl( provider: 'resend', error_message: msg.slice(0, 500), }) - continue } - - await supabase.from('salary_payslip_deliveries').insert({ - company_id: companyId, - salary_run_id: id, - employee_id: sre.employee_id, - user_id: user.id, - email_address: emp.email, - status: 'sent', - provider: 'resend', - provider_message_id: sendResult.messageId ?? null, - }) - - sent++ - } catch (err) { - const msg = err instanceof Error ? err.message : 'Okänt fel' - errors.push(`${emp.first_name} ${emp.last_name}: ${msg}`) - - await supabase.from('salary_payslip_deliveries').insert({ - company_id: companyId, - salary_run_id: id, - employee_id: sre.employee_id, - user_id: user.id, - email_address: emp.email, - status: 'failed', - provider: 'resend', - error_message: msg.slice(0, 500), - }) } - } - return NextResponse.json({ - data: { - sent, - skipped, - errors: errors.length > 0 ? errors : undefined, - total: runEmployees.length, - }, - }) -} + return NextResponse.json({ + data: { + sent, + skipped, + errors: errors.length > 0 ? errors : undefined, + total: runEmployees.length, + }, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/route.ts b/app/api/salary/runs/[id]/route.ts index 21309dc8..7bfe460b 100644 --- a/app/api/salary/runs/[id]/route.ts +++ b/app/api/salary/runs/[id]/route.ts @@ -22,22 +22,105 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) } - // Load employees with line items - const { data: employees } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type, default_dimensions), line_items:salary_line_items(*)') - .eq('salary_run_id', id) - .order('created_at') + // These five reads only depend on `run` (already fetched) + companyId, so + // fire them concurrently — the detail GET is on the hot path for every + // status transition, and serial round-trips dominated its latency. + type PreviousRun = { + id: string + period_year: number + period_month: number + by_employee: Record + } - // Resolve Skatteverket arbetsgivare ID for AGI submission. We surface this - // in the run payload so the client doesn't need a second round-trip just to - // build extension URLs. Quietly null when the org number isn't set yet. + const [employeesResult, settingsResult, previousRun, correctedByRunId, deliveriesResult] = + await Promise.all([ + // Employees with line items. A failed embed here (e.g. a schema/column + // mismatch on the joined tables) must surface — silently returning an + // empty list makes the run look employee-less, which then lets the + // client offer an already-added employee and get a confusing 409. + supabase + .from('salary_run_employees') + .select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type, default_dimensions), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + .order('created_at'), + // Skatteverket arbetsgivare ID for AGI submission. + supabase + .from('company_settings') + .select('org_number, entity_type') + .eq('company_id', companyId) + .maybeSingle(), + // Latest booked run before this period — powers the Δ-vs-last-month + // column. Effective values (overrides coalesced) so the diff matches + // what was actually booked and AGI-reported. + (async (): Promise => { + const { data: prev } = await supabase + .from('salary_runs') + .select('id, period_year, period_month') + .eq('company_id', companyId) + .eq('status', 'booked') + .or( + `period_year.lt.${run.period_year},and(period_year.eq.${run.period_year},period_month.lt.${run.period_month})`, + ) + .order('period_year', { ascending: false }) + .order('period_month', { ascending: false }) + .limit(1) + .maybeSingle() + + if (!prev) return null + + const { data: prevEmployees } = await supabase + .from('salary_run_employees') + .select('employee_id, gross_salary, tax_withheld, tax_withheld_override, net_salary') + .eq('salary_run_id', prev.id) + .eq('company_id', companyId) + + const byEmployee: Record = {} + for (const row of prevEmployees || []) { + const effTax = row.tax_withheld_override ?? row.tax_withheld + byEmployee[row.employee_id] = { + gross: row.gross_salary, + tax: effTax, + net: row.net_salary + (row.tax_withheld - effTax), + } + } + return { + id: prev.id, + period_year: prev.period_year, + period_month: prev.period_month, + by_employee: byEmployee, + } + })(), + // Reverse correction link so corrected originals can point forward. + (async (): Promise => { + if (run.status !== 'corrected') return null + const { data: correction } = await supabase + .from('salary_runs') + .select('id') + .eq('company_id', companyId) + .eq('corrects_run_id', id) + .limit(1) + .maybeSingle() + return correction?.id ?? null + })(), + // Latest payslip delivery per employee → counts for the Lönebesked step. + supabase + .from('salary_payslip_deliveries') + .select('employee_id, status, sent_at') + .eq('salary_run_id', id) + .eq('company_id', companyId) + .order('sent_at', { ascending: false }), + ]) + + const { data: employees, error: employeesError } = employeesResult + if (employeesError) { + return NextResponse.json( + { error: `Kunde inte läsa anställda för lönekörningen: ${employeesError.message}` }, + { status: 500 }, + ) + } + + const settings = settingsResult.data let arbetsgivare: string | null = null - const { data: settings } = await supabase - .from('company_settings') - .select('org_number, entity_type') - .eq('company_id', companyId) - .maybeSingle() if (settings?.org_number && settings?.entity_type) { try { arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type) @@ -46,10 +129,32 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( } } + const deliveries = deliveriesResult.data + const latestByEmployee = new Map() + for (const d of deliveries || []) { + if (!latestByEmployee.has(d.employee_id)) { + latestByEmployee.set(d.employee_id, d.status) + } + } + const deliveriesSummary = { + sent: 0, + failed: 0, + skipped: 0, + last_sent_at: deliveries?.[0]?.sent_at ?? null, + } + for (const status of latestByEmployee.values()) { + if (status === 'sent' || status === 'delivered') deliveriesSummary.sent++ + else if (status === 'skipped') deliveriesSummary.skipped++ + else deliveriesSummary.failed++ + } + return NextResponse.json({ data: { ...run, arbetsgivare, + previous_run: previousRun, + corrected_by_run_id: correctedByRunId, + payslip_deliveries_summary: deliveriesSummary, employees: (employees || []).map(emp => ({ ...emp, employee: emp.employee ? { diff --git a/app/api/salary/runs/__tests__/route.test.ts b/app/api/salary/runs/__tests__/route.test.ts new file mode 100644 index 00000000..c35b8fb8 --- /dev/null +++ b/app/api/salary/runs/__tests__/route.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, +} from '@/tests/helpers' + +// withRouteContext dependencies. +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) +vi.mock('@/lib/events', () => ({ eventBus: { emit: vi.fn().mockResolvedValue(undefined) } })) +// The seeding + calculation libs have their own tests — stub them here and +// assert on the wiring (defaults, conflict handling, non-fatal calc). +vi.mock('@/lib/salary/create-run', () => ({ createSalaryRunWithEmployees: vi.fn() })) +vi.mock('@/lib/salary/run-calculation', () => ({ runSalaryCalculation: vi.fn() })) + +import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { createSalaryRunWithEmployees } from '@/lib/salary/create-run' +import { runSalaryCalculation } from '@/lib/salary/run-calculation' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function authed(supabase: unknown) { + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + } as never) +} + +function post(body: unknown = {}) { + return createMockRequest('/api/salary/runs', { method: 'POST', body }) +} + +const CREATED_RUN = { id: 'run-new', period_year: 2026, period_month: 7, status: 'draft' } + +describe('POST /api/salary/runs — one-click creation', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(createSalaryRunWithEmployees).mockResolvedValue({ + run: { ...CREATED_RUN }, + employeeCount: 3, + }) + vi.mocked(runSalaryCalculation).mockResolvedValue({ + ok: true, + run: { ...CREATED_RUN, total_gross: 105000 }, + warnings: [], + }) + }) + + it('returns 401 when unauthenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + } as never) + + const response = await POST(post(), { params: Promise.resolve({}) } as never) + expect(response.status).toBe(401) + }) + + it('rejects an invalid body with 400', async () => { + const { supabase } = createQueuedMockSupabase() + authed(supabase) + + const response = await POST(post({ period_month: 13 }), { + params: Promise.resolve({}), + } as never) + expect(response.status).toBe(400) + expect(createSalaryRunWithEmployees).not.toHaveBeenCalled() + }) + + it('resolves defaults from settings + latest run when body is {}', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + + enqueueMany([ + // settings: pay day 27, salary series L + { + data: { + salary_pay_day: 27, + default_voucher_series_per_source_type: { salary_payment: 'L' }, + }, + }, + // latest non-corrected run: 2026-06 → defaults resolve to 2026-07 + { data: { period_year: 2026, period_month: 6 } }, + // conflict pre-check: none + { data: null }, + ]) + + const response = await POST(post({}), { params: Promise.resolve({}) } as never) + const { status, body } = await parseJsonResponse<{ + data: { total_gross?: number } + employee_count: number + calculation: { ok: boolean } + }>(response) + + expect(status).toBe(201) + expect(body.employee_count).toBe(3) + expect(body.calculation.ok).toBe(true) + // Calculation succeeded → recalculated row is returned + expect(body.data.total_gross).toBe(105000) + + expect(createSalaryRunWithEmployees).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', { + periodYear: 2026, + periodMonth: 7, + paymentDate: '2026-07-27', + voucherSeries: 'L', + notes: undefined, + }) + }) + + it('rolls December forward into January of the next year', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + + enqueueMany([ + { data: null }, // no settings row → pay day 25, series 'A' + { data: { period_year: 2026, period_month: 12 } }, + { data: null }, + ]) + + const response = await POST(post({}), { params: Promise.resolve({}) } as never) + expect(response.status).toBe(201) + expect(createSalaryRunWithEmployees).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ periodYear: 2027, periodMonth: 1, paymentDate: '2027-01-25', voucherSeries: 'A' }), + ) + }) + + it('explicit body fields win over defaults', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + + enqueueMany([ + { data: { salary_pay_day: 27, default_voucher_series_per_source_type: { salary_payment: 'L' } } }, + // no latest-run lookup — period was explicit + { data: null }, // conflict pre-check + ]) + + const response = await POST( + post({ period_year: 2026, period_month: 3, payment_date: '2026-03-24', voucher_series: 'B' }), + { params: Promise.resolve({}) } as never, + ) + expect(response.status).toBe(201) + expect(createSalaryRunWithEmployees).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ + periodYear: 2026, + periodMonth: 3, + paymentDate: '2026-03-24', + voucherSeries: 'B', + }), + ) + }) + + it('returns 409 with existingId when an active run exists for the period', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + + enqueueMany([ + { data: null }, // settings + { data: { id: 'run-existing' } }, // conflict pre-check hit (explicit period) + ]) + + const response = await POST( + post({ period_year: 2026, period_month: 6, payment_date: '2026-06-25' }), + { params: Promise.resolve({}) } as never, + ) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { existingId: string } } + }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('CONFLICT') + expect(body.error.details.existingId).toBe('run-existing') + expect(createSalaryRunWithEmployees).not.toHaveBeenCalled() + }) + + it('maps a create race (lib 23505 message) to 409', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + vi.mocked(createSalaryRunWithEmployees).mockRejectedValue( + new Error('Salary run already exists for this period'), + ) + + enqueueMany([ + { data: null }, // settings + { data: null }, // conflict pre-check (race: passes) + ]) + + const response = await POST( + post({ period_year: 2026, period_month: 6, payment_date: '2026-06-25' }), + { params: Promise.resolve({}) } as never, + ) + expect(response.status).toBe(409) + }) + + it('still returns 201 when the chained calculation fails', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + vi.mocked(runSalaryCalculation).mockResolvedValue({ + ok: false, + code: 'SALARY_RUN_TAX_TABLE_MISSING', + }) + + enqueueMany([ + { data: null }, + { data: null }, + ]) + + const response = await POST( + post({ period_year: 2026, period_month: 6, payment_date: '2026-06-25' }), + { params: Promise.resolve({}) } as never, + ) + const { status, body } = await parseJsonResponse<{ + data: { id: string } + calculation: { ok: boolean; code?: string } + }>(response) + + expect(status).toBe(201) + expect(body.data.id).toBe('run-new') + expect(body.calculation).toEqual({ ok: false, code: 'SALARY_RUN_TAX_TABLE_MISSING' }) + }) +}) diff --git a/app/api/salary/runs/route.ts b/app/api/salary/runs/route.ts index ecf7937f..85d42440 100644 --- a/app/api/salary/runs/route.ts +++ b/app/api/salary/runs/route.ts @@ -1,10 +1,13 @@ import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' -import { CreateSalaryRunSchema } from '@/lib/api/schemas' +import { CreateSalaryRunWithDefaultsSchema } from '@/lib/api/schemas' import { eventBus } from '@/lib/events' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { createSalaryRunWithEmployees } from '@/lib/salary/create-run' +import { runSalaryCalculation } from '@/lib/salary/run-calculation' +import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver' ensureInitialized() @@ -38,25 +41,83 @@ export const GET = withRouteContext( }, ) +/** + * One-click run creation. All body fields are optional — defaults resolve + * server-side so the dashboard button can POST {}: + * period → month after the latest non-corrected run, else current month + * payment_date → company_settings.salary_pay_day (default 25) in the period month + * series → per-source-type map entry for 'salary_payment' + * The run is seeded with every active employee (shared lib — same behavior as + * the MCP tool) and calculated immediately; a calculation failure is + * non-fatal (201 with calculation.ok=false, user lands on the draft). + */ export const POST = withRouteContext( 'salary_run.create', async (request, ctx) => { const { user, supabase, companyId, log, requestId } = ctx - const validation = await validateBody(request, CreateSalaryRunSchema, { + const validation = await validateBody(request, CreateSalaryRunWithDefaultsSchema, { log, operation: 'salary_run.create', }) if (!validation.success) return validation.response const body = validation.data + // Settings drive the defaults; tolerate a missing row (fresh company). + const { data: settings } = await supabase + .from('company_settings') + .select('salary_pay_day, default_voucher_series_per_source_type') + .eq('company_id', companyId) + .maybeSingle() + + let periodYear = body.period_year + let periodMonth = body.period_month + if (!periodYear || !periodMonth) { + const { data: latest } = await supabase + .from('salary_runs') + .select('period_year, period_month') + .eq('company_id', companyId) + .neq('status', 'corrected') + .order('period_year', { ascending: false }) + .order('period_month', { ascending: false }) + .limit(1) + .maybeSingle() + + if (latest) { + // Number() pins the untyped (any) Supabase row values to `number`, + // so periodYear/periodMonth stay narrowed after this block. + const latestYear = Number(latest.period_year) + const latestMonth = Number(latest.period_month) + periodYear = latestMonth === 12 ? latestYear + 1 : latestYear + periodMonth = latestMonth === 12 ? 1 : latestMonth + 1 + } else { + const now = new Date() + periodYear = now.getFullYear() + periodMonth = now.getMonth() + 1 + } + } + + // salary_pay_day is 1–28 by CHECK, so the date exists in every month. + const payDay = settings?.salary_pay_day ?? 25 + const paymentDate = + body.payment_date ?? + `${periodYear}-${String(periodMonth).padStart(2, '0')}-${String(payDay).padStart(2, '0')}` + + const voucherSeries = + body.voucher_series ?? resolveDefaultSeriesForSource(settings ?? null, 'salary_payment') + + // Corrected runs coexist with their correction in the same period (the + // unique index is partial), so exclude them — and use maybeSingle(): + // .single() errors on multiple rows and would skip the 409. const { data: existing } = await supabase .from('salary_runs') .select('id') .eq('company_id', companyId) - .eq('period_year', body.period_year) - .eq('period_month', body.period_month) - .single() + .eq('period_year', periodYear) + .eq('period_month', periodMonth) + .neq('status', 'corrected') + .limit(1) + .maybeSingle() if (existing) { return errorResponseFromCode('CONFLICT', log, { @@ -64,46 +125,75 @@ export const POST = withRouteContext( details: { reason: 'salary_run_exists_for_period', existingId: existing.id, - periodYear: body.period_year, - periodMonth: body.period_month, + periodYear, + periodMonth, }, }) } - const { data: run, error } = await supabase - .from('salary_runs') - .insert({ - company_id: companyId, - user_id: user.id, - period_year: body.period_year, - period_month: body.period_month, - payment_date: body.payment_date, - voucher_series: body.voucher_series, - notes: body.notes || null, + let run: Record + let employeeCount: number + try { + const created = await createSalaryRunWithEmployees(supabase, companyId, user.id, { + periodYear, + periodMonth, + paymentDate, + voucherSeries, + notes: body.notes, }) - .select() - .single() - - if (error) { - log.error('salary run insert failed', error) + run = created.run + employeeCount = created.employeeCount + } catch (err) { + const message = err instanceof Error ? err.message : 'unknown error' + // Race with a concurrent create — the lib maps 23505 to this message. + if (message.includes('already exists')) { + return errorResponseFromCode('CONFLICT', log, { + requestId, + details: { reason: 'salary_run_exists_for_period', periodYear, periodMonth }, + }) + } + log.error('salary run create failed', err as Error) return errorResponseFromCode('SALARY_RUN_CREATE_FAILED', log, { requestId, - details: { reason: error.message }, + details: { reason: message }, }) } + // Chain the calculation so the run lands review-ready. Empty rosters are + // valid (nolldeklaration). Failure is non-fatal: the draft still exists + // and the run page shows Beräkna as the pending step. + const calcResult = await runSalaryCalculation({ + supabase, + companyId, + salaryRunId: run.id as string, + log, + requestId, + }) + + const calculation = calcResult.ok + ? { ok: true as const, warnings: calcResult.warnings } + : { ok: false as const, code: calcResult.code } + if (calcResult.ok) { + run = calcResult.run + } else { + log.warn('chained salary calculation failed', { code: calcResult.code }) + } + await eventBus.emit({ type: 'salary_run.created', payload: { - salaryRunId: run.id, - periodYear: body.period_year, - periodMonth: body.period_month, + salaryRunId: run.id as string, + periodYear, + periodMonth, userId: user.id, companyId: companyId!, }, }) - return NextResponse.json({ data: run }, { status: 201 }) + return NextResponse.json( + { data: run, employee_count: employeeCount, calculation }, + { status: 201 }, + ) }, { requireWrite: true }, ) diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index fcfcc5a6..9103b7e1 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -100,6 +100,20 @@ describe('GET /api/supplier-invoices', () => { expect(status).toBe(200) }) + it('applies supplier_id filter', async () => { + const invoices = [makeSupplierInvoice({ supplier_id: 'supplier-1' })] + enqueue({ data: invoices, error: null }) + + const request = createMockRequest('/api/supplier-invoices', { + searchParams: { status: 'all', supplier_id: 'supplier-1' }, + }) + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(invoices) + }) + it('returns 500 on database error', async () => { enqueue({ data: null, error: { message: 'DB error' } }) diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index cb496b25..0a9e1975 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -23,12 +23,19 @@ export const GET = withRouteContext( const { searchParams } = new URL(request.url) const status = searchParams.get('status') + const supplierId = searchParams.get('supplier_id') let query = supabase .from('supplier_invoices') .select('*, supplier:suppliers(id, name)') .eq('company_id', companyId) + // Optional narrowing to one supplier — the supplier detail page only + // needs that supplier's invoices, not the whole company ledger. + if (supplierId) { + query = query.eq('supplier_id', supplierId) + } + if (status && status !== 'all') { if (status === 'to_pay') { query = query.in('status', ['approved', 'overdue']) diff --git a/app/api/tax-deadlines/generate/route.ts b/app/api/tax-deadlines/generate/route.ts index ee3fd413..26349788 100644 --- a/app/api/tax-deadlines/generate/route.ts +++ b/app/api/tax-deadlines/generate/route.ts @@ -1,61 +1,52 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' /** * POST /api/tax-deadlines/generate * Manually trigger tax deadline generation for the current user */ -export async function POST() { - const supabase = await createClient() +export const POST = withRouteContext( + 'tax_deadlines.generate', + async (_request, ctx) => { + const { supabase, companyId, log } = ctx - const { data: { user } } = await supabase.auth.getUser() + // Fetch company settings + const { data: settings, error: settingsError } = await supabase + .from('company_settings') + .select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month') + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (settingsError || !settings) { + return NextResponse.json( + { error: 'Company settings not found' }, + { status: 404 } + ) + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + try { + const result = await regenerateTaxDeadlinesForUser(supabase, companyId, { + entity_type: settings.entity_type, + moms_period: settings.moms_period, + f_skatt: settings.f_skatt, + vat_registered: settings.vat_registered, + pays_salaries: settings.pays_salaries ?? false, + fiscal_year_start_month: settings.fiscal_year_start_month, + }) - const companyId = await requireCompanyId(supabase, user.id) - - // Fetch company settings - const { data: settings, error: settingsError } = await supabase - .from('company_settings') - .select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month') - .eq('company_id', companyId) - .single() - - if (settingsError || !settings) { - return NextResponse.json( - { error: 'Company settings not found' }, - { status: 404 } - ) - } - - try { - const result = await regenerateTaxDeadlinesForUser(supabase, companyId, { - entity_type: settings.entity_type, - moms_period: settings.moms_period, - f_skatt: settings.f_skatt, - vat_registered: settings.vat_registered, - pays_salaries: settings.pays_salaries ?? false, - fiscal_year_start_month: settings.fiscal_year_start_month, - }) - - return NextResponse.json({ - success: true, - created: result.created, - deleted: result.deleted, - }) - } catch (error) { - console.error('Error generating tax deadlines:', error) - return NextResponse.json( - { error: 'Failed to generate tax deadlines' }, - { status: 500 } - ) - } -} + return NextResponse.json({ + success: true, + created: result.created, + deleted: result.deleted, + }) + } catch (error) { + log.error('tax deadline generation failed', error as Error) + return NextResponse.json( + { error: 'Failed to generate tax deadlines' }, + { status: 500 } + ) + } + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/[id]/refresh-exchange-rate/route.ts b/app/api/transactions/[id]/refresh-exchange-rate/route.ts index d61d3e5a..abd3a9dd 100644 --- a/app/api/transactions/[id]/refresh-exchange-rate/route.ts +++ b/app/api/transactions/[id]/refresh-exchange-rate/route.ts @@ -33,7 +33,7 @@ export const POST = withRouteContext( return NextResponse.json({ data: transaction }) } - const rate = await fetchExchangeRate(transaction.currency as Currency, new Date(transaction.date)) + const rate = await fetchExchangeRate(transaction.currency as Currency, new Date(transaction.date), supabase) if (!rate) { return errorResponseFromCode('TX_EXCHANGE_RATE_UNAVAILABLE', log, { requestId, diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts index ab28c33c..5ec47b11 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts @@ -39,13 +39,16 @@ const CreditNoteRequest = z.object({ }) const ORIGINAL_INVOICE_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type' + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, default_dimensions' +// default_dimensions stays in this projection: the inserted credit-note row is +// handed to createCreditNoteJournalEntry, which reads the bag off the row so +// the reversing JE nets against the same dimension cells as the original. const CREDIT_NOTE_RESPONSE_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, paid_at, paid_amount, remaining_amount, created_at, updated_at' + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, paid_at, paid_amount, remaining_amount, default_dimensions, created_at, updated_at' const ORIGINAL_ITEMS_COLUMNS = - 'sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount' + 'sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, dimensions' const CreditNoteCreated = z.object({ id: z.string().uuid(), @@ -181,6 +184,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string line_total: number vat_rate?: number | null vat_amount?: number | null + dimensions?: Record | null }> } const original = originalInvoice as unknown as OriginalShape @@ -246,6 +250,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string credited_invoice_id: originalId, status: 'sent' as const, document_type: 'invoice' as const, + // Copy the original's dimension bag so the credit-note verifikat nets + // against the same dimension cells in reports (dimensions PR7). + default_dimensions: original.default_dimensions ?? {}, } const creditNoteItems = (original.items ?? []).map((item) => ({ @@ -257,6 +264,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string line_total: -Math.abs(item.line_total), vat_rate: item.vat_rate ?? 0, vat_amount: -Math.abs(item.vat_amount ?? 0), + // Same reasoning: the reversal must carry the exact per-item bag the + // original booked with (dimensions PR7). + dimensions: item.dimensions ?? {}, })) // Fetch settings for accounting method + entity type. diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts index 392607e5..a863b781 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts @@ -46,8 +46,11 @@ import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment' import { roundOre } from '@/lib/money' import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types' +// default_dimensions must stay in this projection: the fetched row feeds the +// payment/cash JE generators, which re-propagate the bag onto every leg — +// dropping the column here silently untags the payment voucher. const INVOICE_MARK_PAID_RESPONSE_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, created_at, updated_at' + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, created_at, updated_at' const InvoiceMarkPaidResponse = z.object({ id: z.string().uuid(), @@ -176,7 +179,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const { data: invoice, error: fetchErr } = await ctx.supabase .from('invoices') .select( - `${INVOICE_MARK_PAID_RESPONSE_COLUMNS}, journal_entry_id, customer:customers(id, name, customer_type), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount)`, + `${INVOICE_MARK_PAID_RESPONSE_COLUMNS}, journal_entry_id, customer:customers(id, name, customer_type), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, dimensions)`, ) .eq('company_id', ctx.companyId!) .eq('id', invoiceId) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts index 891ec3ac..e4c5092d 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts @@ -45,8 +45,11 @@ import { eventBus } from '@/lib/events' import type { EntityType, Invoice } from '@/types' // Explicit projection: drops user_id, company_id (internal scoping). +// default_dimensions must stay in this projection: the fetched row feeds +// createInvoiceJournalEntry, which reads the bag off the row: dropping the +// column here silently untags the revenue JE lines. const INVOICE_MARK_SENT_RESPONSE_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, created_at, updated_at' + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, created_at, updated_at' const InvoiceMarkSentResponse = z.object({ id: z.string().uuid(), @@ -126,7 +129,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const { data: invoice, error: fetchErr } = await ctx.supabase .from('invoices') .select( - `${INVOICE_MARK_SENT_RESPONSE_COLUMNS}, customer:customers(id, name, customer_type, country), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, revenue_account)`, + `${INVOICE_MARK_SENT_RESPONSE_COLUMNS}, customer:customers(id, name, customer_type, country), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, revenue_account, dimensions)`, ) .eq('company_id', ctx.companyId!) .eq('id', invoiceId) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts index 6fb7eb01..86b047bd 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts @@ -57,13 +57,13 @@ const ALLOWED_EXPAND = ['items', 'payments'] as const // VAT treatment, conversion, FX, and notes, but still drops user_id and // company_id (internal scoping). const INVOICE_DETAIL_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, created_at, updated_at' + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, created_at, updated_at' const CUSTOMER_DETAIL_COLUMNS = 'id, name, customer_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, default_payment_terms, notes, archived_at, created_at, updated_at' const INVOICE_ITEM_COLUMNS = - 'id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, created_at' + 'id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, dimensions, created_at' // Payment projection: drops invoice_id (redundant on the parent), user_id, // company_id (internal scoping). diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index 2bf64da1..3bf8748c 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -59,8 +59,11 @@ import { requireCapability } from '@/lib/entitlements/has-capability' import { CAPABILITY } from '@/lib/entitlements/keys' import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types' +// default_dimensions must stay in this projection: the fetched row feeds +// createInvoiceJournalEntry, which reads the bag off the row — dropping the +// column here silently untags the revenue JE lines. const INVOICE_SEND_RESPONSE_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, created_at, updated_at' + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, created_at, updated_at' const InvoiceSendResponse = z.object({ id: z.string().uuid(), @@ -159,7 +162,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const { data: invoice, error: fetchErr } = await ctx.supabase .from('invoices') .select( - `${INVOICE_SEND_RESPONSE_COLUMNS}, customer:customers(id, name, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, revenue_account)`, + `${INVOICE_SEND_RESPONSE_COLUMNS}, customer:customers(id, name, email, customer_type, country, address_line1, address_line2, postal_code, city, vat_number), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, revenue_account, dimensions)`, ) .eq('company_id', ctx.companyId!) .eq('id', invoiceId) diff --git a/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts index bc3c5319..678974e5 100644 --- a/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts @@ -637,6 +637,144 @@ describe('POST /api/v1/companies/:companyId/invoices', () => { const body = await res.json() expect(body.error.code).toBe('VALIDATION_ERROR') }) + + it('persists default_dimensions + items[].dimensions on the draft', async () => { + withInvoiceWriteScope() + const createdInvoice = { + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + invoice_number: null, + customer_id: CUSTOMER_ID, + status: 'draft', + document_type: 'invoice', + } + let insertedInvoice: Record | null = null + let insertedItems: Array> | null = null + mockServiceClient.mockReturnValue({ + from: (table: string) => { + if (table === 'invoices') { + return new Proxy({}, { + get(_t, prop) { + if (prop === 'insert') { + return (row: Record) => { + insertedInvoice = row + return new Proxy({}, { + get(_t2, prop2) { + if (prop2 === 'then') { + return (r: (v: unknown) => void) => r({ data: createdInvoice, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + } + if (prop === 'then') { + return (r: (v: unknown) => void) => r({ data: createdInvoice, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + if (table === 'invoice_items') { + return new Proxy({}, { + get(_t, prop) { + if (prop === 'insert') { + return (rows: Array>) => { + insertedItems = rows + return new Proxy({}, { + get(_t2, prop2) { + if (prop2 === 'then') { + return (r: (v: unknown) => void) => r({ data: null, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + } + if (prop === 'then') { + return (r: (v: unknown) => void) => r({ data: null, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + return new Proxy({}, { + get(_t, prop) { + if (prop === 'then') { + const data = table === 'company_members' + ? { company_id: COMPANY_ID, role: 'owner' } + : table === 'customers' + ? SWEDISH_BUSINESS_CUSTOMER + : null + return (r: (v: unknown) => void) => r({ data, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + }, + }) + + const res = await createInvoice( + makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + default_dimensions: { '6': 'P001' }, + items: [ + { + description: 'Konsultation', + quantity: 8, + unit: 'tim', + unit_price: 1250, + dimensions: { '1': 'KS01' }, + }, + ], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + expect(insertedInvoice).not.toBeNull() + expect(insertedInvoice!.default_dimensions).toEqual({ '6': 'P001' }) + expect(insertedItems).not.toBeNull() + expect(insertedItems![0].dimensions).toEqual({ '1': 'KS01' }) + }) + + it('dry-run preview carries default_dimensions and per-item dimensions', async () => { + withInvoiceWriteScope() + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: SWEDISH_BUSINESS_CUSTOMER, error: null }, + }), + ) + + const res = await createInvoice( + makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices?dry_run=true`, { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + default_dimensions: { '6': 'P001' }, + items: [ + { + description: 'Konsultation', + quantity: 8, + unit: 'tim', + unit_price: 1250, + dimensions: { '1': 'KS01' }, + }, + ], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + const body = await res.json() + expect(body.data.preview.default_dimensions).toEqual({ '6': 'P001' }) + expect(body.data.preview.items[0].dimensions).toEqual({ '1': 'KS01' }) + }) }) // ────────────────────────────────────────────────────────────────── diff --git a/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts b/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts index 31a1bec1..5493ab88 100644 --- a/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts @@ -260,6 +260,9 @@ async function createOneInvoice( line_total: lineTotal, vat_rate: itemRate, vat_amount: itemVat, + // Dimensions PR7: per-item bag, merged over the invoice's + // default_dimensions on the revenue line when the JE posts at :send. + dimensions: item.dimensions ?? {}, } }) @@ -314,6 +317,9 @@ async function createOneInvoice( our_reference: input.our_reference, notes: input.notes, document_type: documentType, + // Dimensions PR7: invoice-level bag; the :send JE generator applies it + // to every line (items[].dimensions win per key). + default_dimensions: input.default_dimensions ?? {}, }) .select(INVOICE_BULK_RESPONSE_COLUMNS) .single() diff --git a/app/api/v1/companies/[companyId]/invoices/route.ts b/app/api/v1/companies/[companyId]/invoices/route.ts index 24c7c09d..cacef9bf 100644 --- a/app/api/v1/companies/[companyId]/invoices/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/route.ts @@ -298,10 +298,10 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( // Response projection on create: same shape as the detail endpoint. // Drop user_id, company_id (internal scoping). const INVOICE_RESPONSE_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, created_at, updated_at' + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, created_at, updated_at' const INVOICE_ITEMS_RESPONSE_COLUMNS = - 'id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, created_at' + 'id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, dimensions, created_at' // Loose response schema: invoices have many fields; pinning every one in // the registry is overkill until we have a real schema-drift test. @@ -338,6 +338,7 @@ registerEndpoint({ 'Non-SEK currencies require an active Riksbanken exchange-rate fetch. Failure is non-fatal: the invoice is created with null SEK fields and the agent can recompute later.', 'invoice_number is null on creation. The number is allocated atomically when the invoice transitions out of draft. Counting on a specific number at create time is a bug.', 'document_type=\'delivery_note\' produces no VAT and a different number sequence (D-series). Most use cases want the default document_type=\'invoice\'.', + 'Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). Tags are stored on the draft and applied to the journal entry lines when the invoice is sent. When the company has the dimension registry enabled, unknown or archived codes are rejected at :send with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.', ], example: { request: { @@ -504,6 +505,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( line_total: lineTotal, vat_rate: itemRate, vat_amount: itemVat, + // Dimensions PR7: per-item bag, merged over the invoice's + // default_dimensions on the revenue line when the JE posts at :send. + dimensions: item.dimensions ?? {}, } }) @@ -538,6 +542,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( notes: input.notes ?? null, document_type: documentType, remaining_amount: documentType === 'invoice' ? total : 0, + default_dimensions: input.default_dimensions ?? {}, items: itemRows, }, { requestId: ctx.requestId, log: ctx.log }, @@ -584,6 +589,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( our_reference: input.our_reference, notes: input.notes, document_type: documentType, + // Dimensions PR7: invoice-level bag; the :send JE generator applies it + // to every line (items[].dimensions win per key). + default_dimensions: input.default_dimensions ?? {}, }) .select(INVOICE_RESPONSE_COLUMNS) .single() diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts index 4c3fa497..018ff972 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts @@ -31,8 +31,11 @@ import { eventBus } from '@/lib/events' import type { SupabaseClient } from '@supabase/supabase-js' import type { AccountingMethod, SupplierInvoice, SupplierInvoiceItem } from '@/types' +// default_dimensions stays in this projection: the inserted credit-note row is +// handed to createSupplierCreditNoteEntry, which reads the bag off the row so +// the reversing JE nets against the same dimension cells as the original. const SI_RESPONSE_COLUMNS = - 'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, created_at, updated_at' + 'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, default_dimensions, created_at, updated_at' // GDPR Art.25 data minimisation: the original SI's `user_id` (the row's // historical creator) is never used in the credit flow: the new credit-note @@ -55,9 +58,9 @@ const SI_FULL_COLUMNS = ` currency, exchange_rate, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, remaining_amount, - is_credit_note, credited_invoice_id, arrival_number, + is_credit_note, credited_invoice_id, arrival_number, default_dimensions, supplier:suppliers(id, name, supplier_type), - items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate) + items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions) ` const SupplierInvoiceCredited = z.object({ @@ -154,6 +157,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string credited_invoice_id: string | null supplier_invoice_number: string arrival_number: number + default_dimensions: Record | null supplier: SupplierObj | SupplierObj[] | null items?: Array<{ sort_order: number @@ -167,6 +171,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string vat_rate: number vat_amount: number reverse_charge_rate: number | null + dimensions: Record | null }> } & Record @@ -278,6 +283,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string remaining_amount: 0, is_credit_note: true, credited_invoice_id: typed.id, + // Copy the original's dimension bag so the credit-note verifikat nets + // against the same dimension cells in reports (dimensions PR7). + default_dimensions: typed.default_dimensions ?? {}, }) .select(SI_RESPONSE_COLUMNS) .single() @@ -311,6 +319,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string // Preserve the self-assessed RC rate so the credit note reverses fiktiv // moms at the same rate the original was booked at. reverse_charge_rate: item.reverse_charge_rate, + // Same reasoning: the reversal must carry the exact per-item bag the + // original booked with (dimensions PR7). + dimensions: item.dimensions ?? {}, })) if (creditItems.length > 0) { const { error: itemsErr } = await ctx.supabase diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts index 903fbc22..600e79ad 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts @@ -167,9 +167,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string id, supplier_id, status, currency, exchange_rate, total, paid_amount, remaining_amount, supplier_invoice_number, arrival_number, invoice_date, vat_treatment, reverse_charge, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total_sek, due_date, received_date, - is_credit_note, credited_invoice_id, payment_journal_entry_id, + is_credit_note, credited_invoice_id, payment_journal_entry_id, default_dimensions, supplier:suppliers(id, name, supplier_type), - items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate) + items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions) `) .eq('company_id', ctx.companyId!) .eq('id', invoiceId) diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts index 20af520a..d2294c03 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/route.ts @@ -31,10 +31,10 @@ import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas' const V1PatchSupplierInvoiceSchema = UpdateSupplierInvoiceSchema.strict() const SI_DETAIL_COLUMNS = - 'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_at, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, transaction_id, document_id, notes, reversed_at, created_at, updated_at' + 'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_at, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, transaction_id, document_id, notes, default_dimensions, reversed_at, created_at, updated_at' const SI_ITEM_COLUMNS = - 'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate' + 'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions' const SI_PAYMENT_COLUMNS = 'id, payment_date, amount, currency, exchange_rate, exchange_rate_difference, journal_entry_id, transaction_id, notes, created_at' diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts index 11ee0bec..0bc04ef5 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts @@ -628,6 +628,146 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices', () => { expect(insertedRow!.vat_treatment).toBe('reverse_charge') expect(insertedRow!.reverse_charge).toBe(true) }) + + it('persists default_dimensions + items[].dimensions and hands the item bags to the JE engine', async () => { + let insertedInvoice: Record | null = null + let insertedItems: Array> | null = null + mockServiceClient.mockReturnValue({ + from: (table: string) => { + if (table === 'supplier_invoices') { + return new Proxy({}, { + get(_t, prop) { + if (prop === 'insert') { + return (row: Record) => { + insertedInvoice = row + return new Proxy({}, { + get(_t2, prop2) { + if (prop2 === 'then') { + return (r: (v: unknown) => void) => r({ data: SAMPLE_SI, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + } + if (prop === 'then') { + return (r: (v: unknown) => void) => r({ data: SAMPLE_SI, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + if (table === 'supplier_invoice_items') { + return new Proxy({}, { + get(_t, prop) { + if (prop === 'insert') { + return (rows: Array>) => { + insertedItems = rows + return new Proxy({}, { + get(_t2, prop2) { + if (prop2 === 'then') { + return (r: (v: unknown) => void) => r({ data: null, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + } + if (prop === 'then') { + return (r: (v: unknown) => void) => r({ data: null, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + } + return new Proxy({}, { + get(_t, prop) { + if (prop === 'then') { + const data = table === 'company_members' + ? { company_id: COMPANY_ID, role: 'owner' } + : table === 'suppliers' + ? SAMPLE_SUPPLIER + : table === 'fiscal_periods' + ? { id: 'fp-1', is_closed: false, locked_at: null } + : table === 'company_settings' + ? { bookkeeping_locked_through: null, accounting_method: 'accrual' } + : null + return (r: (v: unknown) => void) => r({ data, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + }, + rpc: vi.fn(() => Promise.resolve({ data: 42, error: null })), + }) + + const res = await createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, { + method: 'POST', + body: JSON.stringify({ + ...validBody, + default_dimensions: { '6': 'P001' }, + items: [ + { + description: 'Office supplies', + amount: 1000, + account_number: '5410', + vat_rate: 0.25, + dimensions: { '1': 'KS01' }, + }, + ], + }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + expect(insertedInvoice).not.toBeNull() + expect(insertedInvoice!.default_dimensions).toEqual({ '6': 'P001' }) + expect(insertedItems).not.toBeNull() + expect(insertedItems![0].dimensions).toEqual({ '1': 'KS01' }) + // The engine receives the item rows WITH their bags so the registration + // JE expense lines are tagged (bag merge happens inside the generator). + expect(mockedReg).toHaveBeenCalledTimes(1) + const engineItems = mockedReg.mock.calls[0][4] as Array<{ dimensions?: Record }> + expect(engineItems[0].dimensions).toEqual({ '1': 'KS01' }) + }) + + it('dry-run preview carries default_dimensions and per-item dimensions', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: SAMPLE_SUPPLIER, error: null }, + company_settings: { data: { bookkeeping_locked_through: null }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + const res = await createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices?dry_run=true`, { + method: 'POST', + body: JSON.stringify({ + ...validBody, + default_dimensions: { '6': 'P001' }, + items: [ + { + description: 'Office supplies', + amount: 1000, + account_number: '5410', + vat_rate: 0.25, + dimensions: { '1': 'KS01' }, + }, + ], + }), + }), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + const body = await res.json() + expect(body.data.preview.default_dimensions).toEqual({ '6': 'P001' }) + expect(body.data.preview.items[0].dimensions).toEqual({ '1': 'KS01' }) + }) }) describe('PATCH /api/v1/companies/:companyId/supplier-invoices/:id', () => { diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts index 84c65008..38d89728 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts @@ -258,11 +258,14 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( // POST: register supplier invoice // ────────────────────────────────────────────────────────────────── +// default_dimensions must stay in this projection: the inserted row is passed +// straight to createSupplierInvoiceRegistrationEntry, which reads the bag off +// the row — dropping the column here silently untags the registration JE. const SI_RESPONSE_COLUMNS = - 'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, notes, created_at, updated_at' + 'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, notes, default_dimensions, created_at, updated_at' const SI_ITEMS_RESPONSE_COLUMNS = - 'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate' + 'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions' const SupplierInvoiceCreated = z.object({ id: z.string().uuid(), @@ -299,6 +302,7 @@ registerEndpoint({ 'Under faktureringsmetoden the registration JE is posted atomically with the SI row. JE failure aborts the whole call and no SI row is left behind (strict-mode).', 'supplier_id must reference an existing, non-archived supplier in the same company: 404 SUPPLIER_NOT_FOUND otherwise.', 'Duplicate (supplier_id, supplier_invoice_number) returns 409 SI_CREATE_DUPLICATE_INVOICE_NUMBER. Use the credit flow on the original instead of re-registering with a tweaked number.', + 'Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). The registration JE lines are tagged accordingly. When the company has the dimension registry enabled, unknown or archived codes are rejected with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.', ], example: { request: { @@ -306,6 +310,7 @@ registerEndpoint({ supplier_invoice_number: '2026-1234', invoice_date: '2026-05-10', due_date: '2026-06-09', + default_dimensions: { '6': 'P001' }, items: [ { description: 'Office supplies', amount: 1000, account_number: '5410', vat_rate: 0.25 }, ], @@ -344,6 +349,7 @@ interface ComputedItem { vat_rate: number vat_amount: number reverse_charge_rate: number | null + dimensions: Record } // Swedish VAT rates per ML 2 kap 1 § + Skatteverket's 2026 satser. Allow @@ -388,6 +394,9 @@ function computeItemsAndTotals(input: z.infer sum + i.line_total, 0) @@ -551,6 +560,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( remaining_amount: total, is_credit_note: false, notes: body.notes ?? null, + default_dimensions: body.default_dimensions ?? {}, items, // Indicate what the live commit would do; the actual JE row is not // staged in pending_operations because the SI write path is @@ -599,6 +609,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( total_sek: totalSek, remaining_amount: total, notes: body.notes ?? null, + // Dimensions PR7: invoice-level bag; generators apply it to every line. + default_dimensions: body.default_dimensions ?? {}, }) .select(SI_RESPONSE_COLUMNS) .single() diff --git a/app/payslip/[token]/page.tsx b/app/payslip/[token]/page.tsx new file mode 100644 index 00000000..b0c85f36 --- /dev/null +++ b/app/payslip/[token]/page.tsx @@ -0,0 +1,149 @@ +import type { Metadata } from 'next' +import { createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { resolvePayslipToken } from '@/lib/salary/payslips/links' +import { formatCurrency } from '@/lib/utils' + +// The token is the authentication — no session, no company context. Swedish +// only (employee-facing). Never indexed. +export const metadata: Metadata = { + title: 'Lönespecifikation', + robots: { index: false, follow: false }, +} + +export const dynamic = 'force-dynamic' + +const MONTH_NAMES = [ + 'januari', 'februari', 'mars', 'april', 'maj', 'juni', + 'juli', 'augusti', 'september', 'oktober', 'november', 'december', +] + +function MessageShell({ title, body }: { title: string; body: string }) { + return ( +
+
+

{title}

+

{body}

+
+
+ ) +} + +export default async function PayslipPage({ + params, +}: { + params: Promise<{ token: string }> +}) { + const { token } = await params + const serviceClient = createServiceClientNoCookies() + const resolved = await resolvePayslipToken(serviceClient, token) + + if (!resolved.ok) { + if (resolved.reason === 'expired') { + return ( + + ) + } + if (resolved.reason === 'revoked') { + return ( + + ) + } + return ( + + ) + } + + const { link } = resolved + + const [{ data: run }, { data: sre }, { data: company }] = await Promise.all([ + serviceClient + .from('salary_runs') + .select('period_year, period_month, payment_date') + .eq('id', link.salary_run_id) + .eq('company_id', link.company_id) + .single(), + serviceClient + .from('salary_run_employees') + .select('gross_salary, tax_withheld, tax_withheld_override, net_salary, employee:employees(first_name, last_name)') + .eq('salary_run_id', link.salary_run_id) + .eq('employee_id', link.employee_id) + .single(), + serviceClient + .from('companies') + .select('name') + .eq('id', link.company_id) + .single(), + ]) + + if (!run || !sre || !company) { + return ( + + ) + } + + const emp = sre.employee as unknown as { first_name: string; last_name: string } | null + const effectiveTax = (sre.tax_withheld_override as number | null) ?? (sre.tax_withheld as number) + const effectiveNet = (sre.net_salary as number) + ((sre.tax_withheld as number) - effectiveTax) + const monthName = MONTH_NAMES[run.period_month - 1] + + return ( +
+
+
+

{company.name}

+

+ Lönespecifikation {monthName} {run.period_year} +

+ {emp && ( +

+ {emp.first_name} {emp.last_name} +

+ )} +
+ +
+
+
Bruttolön
+
{formatCurrency(sre.gross_salary as number)}
+
+
+
Skatteavdrag
+
−{formatCurrency(effectiveTax)}
+
+
+
Nettolön
+
{formatCurrency(effectiveNet)}
+
+
+
Utbetalningsdag
+
{run.payment_date}
+
+
+ + + Ladda ner PDF (lönespecifikation) + + +

+ Länken är personlig — dela den inte vidare. PDF-filen innehåller den + fullständiga specifikationen. +

+
+
+ ) +} diff --git a/components/agent/ApprovalCard.tsx b/components/agent/ApprovalCard.tsx index d0706598..50b4eddf 100644 --- a/components/agent/ApprovalCard.tsx +++ b/components/agent/ApprovalCard.tsx @@ -538,9 +538,19 @@ function CategorizeTransactionPreview({ const amount = preview.amount as number | undefined const currency = (preview.currency as string | undefined) ?? 'SEK' const category = preview.category as string | undefined - // Server emits { account_number, debit_amount, credit_amount, description } - // per VAT line (extensions/general/mcp-server/server.ts:390-395). One side - // is non-zero, the other 0: render the active side with D/K prefix. + // The exact journal lines the approval will post (net cost line, VAT line, + // gross bank line, SEK), staged by the server since the preview-lines fix. + const lines = (preview.lines as + | { + account_number?: string + debit_amount?: number + credit_amount?: number + description?: string + }[] + | undefined) ?? [] + // Legacy summary fields, rendered only for operations staged before the + // preview carried full lines. Pairing the gross amount with the cost + // account reads as an unbalanced entry: never show it when lines exist. const vatLines = (preview.vat_lines as | { account_number?: string @@ -560,42 +570,69 @@ function CategorizeTransactionPreview({ {prettyCategory(category)}
- {debit && credit && amount != null && ( - - D - {debit} - / K - {credit} - {formatCurrency(amount, currency)} - - } - /> - )} - {vatLines.length > 0 && ( -
- {vatLines.map((v, i) => { - const debit = typeof v.debit_amount === 'number' ? v.debit_amount : 0 - const credit = typeof v.credit_amount === 'number' ? v.credit_amount : 0 - const side: 'D' | 'K' | null = debit > 0 ? 'D' : credit > 0 ? 'K' : null - const amount = side === 'D' ? debit : side === 'K' ? credit : 0 + {lines.length > 0 ? ( +
+ {lines.map((l, i) => { + const debitAmt = typeof l.debit_amount === 'number' ? l.debit_amount : 0 + const creditAmt = typeof l.credit_amount === 'number' ? l.credit_amount : 0 + const side: 'D' | 'K' = debitAmt > 0 ? 'D' : 'K' return ( - {side && {side} } - {v.account_number ?? ''} - {formatCurrency(amount, currency)} + {side} + {l.account_number ?? '?'} + + {formatCurrency(side === 'D' ? debitAmt : creditAmt)} + } /> ) })}
+ ) : ( + <> + {debit && credit && amount != null && ( + + D + {debit} + / K + {credit} + {formatCurrency(amount, currency)} + + } + /> + )} + {vatLines.length > 0 && ( +
+ {vatLines.map((v, i) => { + const debit = typeof v.debit_amount === 'number' ? v.debit_amount : 0 + const credit = typeof v.credit_amount === 'number' ? v.credit_amount : 0 + const side: 'D' | 'K' | null = debit > 0 ? 'D' : credit > 0 ? 'K' : null + const amount = side === 'D' ? debit : side === 'K' ? credit : 0 + return ( + + {side && {side} } + {v.account_number ?? ''} + {formatCurrency(amount, currency)} + + } + /> + ) + })} +
+ )} + )}
) diff --git a/components/auth/BankIdAuth.tsx b/components/auth/BankIdAuth.tsx index 57233002..867cdacf 100644 --- a/components/auth/BankIdAuth.tsx +++ b/components/auth/BankIdAuth.tsx @@ -11,6 +11,16 @@ type BankIdStatus = 'idle' | 'scanning' | 'complete' | 'failed' | 'no_account' | /** Max consecutive poll failures before we declare service unavailable */ const MAX_POLL_FAILURES = 3 +/** + * Abandon a session that never reaches a terminal state. Safety net for TIC + * responses that carry no `status` (e.g. an expired-session 410 body) — without + * it the poll loop would spin forever on a dead QR code. + */ +const POLL_DEADLINE_MS = 6 * 60 * 1000 + +/** Min spacing between billable TIC session starts (mirrors the server cooldown). */ +const START_COOLDOWN_MS = 5_000 + interface BankIdSession { sessionId: string autoStartToken: string @@ -129,6 +139,16 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { onCompleteRef.current = onComplete const pollFailureCount = useRef(0) + /** True while a poll response is being processed — prevents overlapping ticks. */ + const pollInFlightRef = useRef(false) + /** Set once a terminal poll result has been handled — the completion branch must run at most once. */ + const completedRef = useRef(false) + /** When the current poll loop began, for the POLL_DEADLINE_MS cap. */ + const pollStartedAtRef = useRef(0) + /** Bumped by cancel/unmount so an in-flight startSession stops touching state. */ + const startGenRef = useRef(0) + /** True while startSession is running — collapses double-clicks into one billable session. */ + const startingRef = useRef(false) const cleanup = useCallback(() => { if (pollRef.current) { @@ -140,16 +160,43 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { abortRef.current = null } pollFailureCount.current = 0 + pollInFlightRef.current = false }, []) - useEffect(() => cleanup, [cleanup]) + useEffect( + () => () => { + startGenRef.current++ + cleanup() + }, + [cleanup] + ) // Poll an in-flight BankID session until it completes, fails, or the service // gives up. Extracted from startSession so the resume effect (mobile return) // can re-attach to a session that was started before the tab reloaded. const beginPolling = useCallback((session: BankIdSession) => { abortRef.current = new AbortController() + pollStartedAtRef.current = Date.now() + completedRef.current = false + pollInFlightRef.current = false pollRef.current = setInterval(async () => { + // Never process two ticks concurrently: a slow response overlapping the + // next tick could otherwise run the completion branch twice (double + // /complete → double generateLink, which invalidates the first magic + // link and fails the login intermittently). + if (pollInFlightRef.current || completedRef.current) return + + // Hard cap — a session that never reaches a terminal state (expired + // order, TIC response without `status`) must not poll forever. + if (Date.now() - pollStartedAtRef.current > POLL_DEADLINE_MS) { + cleanup() + clearPending() + setStatus('failed') + setErrorMessage('BankID-sessionen löpte ut. Försök igen.') + return + } + + pollInFlightRef.current = true try { const pollRes = await fetch(`${API_BASE}/poll`, { method: 'POST', @@ -159,16 +206,16 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { }) if (!pollRes.ok) { - const pollErr = await pollRes.json().catch(() => ({})) - if (pollErr.error === 'service_unavailable' || pollRes.status === 502 || pollRes.status === 503) { - pollFailureCount.current++ - if (pollFailureCount.current >= MAX_POLL_FAILURES) { - cleanup() - clearPending() - setStatus('service_unavailable') - setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') - onCompleteRef.current({ error: 'service_unavailable' }) - } + // Count EVERY failed poll (5xx, 429, unexpected 4xx) — errors that + // never increment the counter would otherwise leave the user + // silently polling a dead session forever. + pollFailureCount.current++ + if (pollFailureCount.current >= MAX_POLL_FAILURES) { + cleanup() + clearPending() + setStatus('service_unavailable') + setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') + onCompleteRef.current({ error: 'service_unavailable' }) } return } @@ -199,6 +246,7 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { } if (pollData.status === 'complete') { + completedRef.current = true cleanup() clearPending() setStatus('complete') @@ -268,6 +316,7 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { }) } } else if (pollData.status === 'failed' || pollData.status === 'cancelled') { + completedRef.current = true cleanup() clearPending() setStatus('failed') @@ -283,15 +332,17 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') onCompleteRef.current({ error: 'service_unavailable' }) } + } finally { + pollInFlightRef.current = false } }, 2000) }, [cleanup, mode]) const startSession = useCallback(async () => { - // Prevent rapid restarts (each start = billable TIC session) - const now = Date.now() - if (now - lastStartRef.current < 5000) return - lastStartRef.current = now + // Collapse double-clicks — one billable TIC session per intent. + if (startingRef.current) return + startingRef.current = true + const gen = ++startGenRef.current cleanup() clearPending() @@ -300,7 +351,20 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { setErrorMessage('') try { + // Respect the billable-session cooldown by waiting out the remainder + // instead of silently dropping the click — a retry button that does + // nothing reads as "BankID is broken". Also keeps us under the + // server-side per-IP cooldown on /start. + const sinceLast = Date.now() - lastStartRef.current + if (sinceLast < START_COOLDOWN_MS) { + await new Promise((resolve) => setTimeout(resolve, START_COOLDOWN_MS - sinceLast)) + } + if (gen !== startGenRef.current) return // cancelled/unmounted while waiting + lastStartRef.current = Date.now() + const res = await fetch(`${API_BASE}/start`, { method: 'POST' }) + if (gen !== startGenRef.current) return + if (!res.ok) { const err = await res.json().catch(() => ({})) if (err.error === 'service_unavailable' || err.error === 'not_configured' || res.status === 502 || res.status === 503) { @@ -310,7 +374,17 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { onCompleteRef.current({ error: 'service_unavailable' }) return } - throw new Error(err.message || err.error || 'Failed to start BankID') + if (res.status === 429 || err.error === 'rate_limit') { + setStatus('failed') + setErrorMessage('För många försök. Vänta en stund och försök igen.') + return + } + // Unknown error — server messages are not user-facing copy; keep the + // detail in the console and show Swedish. + console.error('[bankid] start failed', res.status, err) + setStatus('failed') + setErrorMessage('Ett oväntat fel uppstod. Försök igen.') + return } const { data } = await res.json() @@ -328,8 +402,12 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { beginPolling(newSession) } catch (error) { + if (gen !== startGenRef.current) return + console.error('[bankid] start failed', error) setStatus('failed') - setErrorMessage(error instanceof Error ? error.message : 'Ett oväntat fel uppstod') + setErrorMessage('Ett oväntat fel uppstod. Försök igen.') + } finally { + startingRef.current = false } }, [cleanup, mode, beginPolling]) @@ -348,6 +426,7 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { }, []) const handleCancel = useCallback(async () => { + startGenRef.current++ // stop an in-flight startSession from resuming if (session) { fetch(`${API_BASE}/${session.sessionId}`, { method: 'DELETE' }).catch(() => {}) } diff --git a/components/bookkeeping/ChartOfAccountsManager.tsx b/components/bookkeeping/ChartOfAccountsManager.tsx index 37739c2e..6e847359 100644 --- a/components/bookkeeping/ChartOfAccountsManager.tsx +++ b/components/bookkeeping/ChartOfAccountsManager.tsx @@ -11,6 +11,7 @@ import { useToast } from '@/components/ui/use-toast' import { AccountNumber } from '@/components/ui/account-number' import { AddAccountDialog } from './AddAccountDialog' import { EditAccountDialog } from './EditAccountDialog' +import { PruneAccountsDialog } from './PruneAccountsDialog' import { Search, ChevronDown, @@ -23,7 +24,7 @@ import { BookOpen, } from 'lucide-react' import type { BASAccount } from '@/types' -import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference' +import { isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference' // --------------------------------------------------------------------------- // Types @@ -66,11 +67,13 @@ export default function ChartOfAccountsManager() { // Data state const [accounts, setAccounts] = useState([]) const [referenceAccounts, setReferenceAccounts] = useState([]) + const [usageCounts, setUsageCounts] = useState>(new Map()) const [loading, setLoading] = useState(true) // Dialog state const [addDialogOpen, setAddDialogOpen] = useState(false) const [editAccount, setEditAccount] = useState(null) + const [pruneDialogOpen, setPruneDialogOpen] = useState(false) // Action states const [togglingAccount, setTogglingAccount] = useState(null) @@ -93,10 +96,30 @@ export default function ChartOfAccountsManager() { setReferenceAccounts(data || []) }, []) + // Per-account posting counts — drives the "Verifikat" column. Non-fatal: + // the page works without it, cells just show a dash. + const fetchUsage = useCallback(async () => { + try { + const res = await fetch('/api/bookkeeping/accounts/usage') + if (!res.ok) return + const { data } = await res.json() + setUsageCounts( + new Map( + (data || []).map((u: { account_number: string; usage_count: number }) => [ + u.account_number, + Number(u.usage_count), + ]), + ), + ) + } catch { + // Leave the map empty — usage display is informational only. + } + }, []) + useEffect(() => { async function load() { setLoading(true) - await Promise.all([fetchAccounts(), fetchReference()]) + await Promise.all([fetchAccounts(), fetchReference(), fetchUsage()]) // Set K2 filter default based on company settings (plan_type) if (hideK2Excluded === null) { try { @@ -115,11 +138,11 @@ export default function ChartOfAccountsManager() { setLoading(false) } load() - }, [fetchAccounts, fetchReference, hideK2Excluded]) + }, [fetchAccounts, fetchReference, fetchUsage, hideK2Excluded]) const refreshAll = useCallback(async () => { - await Promise.all([fetchAccounts(), fetchReference()]) - }, [fetchAccounts, fetchReference]) + await Promise.all([fetchAccounts(), fetchReference(), fetchUsage()]) + }, [fetchAccounts, fetchReference, fetchUsage]) // ------------------------------------------- // Actions @@ -294,10 +317,16 @@ export default function ChartOfAccountsManager() { {view === 'my-accounts' && ( - +
+ + +
)} {view === 'bas-catalog' && ( @@ -363,6 +392,7 @@ export default function ChartOfAccountsManager() { {t('col_name')} {t('col_sru')} {t('col_type')} + {t('col_usage')} {t('col_active')} @@ -386,6 +416,11 @@ export default function ChartOfAccountsManager() { {t('system_badge')} )} + {!isStandardBASAccount(account.account_number) && ( + + {t('own_badge')} + + )} @@ -398,6 +433,11 @@ export default function ChartOfAccountsManager() { {typeLabel(account.account_type)} + + + {usageCounts.get(account.account_number) ?? '\u2014'} + + + + {editAccount && ( { let fetched: FiscalPeriod[] = [] @@ -271,20 +288,12 @@ export default function JournalEntryList() { } if (cancelled) return - const stored = - typeof window !== 'undefined' - ? window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id) - : null - if (stored === FISCAL_YEAR_ALL_VALUE) { - // User explicitly chose "all years", respect it. - setPeriodId(null) - } else if (stored && fetched.some((p) => p.id === stored)) { - setPeriodId(stored) - } else { - // No (valid) saved scope → default to the current räkenskapsår. - const today = new Date().toISOString().split('T')[0] - setPeriodId(resolveCurrentPeriodId(fetched, today)) - } + if (stored === FISCAL_YEAR_ALL_VALUE) return + if (stored && fetched.some((p) => p.id === stored)) return + + // No (valid) saved scope → default to the current räkenskapsår. + const today = new Date().toISOString().split('T')[0] + setPeriodId(resolveCurrentPeriodId(fetched, today)) setPeriodHydrated(true) })() return () => { diff --git a/components/bookkeeping/PruneAccountsDialog.tsx b/components/bookkeeping/PruneAccountsDialog.tsx new file mode 100644 index 00000000..e92b40b6 --- /dev/null +++ b/components/bookkeeping/PruneAccountsDialog.tsx @@ -0,0 +1,295 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslations } from 'next-intl' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { useToast } from '@/components/ui/use-toast' +import { Loader2, Search, Trash2 } from 'lucide-react' + +interface PruneCandidate { + account_number: string + account_name: string + account_class: number + plan_type: string | null + is_active: boolean + in_bas_reference: boolean +} + +interface PruneAccountsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + /** Called after a successful prune so the parent can refetch the chart. */ + onPruned: () => void +} + +/** + * Bulk deletion of unused accounts. Fetches the server-computed deletable set + * (dry run) and presents it in two groups — imported/custom accounts and + * untouched accounts from the seeded base plan. Nothing is preselected: + * deletion is opt-in per account or per group. Used accounts never appear + * here; the server re-verifies every guard at execute time anyway. + */ +export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccountsDialogProps) { + const t = useTranslations('chart_of_accounts') + const tCommon = useTranslations('common') + const { toast } = useToast() + + const [loading, setLoading] = useState(false) + const [candidates, setCandidates] = useState([]) + const [usedCount, setUsedCount] = useState(0) + const [selected, setSelected] = useState>(new Set()) + const [searchQuery, setSearchQuery] = useState('') + const [isDeleting, setIsDeleting] = useState(false) + + // Grouping only: imported/manually added accounts (the typical cleanup + // target) are listed separately from unused accounts in the seeded base + // plan, so users see what came from an import at a glance. + const isImportedOrCustom = useCallback( + (c: PruneCandidate) => !c.in_bas_reference || c.plan_type === 'full_bas', + [], + ) + + useEffect(() => { + if (!open) return + let cancelled = false + async function load() { + setLoading(true) + setCandidates([]) + setSelected(new Set()) + setSearchQuery('') + try { + const res = await fetch('/api/bookkeeping/accounts/prune', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dry_run: true }), + }) + if (!res.ok) throw new Error() + const { data } = await res.json() + if (cancelled) return + const deletable: PruneCandidate[] = data?.deletable ?? [] + setCandidates(deletable) + setUsedCount((data?.used ?? []).length) + // Nothing starts selected — deletion is opt-in per account (or per + // group via the header checkbox), never a preloaded default. + } catch { + if (!cancelled) { + toast({ title: t('toast_prune_failed'), variant: 'destructive' }) + onOpenChange(false) + } + } finally { + if (!cancelled) setLoading(false) + } + } + load() + return () => { + cancelled = true + } + }, [open, onOpenChange, t, toast]) + + // Charts bloated by an import easily reach hundreds of candidates — filter + // by number or name, same semantics as the kontoplan page search. Selection + // is keyed by account number, so it survives filter changes; the confirm + // button always shows the true selected count. + const filteredCandidates = useMemo(() => { + if (!searchQuery) return candidates + const q = searchQuery.toLowerCase() + return candidates.filter( + (c) => c.account_number.includes(q) || c.account_name.toLowerCase().includes(q), + ) + }, [candidates, searchQuery]) + + const customGroup = useMemo( + () => filteredCandidates.filter(isImportedOrCustom), + [filteredCandidates, isImportedOrCustom], + ) + const seedGroup = useMemo( + () => filteredCandidates.filter((c) => !isImportedOrCustom(c)), + [filteredCandidates, isImportedOrCustom], + ) + + function toggleAccount(accountNumber: string) { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(accountNumber)) { + next.delete(accountNumber) + } else { + next.add(accountNumber) + } + return next + }) + } + + function toggleGroup(group: PruneCandidate[], checked: boolean) { + setSelected((prev) => { + const next = new Set(prev) + for (const c of group) { + if (checked) { + next.add(c.account_number) + } else { + next.delete(c.account_number) + } + } + return next + }) + } + + async function handlePrune() { + if (selected.size === 0) return + setIsDeleting(true) + try { + const res = await fetch('/api/bookkeeping/accounts/prune', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dry_run: false, account_numbers: [...selected] }), + }) + const body = await res.json().catch(() => null) + if (!res.ok) throw new Error(body?.error) + const deleted: string[] = body?.data?.deleted ?? [] + const skipped: string[] = [ + ...(body?.data?.skipped ?? []), + ...(body?.data?.not_found ?? []), + ] + toast({ + title: t('toast_pruned_title'), + description: + skipped.length > 0 + ? t('toast_pruned_with_skipped', { deleted: deleted.length, skipped: skipped.length }) + : t('toast_pruned_description', { deleted: deleted.length }), + }) + onPruned() + onOpenChange(false) + } catch (err) { + toast({ + title: err instanceof Error && err.message ? err.message : t('toast_prune_failed'), + variant: 'destructive', + }) + } finally { + setIsDeleting(false) + } + } + + function renderGroup(group: PruneCandidate[], titleKey: 'prune_group_custom' | 'prune_group_seed', hintKey: 'prune_group_custom_hint' | 'prune_group_seed_hint') { + if (group.length === 0) return null + const allChecked = group.every((c) => selected.has(c.account_number)) + return ( +
+
+ toggleGroup(group, checked === true)} + className="mt-1" + aria-label={t(titleKey)} + /> +
+

+ {t(titleKey)}{' '} + ({group.length}) +

+

{t(hintKey)}

+
+
+
+ {group.map((c) => ( + + ))} +
+
+ ) + } + + return ( + !isDeleting && onOpenChange(v)}> + + + {t('prune_title')} + {t('prune_description')} + + + {loading ? ( +
+ + {t('prune_loading')} +
+ ) : candidates.length === 0 ? ( +

{t('prune_empty')}

+ ) : ( + // min-w-0: DialogContent is a grid, and grid items default to + // min-width auto — without this, the nowrap (truncate) account + // names propagate their full width up and blow the dialog open + // horizontally instead of truncating. +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+
+ {filteredCandidates.length === 0 ? ( +

{t('no_matches')}

+ ) : ( + <> + {renderGroup(customGroup, 'prune_group_custom', 'prune_group_custom_hint')} + {renderGroup(seedGroup, 'prune_group_seed', 'prune_group_seed_hint')} + + )} + {usedCount > 0 && ( +

+ {t('prune_used_note', { count: usedCount })} +

+ )} +
+
+ )} + + + + + +
+
+ ) +} diff --git a/components/dashboard/MainContainer.tsx b/components/dashboard/MainContainer.tsx index 603226cc..d9a70b62 100644 --- a/components/dashboard/MainContainer.tsx +++ b/components/dashboard/MainContainer.tsx @@ -28,12 +28,25 @@ export function MainContainer({ // pane on wide viewports. const isFullBleed = pathname.startsWith('/e/') || pathname.startsWith('/chat') - return isFullBleed ? ( -
{children}
- ) : ( + // The salary run detail page drives a wide, horizontal-flow layout (progress + // band + 5-up KPIs + full-width employee ledger) that the standard max-w-5xl + // column squeezes. It opts into a wider canvas — a deliberate, scoped + // exception to the locked container token. Match only /salary/runs/{id}, not + // its nested employee sub-pages. + const isWide = /^\/salary\/runs\/[^/]+$/.test(pathname) + + if (isFullBleed) { + return
{children}
+ } + + return (
{children}
diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 2cc0c2be..919d04de 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -1174,6 +1174,9 @@ function getFYStatus(r: ImportResult): { icon: 'success' | 'warning' | 'error'; if (r.errors.length > 0 || (r.details?.skippedVouchers && r.details.skippedVouchers.total > 0)) { return { icon: 'warning', label: 'Delvis importerad' } } + if (r.details?.untransferredResults && r.details.untransferredResults.length > 0) { + return { icon: 'warning', label: 'Importerad med varning' } + } return { icon: 'success', label: 'Importerad' } } @@ -1354,6 +1357,61 @@ function FiscalYearResult({ result, index }: { result: ImportResult; index: numb
)} + {/* Untransferred prior-year results — omföring av årets resultat saknas */} + {d?.untransferredResults && d.untransferredResults.length > 0 && ( +
+
+ +
+

+ Årets resultat är inte omfört till eget kapital +

+

+ Följande räkenskapsår saknar omföring av årets resultat. Senare års + balansräkning visar en differens på beloppet tills omföringen bokförs + (konto 8999 mot eget kapital, t.ex. 2099) i respektive år. +

+
+ {d.untransferredResults.map((u) => ( +
+ {u.period_name} + + {u.pl_net.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK + +
+ ))} +
+
+
+
+ )} + + {/* Remaining warnings — previously dropped entirely in this flow. + Strings covered by structured cards above are filtered out. */} + {(() => { + const remainingWarnings = result.warnings.filter( + (w) => + !(d?.skippedVouchers && d.skippedVouchers.total > 0 && w.includes('hoppades över')) && + !(d?.untransferredResults && d.untransferredResults.length > 0 && w.includes('förts om till eget kapital')) + ) + if (remainingWarnings.length === 0) return null + return ( +
+
+ +
+

+ {remainingWarnings.length === 1 ? '1 varning' : `${remainingWarnings.length} varningar`} +

+ {remainingWarnings.map((w, i) => ( +

{w}

+ ))} +
+
+
+ ) + })()} + {/* Retry info (only shown if retries happened) */} {d && d.retriedBatches > 0 && (

diff --git a/components/extensions/general/BookDirectlyDialog.tsx b/components/extensions/general/BookDirectlyDialog.tsx index 3e66fc95..d7c606ed 100644 --- a/components/extensions/general/BookDirectlyDialog.tsx +++ b/components/extensions/general/BookDirectlyDialog.tsx @@ -15,11 +15,13 @@ import { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Badge } from '@/components/ui/badge' import { useToast } from '@/components/ui/use-toast' -import { Loader2, Plus, Trash2, AlertTriangle, Search, Check } from 'lucide-react' +import { Loader2, Plus, Trash2, AlertTriangle, Search, Check, BookmarkPlus } from 'lucide-react' import { cn, formatCurrency } from '@/lib/utils' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane' import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker' +import { TemplateForm } from '@/components/settings/TemplateForm' +import { deriveTemplateLinesFromBooking } from '@/lib/bookkeeping/template-library' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' import { useCompany } from '@/contexts/CompanyContext' import { @@ -30,7 +32,7 @@ import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import { resolveAccount } from '@/lib/cash-accounts/resolve-account' -import type { BASAccount, CashAccount, FiscalPeriod, InvoiceExtractionResult } from '@/types' +import type { BASAccount, BookingTemplateLibrary, CashAccount, FiscalPeriod, InvoiceExtractionResult } from '@/types' interface InboxItem { id: string @@ -68,6 +70,15 @@ interface FormLine { const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '' } +// Swedish entity labels for the "Spara som mall" editor. Hard-coded to match +// this dialog's Swedish-only surface (the shared TemplateForm handles the rest +// of its own strings bilingually). +const TEMPLATE_ENTITY_LABELS: Record = { + all: 'Alla', + enskild_firma: 'Enskild firma', + aktiebolag: 'Aktiebolag', +} + interface Props { open: boolean onOpenChange: (v: boolean) => void @@ -196,6 +207,12 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = const [isSubmitting, setIsSubmitting] = useState(false) + // "Spara som mall" — derive amount-parameterised template lines from the + // current konteringsrader so the user can save the pattern they just worked + // out. Labels come from the loaded BAS chart; the user reviews/edits in the + // shared TemplateForm before saving. + const [showSaveTemplate, setShowSaveTemplate] = useState(false) + // Reset state when a different item opens the dialog. We pass bankAccount // here but it may still be null (fetch in flight): in that case '1930' is // used as a placeholder and the prefill-update effect below will overwrite @@ -426,6 +443,19 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = } }, [lines]) + // Account number → BAS name, so derived template lines get meaningful labels. + const accountNameMap = useMemo( + () => Object.fromEntries(accounts.map((a) => [a.account_number, a.account_name])), + [accounts], + ) + + // Template lines derived from the current booking. Empty (<2 usable lines) + // disables the "Spara som mall" button. + const derivedTemplateLines = useMemo( + () => deriveTemplateLinesFromBooking(lines, accountNameMap), + [lines, accountNameMap], + ) + const updateLine = useCallback((idx: number, patch: Partial) => { setLines((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l))) }, []) @@ -841,6 +871,21 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = : targetSek ?? undefined } /> +

{totals.balanced ? ( @@ -914,6 +959,45 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = onConfirm={confirmActivation} onCancel={cancelActivation} /> + + {/* Save the current kontering as a reusable template. Amounts are stored + as ratios of the total, so the user picks a fresh amount when applying + the mall later. The shared TemplateForm re-seeds from the derived lines + each time the dialog opens (Radix unmounts its content when closed). */} + + + + Spara som bokföringsmall + + Spara den här konteringen som en återanvändbar mall. Beloppen sparas + som andelar av totalsumman — du anger ett nytt belopp när du använder + mallen. Kontrollera raderna nedan innan du sparar. + + + {showSaveTemplate && ( + setShowSaveTemplate(false)} + /> + )} + + ) } diff --git a/components/import/ImportResultStep.tsx b/components/import/ImportResultStep.tsx index 785a710d..2afb4b66 100644 --- a/components/import/ImportResultStep.tsx +++ b/components/import/ImportResultStep.tsx @@ -18,6 +18,7 @@ import { DestructiveConfirmDialog, useDestructiveConfirm, } from '@/components/ui/destructive-confirm-dialog' +import { formatCurrency } from '@/lib/utils' import type { ImportResult } from '@/lib/import/types' interface ImportResultStepProps { @@ -41,11 +42,15 @@ export default function ImportResultStep({ result, onNewImport, onUndo }: Import } const hasErrors = result.errors.length > 0 const skipped = result.details?.skippedVouchers + const untransferred = result.details?.untransferredResults - // Filter out raw "hoppades över" warnings when we have structured data - const otherWarnings = skipped && skipped.total > 0 + // Filter out raw warnings when we have structured data for them + let otherWarnings = skipped && skipped.total > 0 ? result.warnings.filter((w) => !w.includes('hoppades över')) : result.warnings + if (untransferred && untransferred.length > 0) { + otherWarnings = otherWarnings.filter((w) => !w.includes('förts om till eget kapital')) + } return (
@@ -259,6 +264,35 @@ export default function ImportResultStep({ result, onNewImport, onUndo }: Import )} + {/* Untransferred prior-year results — the year-end omföring is missing */} + {untransferred && untransferred.length > 0 && ( + + + + + Årets resultat är inte omfört + + + Följande räkenskapsår saknar omföring av årets resultat till eget kapital. + Senare års balansräkning visar en differens på beloppet tills omföringen + bokförs (konto 8999 mot eget kapital, t.ex. 2099) i respektive år. + + + +
+ {untransferred.map((u) => ( +
+ {u.period_name} + + {formatCurrency(u.pl_net, 'SEK', { minimumFractionDigits: 2 })} + +
+ ))} +
+
+
+ )} + {/* Other warnings (filtered) */} {otherWarnings.length > 0 && ( diff --git a/components/invoices/NewInvoiceDialog.tsx b/components/invoices/NewInvoiceDialog.tsx index 70aad7d2..899db82f 100644 --- a/components/invoices/NewInvoiceDialog.tsx +++ b/components/invoices/NewInvoiceDialog.tsx @@ -1,8 +1,23 @@ 'use client' +import dynamic from 'next/dynamic' import { useTranslations } from 'next-intl' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' -import InvoiceEditor from '@/components/invoices/InvoiceEditor' +import { Skeleton } from '@/components/ui/skeleton' + +// Deferred: the editor (and its framer-motion dependency) is a large chunk +// that would otherwise ship with the invoice LIST bundle — it's only needed +// once this dialog actually opens. +const InvoiceEditor = dynamic(() => import('@/components/invoices/InvoiceEditor'), { + ssr: false, + loading: () => ( +
+ + + +
+ ), +}) interface Props { open: boolean diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index 9f0f56bb..f8314192 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -664,6 +664,11 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
)}
+ {!isBalanced && data.imbalance_diagnosis && ( +

+ {data.imbalance_diagnosis.message} +

+ )} @@ -948,11 +953,23 @@ export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }: Balanserar ) : ( - - Balanserar ej - +
+ + Balanserar ej + + {data.imbalance_diagnosis && ( +

+ Differens: {formatAmount(Math.abs(data.imbalance_diagnosis.differens))} kr +

+ )} +
)} + {!data.is_balanced && data.imbalance_diagnosis && ( +

+ {data.imbalance_diagnosis.message} +

+ )} diff --git a/components/salary/AGIPanel.tsx b/components/salary/AGIPanel.tsx index 8e66c957..869635c1 100644 --- a/components/salary/AGIPanel.tsx +++ b/components/salary/AGIPanel.tsx @@ -15,6 +15,7 @@ import { ShieldAlert, Unlock, } from 'lucide-react' +import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { useCapability } from '@/contexts/CompanyContext' @@ -114,6 +115,7 @@ export function AGIPanel(props: AGIPanelProps) { onChange, } = props + const t = useTranslations('salary_agi') const hasSkatteverket = useCapability(CAPABILITY.skatteverket) const [extensionDisabled, setExtensionDisabled] = useState(false) @@ -188,19 +190,19 @@ export function AGIPanel(props: AGIPanelProps) { if (event.origin !== window.location.origin) return if (event.data?.type === 'skatteverket-oauth-success') { setError(null) - setSuccess('Anslutningen mot Skatteverket lyckades.') + setSuccess(t('oauth_success')) fetchStatus() } else if (event.data?.type === 'skatteverket-oauth-error') { const reason = typeof event.data.reason === 'string' && event.data.reason ? event.data.reason - : 'OAuth-anslutningen misslyckades. Försök igen.' + : t('oauth_error_fallback') setError(reason) } } window.addEventListener('message', handleMessage) return () => window.removeEventListener('message', handleMessage) - }, [fetchStatus]) + }, [fetchStatus, t]) // Drop a stale "AGI-XML saknas" error once the run's AGI is (re)generated. // That error is set when "Skicka in underlag" runs before the XML exists; if @@ -252,14 +254,14 @@ export function AGIPanel(props: AGIPanelProps) { // Replace any lingering "Granskningsunderlag klart…" / stale error // with an unambiguous confirmation. Mirrors handleCheckSubmitted. setError(null) - setSuccess('AGI har signerats och lämnats in.') + setSuccess(t('signed_success')) onChange?.() } return signed } catch { return false } - }, [arbetsgivare, period, fetchSubmission, onChange]) + }, [arbetsgivare, period, fetchSubmission, onChange, t]) /** * Background-poll /agi/kvittenser at 30s, 2 min, and 5 min after the user @@ -327,18 +329,18 @@ export function AGIPanel(props: AGIPanelProps) { }) if (!res.ok) { const json = await res.json().catch(() => ({})) - setError(json.error || `Kunde inte koppla bort (${res.status})`) + setError(json.error || t('disconnect_failed_status', { status: res.status })) return } - setSuccess('Anslutningen mot Skatteverket har kopplats bort.') + setSuccess(t('disconnect_success')) await fetchStatus() await fetchSubmission() } catch (e) { - setError(e instanceof Error ? e.message : 'Kunde inte koppla bort') + setError(e instanceof Error ? e.message : t('disconnect_failed')) } finally { setActionLoading(null) } - }, [fetchStatus, fetchSubmission]) + }, [fetchStatus, fetchSubmission, t]) const handleConnect = () => { // Open the BankID OAuth flow in a centered popup. The callback page @@ -425,7 +427,7 @@ export function AGIPanel(props: AGIPanelProps) { const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`) if (!res.ok) { const data = await res.json().catch(() => ({})) - throw new Error(data.error || 'Kunde inte generera AGI-filen') + throw new Error(data.error || t('xml_generate_failed')) } const blob = await res.blob() const url = URL.createObjectURL(blob) @@ -438,7 +440,7 @@ export function AGIPanel(props: AGIPanelProps) { URL.revokeObjectURL(url) onChange?.() } catch (e) { - setError(e instanceof Error ? e.message : 'Kunde inte ladda ner AGI-filen') + setError(e instanceof Error ? e.message : t('xml_download_failed')) } finally { setActionLoading(null) } @@ -457,12 +459,12 @@ export function AGIPanel(props: AGIPanelProps) { }) const submitJson = await submitRes.json() if (!submitRes.ok || submitJson.error) { - setError(submitJson.error || `Inlämning misslyckades (${submitRes.status})`) + setError(submitJson.error || t('submit_failed_status', { status: submitRes.status })) return } const inlamningId = submitJson.data?.inlamningId as number | undefined if (!inlamningId) { - setError('Inlämningssvar saknar inlamningId') + setError(t('submit_missing_id')) return } @@ -474,7 +476,7 @@ export function AGIPanel(props: AGIPanelProps) { ) const krJson = await krRes.json() if (!krRes.ok || krJson.error) { - setError(krJson.error || `Kontrollresultat misslyckades (${krRes.status})`) + setError(krJson.error || t('kontrollresultat_failed_status', { status: krRes.status })) return } kr = krJson.data as Kontrollresultat @@ -482,7 +484,7 @@ export function AGIPanel(props: AGIPanelProps) { await new Promise(r => setTimeout(r, 1000)) } if (!kr || kr.status === 'PROCESSING') { - setError('Skatteverket bearbetar fortfarande underlaget. Försök igen om en stund.') + setError(t('still_processing')) return } @@ -490,20 +492,17 @@ export function AGIPanel(props: AGIPanelProps) { setKontroller(findings) if (kr.status === 'DONE_SUCCESS') { - setSuccess( - 'Underlag accepterat hos Skatteverket. Klicka "Skapa signeringslänk" ' + - 'för att gå vidare till BankID-signering i Mina Sidor.', - ) + setSuccess(t('underlag_accepted')) } else if (kr.status === 'DONE_REJECTED') { - setError(`Underlaget innehåller ${findings.filter(f => f.status === 'STOPP').length} stoppande fel. Åtgärda och skicka igen.`) + setError(t('underlag_rejected_error', { count: findings.filter(f => f.status === 'STOPP').length })) } else { - setError('Skatteverket avvisade underlaget (DONE_FAILED).') + setError(t('underlag_failed')) } await fetchSubmission() onChange?.() } catch (e) { - setError(e instanceof Error ? e.message : 'Kunde inte skicka AGI') + setError(e instanceof Error ? e.message : t('submit_failed')) } finally { setActionLoading(null) } @@ -525,13 +524,13 @@ export function AGIPanel(props: AGIPanelProps) { ) const json = await res.json() if (!res.ok || json.error) { - setError(json.error || `Kunde inte skapa granskningsunderlag (${res.status})`) + setError(json.error || t('signing_link_failed_status', { status: res.status })) return } if (json.data?.tillstand === 'INCORRECT_DATA') { - setError(`${json.data.meddelande || 'Felaktiga underlag finns'}: öppna länken för felrapport.`) + setError(t('incorrect_data_error', { message: json.data.meddelande || t('incorrect_data_fallback') })) } else { - setSuccess('Granskningsunderlag klart. Öppna signeringslänken för att signera med BankID.') + setSuccess(t('signing_link_ready')) // The user typically opens the link, signs in Mina Sidor, then // returns later (or never). Auto-poll so we capture the kvittens // (and stamp agi_submitted_at) without forcing the user to come @@ -540,7 +539,7 @@ export function AGIPanel(props: AGIPanelProps) { } await fetchSubmission() } catch (e) { - setError(e instanceof Error ? e.message : 'Kunde inte skapa granskningsunderlag') + setError(e instanceof Error ? e.message : t('signing_link_failed')) } finally { setActionLoading(null) } @@ -557,13 +556,13 @@ export function AGIPanel(props: AGIPanelProps) { ) const json = await res.json() if (!res.ok || json.error) { - setError(json.error || `Kunde inte låsa upp (${res.status})`) + setError(json.error || t('unlock_failed_status', { status: res.status })) return } - setSuccess('AGI har låsts upp') + setSuccess(t('unlock_success')) await fetchSubmission() } catch (e) { - setError(e instanceof Error ? e.message : 'Kunde inte låsa upp') + setError(e instanceof Error ? e.message : t('unlock_failed')) } finally { setActionLoading(null) } @@ -585,19 +584,19 @@ export function AGIPanel(props: AGIPanelProps) { ) const json = await res.json() if (!res.ok || json.error) { - setError(json.error || 'Kunde inte hämta kvittenser') + setError(json.error || t('kvittens_fetch_failed')) return } const kvittens = json.data?.kvittenser?.[0] if (kvittens?.uuidKvittens) { - setSuccess('AGI har signerats och lämnats in.') + setSuccess(t('signed_success')) } else { - setSuccess('Ingen signerad kvittens hittades än för perioden.') + setSuccess(t('no_kvittens_yet')) } await fetchSubmission() onChange?.() } catch (e) { - setError(e instanceof Error ? e.message : 'Kunde inte kontrollera status') + setError(e instanceof Error ? e.message : t('check_status_failed')) } finally { setActionLoading(null) } @@ -609,15 +608,15 @@ export function AGIPanel(props: AGIPanelProps) { return ( - Arbetsgivardeklaration (AGI) + {t('title')}

- Skatteverket-integrationen är inaktiverad i denna miljö. Aktivera + {t('disabled_before')} SKATTEVERKET_ENABLED - för att skicka AGI direkt till Skatteverket. + {t('disabled_after')}

@@ -629,10 +628,10 @@ export function AGIPanel(props: AGIPanelProps) { return ( - Arbetsgivardeklaration (AGI) + {t('title')} - Hämtar Skatteverket-status... + {t('loading_status')} ) @@ -642,16 +641,16 @@ export function AGIPanel(props: AGIPanelProps) { return ( - Arbetsgivardeklaration (AGI) + {t('title')}

- Anslut till Skatteverket med BankID för att skicka AGI direkt från accounted. + {t('connect_description')}

{!readOnly && ( )}
@@ -687,11 +686,11 @@ export function AGIPanel(props: AGIPanelProps) { - Arbetsgivardeklaration (AGI) + {t('title')} - Ansluten + {t('connected')} {!readOnly && ( )} @@ -719,13 +718,13 @@ export function AGIPanel(props: AGIPanelProps) { only fix is a fresh BankID round-trip. */} {(status?.expired === true || status?.canRefresh === false) && !readOnly && (
-

Anslutningen mot Skatteverket har gått ut

+

{t('expired_banner_title')}

- Logga in med BankID igen för att kunna skicka AGI. + {t('expired_banner_description')}

)} @@ -737,18 +736,16 @@ export function AGIPanel(props: AGIPanelProps) { {missingAgdScope && !readOnly && (

- Anslutningen mot Skatteverket saknar behörighet för Arbetsgivardeklaration + {t('missing_scope_title')}

- Din anslutning utfärdades innan AGI-stödet aktiverades. Koppla - bort och anslut igen via Inställningar → Skatt för att - kunna skicka AGI direkt. + {t('missing_scope_description')}

- Öppna inställningar + {t('open_settings')}
)} @@ -757,26 +754,26 @@ export function AGIPanel(props: AGIPanelProps) {
@@ -788,9 +785,9 @@ export function AGIPanel(props: AGIPanelProps) { BankID signing is even possible. */} {submission?.signeringslank && awaitingSigning && !draftIsStale && (
-

Utkastet är låst och redo att signeras

+

{t('draft_locked_title')}

- Öppna länken nedan och signera med BankID på Skatteverkets sida. + {t('draft_locked_description')}

- Öppna signeringslänk + {t('open_signing_link')}
)} @@ -810,18 +807,19 @@ export function AGIPanel(props: AGIPanelProps) { the SKV lock; the user then re-submits the freshly generated XML. */} {awaitingSigning && draftIsStale && (
-

Signeringsutkastet är inaktuellt

+

{t('stale_draft_title')}

- AGI:n genererades om{' '} - {agiGeneratedAt ? new Date(agiGeneratedAt).toLocaleString('sv-SE') : ''}{' '} - efter att det här signeringsutkastet skapades - {submission?.updatedAt - ? ` (${new Date(submission.updatedAt).toLocaleString('sv-SE')})` - : ''} - . Utkastet hos Skatteverket innehåller äldre siffror. Klicka{' '} - Lås upp och därefter{' '} - Skicka in underlag för att - signera rätt belopp. + {t('stale_draft_description', { + generatedAt: agiGeneratedAt ? new Date(agiGeneratedAt).toLocaleString('sv-SE') : '', + draftCreatedAt: submission?.updatedAt + ? ` (${new Date(submission.updatedAt).toLocaleString('sv-SE')})` + : '', + })}{' '} + {t('stale_draft_click')}{' '} + {t('unlock_button')}{' '} + {t('stale_draft_then')}{' '} + {t('submit_button')}{' '} + {t('stale_draft_to_sign')}

)} @@ -834,10 +832,10 @@ export function AGIPanel(props: AGIPanelProps) { {submission?.signeringslank && underlagRejected && (

- Felaktiga underlag: granskningsunderlag kunde inte signeras + {t('incorrect_data_title')}

- {submission.meddelande || 'Skatteverket avvisade underlaget. Öppna felrapporten för detaljer.'} + {submission.meddelande || t('incorrect_data_description')}

- Öppna felrapport hos Skatteverket + {t('open_error_report')}
)} @@ -887,7 +885,7 @@ export function AGIPanel(props: AGIPanelProps) {
)} @@ -908,14 +906,14 @@ export function AGIPanel(props: AGIPanelProps) { variant="outline" onClick={handleDownloadXml} disabled={actionLoading === 'download'} - title="Ladda ner AGI-filen (XML) för manuell inlämning hos Skatteverket" + title={t('download_xml_title')} > {actionLoading === 'download' ? ( ) : ( )} - Ladda ner AGI-fil + {t('download_xml_button')} {awaitingSigning && ( )} @@ -980,12 +978,11 @@ export function AGIPanel(props: AGIPanelProps) { {!readOnly && !isSigned && !hasSkatteverket && (

- Ladda ner AGI-filen ovan och lämna in den manuellt i Skatteverkets - e-tjänst, eller{' '} + {t('upgrade_hint_before')}{' '} - uppgradera + {t('upgrade_hint_link')} {' '} - för att skicka in direkt härifrån. + {t('upgrade_hint_after')}

)} diff --git a/components/salary/EmployeeBenefitsPanel.tsx b/components/salary/EmployeeBenefitsPanel.tsx index 501c1171..feb12643 100644 --- a/components/salary/EmployeeBenefitsPanel.tsx +++ b/components/salary/EmployeeBenefitsPanel.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -25,6 +26,8 @@ interface EmployeeBenefit { is_active: boolean } +// Swedish defaults written to the DB when the description is left empty: +// stored data stays Swedish regardless of the viewer's UI locale. const BENEFIT_LABELS: Record = { bike: 'Cykelförmån', car: 'Bilförmån', @@ -35,6 +38,7 @@ const BENEFIT_LABELS: Record = { } export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) { + const t = useTranslations('salary_employee') const { toast } = useToast() const [benefits, setBenefits] = useState([]) const [loading, setLoading] = useState(true) @@ -94,13 +98,13 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st }) if (res.ok) { - toast({ title: 'Förmån tillagd' }) + toast({ title: t('benefits_added') }) reset() await load() } else { const result = await res.json() toast({ - title: 'Kunde inte spara förmån', + title: t('benefits_save_failed'), description: getErrorMessage(result, { statusCode: res.status }), variant: 'destructive', }) @@ -111,10 +115,10 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st async function handleDelete(id: string) { const res = await fetch(`/api/salary/employees/${employeeId}/benefits/${id}`, { method: 'DELETE' }) if (res.ok) { - toast({ title: 'Förmån borttagen' }) + toast({ title: t('benefits_removed') }) await load() } else { - toast({ title: 'Kunde inte ta bort', variant: 'destructive' }) + toast({ title: t('benefits_remove_failed'), variant: 'destructive' }) } } @@ -126,46 +130,46 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st return ( - Förmåner + {t('benefits_title')} {canWrite && !adding && ( )} {loading ? ( -

Laddar…

+

{t('benefits_loading')}

) : benefits.length === 0 && !adding ? (

- Inga förmåner registrerade. Aktiva förmåner läggs till automatiskt som rader vid lönekörning. + {t('benefits_empty')}

) : ( benefits.length > 0 && ( - Typ - Beskrivning - Värde/mån - Period + {t('benefits_type')} + {t('benefits_description')} + {t('benefits_value_per_month')} + {t('benefits_period')} {benefits.map(b => ( - {BENEFIT_LABELS[b.benefit_type]} + {t(`benefits_type_${b.benefit_type}`)} {b.description} {formatCurrency(b.monthly_value)} {formatDate(b.valid_from)} - {b.valid_to ? ` till ${formatDate(b.valid_to)}` : ' till löpande'} + {b.valid_to ? ` ${formatDate(b.valid_to)}` : ` ${t('benefits_ongoing')}`} {canWrite && ( - )} @@ -181,32 +185,32 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
- +
- + setDescription(e.target.value)} - placeholder={BENEFIT_LABELS[type]} + placeholder={t(`benefits_type_${type}`)} />
{type === 'bike' ? (
- + setAnnualMarketValue(e.target.value)} - placeholder="t.ex. 8 400 (700 kr/mån)" + placeholder={t('benefits_annual_market_value_placeholder')} className="max-w-xs" />

- Skatteverkets schablon: första 3 000 kr/år är skattefri. Resterande beskattas månadsvis. + {t('benefits_bike_hint')} {parseFloat(annualMarketValue) > 0 && ( - Månatligt förmånsvärde: {formatCurrency(previewMonthlyBike)} + {t('benefits_monthly_value_preview')} {formatCurrency(previewMonthlyBike)} )}

) : (
- +
- + setValidFrom(e.target.value)} />
- + setValidTo(e.target.value)} />
- +
diff --git a/components/salary/EmployeeTaxCard.tsx b/components/salary/EmployeeTaxCard.tsx index 5a7953b2..268ec3c5 100644 --- a/components/salary/EmployeeTaxCard.tsx +++ b/components/salary/EmployeeTaxCard.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useMemo, useRef } from 'react' +import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -44,6 +45,7 @@ export default function EmployeeTaxCard({ disabled, onChange, }: EmployeeTaxCardProps) { + const t = useTranslations('salary_employee') const incomeYear = year ?? new Date().getFullYear() const [fSkatt, setFSkatt] = useState(initial?.f_skatt_status ?? 'a_skatt') @@ -89,14 +91,14 @@ export default function EmployeeTaxCard({ return ( - Skatt + {t('tax_title')}
@@ -120,8 +122,8 @@ export default function EmployeeTaxCard({ disabled={disabled} className="rounded border-border" /> - - Sidoinkomst (30 % skatteavdrag) + + {t('tax_sidoinkomst_label')}
@@ -131,8 +133,8 @@ export default function EmployeeTaxCard({ <>
@@ -163,8 +165,8 @@ export default function EmployeeTaxCard({
@@ -191,7 +193,7 @@ export default function EmployeeTaxCard({
) : (

- Välj kommun ovan + {t('tax_table_pick_municipality')}

)} @@ -201,15 +203,15 @@ export default function EmployeeTaxCard({ onClick={() => setTableManual((v) => !v)} className="text-xs text-primary hover:underline underline-offset-4" > - {tableManual ? 'Använd kommunens tabell' : 'Ange tabell manuellt'} + {tableManual ? t('tax_table_use_municipality') : t('tax_table_enter_manually')} )}
setFormat(v as PaymentFormat)}> - {FORMAT_LABEL.bg_lb} {FORMAT_LABEL.pain001} + {FORMAT_LABEL.bg_lb} -

{FORMAT_DESCRIPTION[format]}

+

+ {format === 'bg_lb' ? t('format_description_bg_lb') : t('format_description_pain001')} +

+ {format === 'bg_lb' && ( +
+ + + {t('sunset_warning')}{' '} + + {t('sunset_link')} + + +
+ )} +
@@ -146,30 +172,31 @@ export function PaymentFilePanel({ aria-expanded={showInstructions} > - Så importerar du filen i din bank + {t('instructions_toggle')} {showInstructions && (
- {BANK_INSTRUCTIONS[format].map(b => ( -
- {b.bank}.{' '} - {b.steps} + {sortedBanks.map((bank) => ( +
+ + {BANK_NAME[bank]} + {bank === matchedBank ? ` (${t('your_bank')})` : ''}. + {' '} + + {t(`steps_${format}_${bank}`)} +
))}

- Beloppen är förifyllda: kvar är att signera med BankID i banken. + {t('instructions_footer')}

)}
- - Open Payments / direktbetalning via PSD2 (utan filimport) - är planerat för framtiden via Enable Banking. Initiering kräver separat PIS-avtal: vi följer upp när - tillräckligt många kunder använder lönebetalfiler regelbundet. - + {t('open_payments_note')}
)} diff --git a/components/salary/SalaryCalendar.tsx b/components/salary/SalaryCalendar.tsx index 22e561f6..761fe01a 100644 --- a/components/salary/SalaryCalendar.tsx +++ b/components/salary/SalaryCalendar.tsx @@ -11,7 +11,7 @@ import { startOfMonth, startOfWeek, } from 'date-fns' -import { sv } from 'date-fns/locale' +import { enUS, sv } from 'date-fns/locale' import { Activity, Baby, @@ -27,6 +27,7 @@ import { X, type LucideIcon, } from 'lucide-react' +import { useLocale, useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Dialog, @@ -68,21 +69,22 @@ interface WorkedDay { } interface AbsenceTypeMeta { - label: string - shortLabel: string + /** Translation keys in the `salary_calendar` namespace. */ + labelKey: string + shortLabelKey: string icon: LucideIcon pillClass: string } const TYPE_META: Record = { - sick: { label: 'Sjukfrånvaro', shortLabel: 'Sjuk', icon: HeartPulse, pillClass: 'bg-red-100 text-red-800' }, - vab: { label: 'VAB', shortLabel: 'VAB', icon: Baby, pillClass: 'bg-amber-100 text-amber-800' }, - parental: { label: 'Föräldraledighet', shortLabel: 'Förä.', icon: Heart, pillClass: 'bg-emerald-100 text-emerald-800' }, - pregnancy: { label: 'Graviditetspenning', shortLabel: 'Grav.', icon: Heart, pillClass: 'bg-pink-100 text-pink-800' }, - care_relative: { label: 'Närståendepenning', shortLabel: 'Närst.', icon: Heart, pillClass: 'bg-blue-100 text-blue-800' }, - study: { label: 'Studieledig', shortLabel: 'Studie', icon: Activity, pillClass: 'bg-indigo-100 text-indigo-800' }, - unpaid_leave: { label: 'Tjänstledig utan lön', shortLabel: 'Tjänstl.', icon: MinusCircle, pillClass: 'bg-slate-100 text-slate-800' }, - other_leave: { label: 'Övrig ledighet', shortLabel: 'Övrigt', icon: Activity, pillClass: 'bg-zinc-100 text-zinc-800' }, + sick: { labelKey: 'type_sick', shortLabelKey: 'type_sick_short', icon: HeartPulse, pillClass: 'bg-red-100 text-red-800' }, + vab: { labelKey: 'type_vab', shortLabelKey: 'type_vab_short', icon: Baby, pillClass: 'bg-amber-100 text-amber-800' }, + parental: { labelKey: 'type_parental', shortLabelKey: 'type_parental_short', icon: Heart, pillClass: 'bg-emerald-100 text-emerald-800' }, + pregnancy: { labelKey: 'type_pregnancy', shortLabelKey: 'type_pregnancy_short', icon: Heart, pillClass: 'bg-pink-100 text-pink-800' }, + care_relative: { labelKey: 'type_care_relative', shortLabelKey: 'type_care_relative_short', icon: Heart, pillClass: 'bg-blue-100 text-blue-800' }, + study: { labelKey: 'type_study', shortLabelKey: 'type_study_short', icon: Activity, pillClass: 'bg-indigo-100 text-indigo-800' }, + unpaid_leave: { labelKey: 'type_unpaid_leave', shortLabelKey: 'type_unpaid_leave_short', icon: MinusCircle, pillClass: 'bg-slate-100 text-slate-800' }, + other_leave: { labelKey: 'type_other_leave', shortLabelKey: 'type_other_leave_short', icon: Activity, pillClass: 'bg-zinc-100 text-zinc-800' }, } const TYPE_ORDER: AbsenceType[] = ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave'] @@ -119,6 +121,9 @@ export function SalaryCalendar({ onChange, onAbsenceCountsChange, }: SalaryCalendarProps) { + const t = useTranslations('salary_calendar') + const locale = useLocale() + const dateLocale = locale === 'en' ? enUS : sv const isHourly = salaryType === 'hourly' const periodStartDate = useMemo(() => parseISO(periodStart), [periodStart]) const periodEndDate = useMemo(() => parseISO(periodEnd), [periodEnd]) @@ -151,15 +156,15 @@ export function SalaryCalendar({ } const responses = await Promise.all(requests) const absJson = await responses[0]!.json() - if (!responses[0]!.ok) throw new Error(absJson.error || 'Kunde inte ladda frånvaro') + if (!responses[0]!.ok) throw new Error(absJson.error || t('error_load_absence')) setAbsences(absJson.data ?? []) if (isHourly && responses[1]) { const wJson = await responses[1].json() - if (!responses[1].ok) throw new Error(wJson.error || 'Kunde inte ladda arbetade timmar') + if (!responses[1].ok) throw new Error(wJson.error || t('error_load_worked')) setWorked(wJson.data ?? []) } } catch (e) { - setError(e instanceof Error ? e.message : 'Okänt fel') + setError(e instanceof Error ? e.message : t('unknown_error')) } finally { setLoading(false) } @@ -272,9 +277,7 @@ export function SalaryCalendar({ const handleBulkDelete = async () => { if (selected.size === 0 || readOnly) return - if (!confirm( - `Ta bort allt (arbetad tid och frånvaro) på ${selected.size} ${selected.size === 1 ? 'dag' : 'dagar'}?`, - )) return + if (!confirm(t('confirm_bulk_delete', { count: selected.size }))) return setDeleting(true) setError(null) try { @@ -288,7 +291,7 @@ export function SalaryCalendar({ ) if (!wRes.ok) { const j = await wRes.json().catch(() => ({})) - throw new Error(j.error || `Kunde inte ta bort arbetad tid på ${date}`) + throw new Error(j.error || t('error_delete_worked_date', { date })) } } const aRes = await fetch( @@ -297,14 +300,14 @@ export function SalaryCalendar({ ) if (!aRes.ok) { const j = await aRes.json().catch(() => ({})) - throw new Error(j.error || `Kunde inte ta bort frånvaro på ${date}`) + throw new Error(j.error || t('error_delete_absence_date', { date })) } } clearSelection() await load() onChange?.() } catch (e) { - setError(e instanceof Error ? e.message : 'Okänt fel') + setError(e instanceof Error ? e.message : t('unknown_error')) } finally { setDeleting(false) } @@ -319,18 +322,18 @@ export function SalaryCalendar({ variant="ghost" size="sm" onClick={() => setVisibleMonth(prev => addDays(startOfMonth(prev), -1))} - aria-label="Föregående månad" + aria-label={t('prev_month')} > - {format(visibleMonth, 'MMMM yyyy', { locale: sv })} + {format(visibleMonth, 'MMMM yyyy', { locale: dateLocale })} @@ -342,18 +345,18 @@ export function SalaryCalendar({ size="sm" onClick={handleFillWeekdays} className="text-xs" - title="Markera alla vardagar i perioden som inte redan har arbetad tid" + title={t('fill_weekdays_title')} > - Fyll vardagar + {t('fill_weekdays')} )}
{/* Weekday header */}
- {['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön'].map(d => ( -
{d}
+ {(['wd_mon', 'wd_tue', 'wd_wed', 'wd_thu', 'wd_fri', 'wd_sat', 'wd_sun'] as const).map(d => ( +
{t(d)}
))}
@@ -387,7 +390,7 @@ export function SalaryCalendar({ today && 'ring-1 ring-inset ring-primary/40', isSelected && 'ring-2 ring-inset ring-primary bg-primary/5', )} - title={hasContent ? 'Dubbelklicka för detaljer' : undefined} + title={hasContent ? t('cell_details_title') : undefined} > {format(date, 'd')} @@ -411,10 +414,10 @@ export function SalaryCalendar({ 'inline-flex items-center gap-0.5 rounded-full px-1 py-px text-[10px] font-medium', meta.pillClass, )} - title={`${meta.label} (${a.hours}h)`} + title={t('pill_title', { label: t(meta.labelKey), hours: String(a.hours) })} > - {meta.shortLabel} + {t(meta.shortLabelKey)} ) })} @@ -431,12 +434,12 @@ export function SalaryCalendar({ {isHourly && ( <> - Arbetade timmar i perioden:{' '} + {t('worked_hours_in_period')}{' '} {periodTotalHours} h {' · '} )} - Klicka för att markera, shift-klicka för intervall, dubbelklicka för detaljer. + {t('hint_click')}
@@ -447,18 +450,18 @@ export function SalaryCalendar({ - Arbetad tid + {t('worked_time')} )} - {TYPE_ORDER.map(t => { - const meta = TYPE_META[t] + {TYPE_ORDER.map(type => { + const meta = TYPE_META[type] const Icon = meta.icon return ( - + - {meta.label} + {t(meta.labelKey)} ) })} @@ -475,12 +478,12 @@ export function SalaryCalendar({
{selected.size}{' '} - {selected.size === 1 ? 'dag' : 'dagar'} valda + {t('days_selected', { count: selected.size })}
{isHourly && ( )}
@@ -577,6 +580,7 @@ function BulkWorkedDialog({ onClose, onSaved, }: BulkWorkedDialogProps) { + const t = useTranslations('salary_calendar') const [hours, setHours] = useState('8') const [notes, setNotes] = useState('') const [submitting, setSubmitting] = useState(false) @@ -592,7 +596,7 @@ function BulkWorkedDialog({ setConflicts([]) try { if (!isFinite(hoursNum) || hoursNum < 0 || hoursNum > 24) { - throw new Error('Timmar måste vara mellan 0 och 24') + throw new Error(t('error_hours_range')) } // 0 hours = "no worked time on these days" → delete any existing rows. // Avoids tripping the DB CHECK (hours > 0) and matches user intent. @@ -604,7 +608,7 @@ function BulkWorkedDialog({ ) if (!res.ok) { const j = await res.json().catch(() => ({})) - throw new Error(j.error || `Kunde inte ta bort ${date}`) + throw new Error(j.error || t('error_delete_date', { date })) } } onSaved([]) @@ -625,10 +629,10 @@ function BulkWorkedDialog({ setConflicts(json.data?.conflicts ?? []) return } - if (!res.ok) throw new Error(json.error || 'Kunde inte spara timmar') + if (!res.ok) throw new Error(json.error || t('error_save_hours')) onSaved([]) } catch (e) { - setError(e instanceof Error ? e.message : 'Okänt fel') + setError(e instanceof Error ? e.message : t('unknown_error')) } finally { setSubmitting(false) } @@ -638,17 +642,17 @@ function BulkWorkedDialog({ !o && onClose()}> - Arbetade timmar + {t('bulk_worked_title')} {isClear - ? `Arbetad tid tas bort för ${dates.length} ${dates.length === 1 ? 'dag' : 'dagar'}.` - : `${dates.length} ${dates.length === 1 ? 'dag' : 'dagar'} markeras som arbetade. Befintliga timmar för dessa datum skrivs över.`} + ? t('bulk_worked_clear_desc', { count: dates.length }) + : t('bulk_worked_desc', { count: dates.length })}
- +

- Sätt till 0 för att ta bort arbetad tid på de valda dagarna. + {t('hours_zero_hint')}

- +