diff --git a/.gitignore b/.gitignore index 8a7cee29..0c5e9e86 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ supabase/.temp/ # Run `npm run setup:extensions` to regenerate after changing extensions.config.json # The empty defaults in lib/extensions/_generated/ are committed so core compiles # out of the box without running the generator. +supabase/.branches/ diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts index 6e0a0dbc..58f279c1 100644 --- a/app/(auth)/auth/callback/route.ts +++ b/app/(auth)/auth/callback/route.ts @@ -1,5 +1,6 @@ import { createServerClient } from '@supabase/ssr' import { type NextRequest, NextResponse } from 'next/server' +import { hashInviteToken } from '@/lib/auth/invite-tokens' export async function GET(request: NextRequest) { const { searchParams, origin } = new URL(request.url) @@ -22,7 +23,13 @@ export async function GET(request: NextRequest) { }, setAll(cookiesToSet) { pendingCookies.length = 0 - cookiesToSet.forEach((cookie) => pendingCookies.push(cookie)) + cookiesToSet.forEach((cookie) => { + // Mirror the cookie into request.cookies so subsequent getAll() + // calls within this request lifecycle return the updated values + // (matches the pattern used in middleware.ts). + request.cookies.set(cookie.name, cookie.value) + pendingCookies.push(cookie) + }) }, }, } @@ -59,14 +66,95 @@ export async function GET(request: NextRequest) { return response } - // Check if user has completed onboarding - const { data: settings } = await supabase - .from('company_settings') - .select('onboarding_complete') + // Check for pending invite token (set by invite page before redirecting to register) + const inviteToken = request.cookies.get('gnubok-invite-token')?.value + if (inviteToken) { + try { + const tokenHash = hashInviteToken(inviteToken) + + // Use the service role client to bypass RLS for invite acceptance + const serviceClient = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY!, + { cookies: { getAll: () => [], setAll: () => {} } } + ) + + // Look up company invitation + const { data: invite } = await serviceClient + .from('company_invitations') + .select('id, company_id, email, role, status, expires_at') + .eq('token_hash', tokenHash) + .single() + + if ( + invite && + invite.status === 'pending' && + new Date(invite.expires_at) > new Date() && + user.email?.toLowerCase() === invite.email.toLowerCase() + ) { + // Add user to company + await serviceClient.from('company_members').insert({ + company_id: invite.company_id, + user_id: user.id, + role: invite.role, + source: 'direct', + }) + + // Set active company + await serviceClient.from('user_preferences').upsert({ + user_id: user.id, + active_company_id: invite.company_id, + }, { onConflict: 'user_id' }) + + // Mark invite as accepted + await serviceClient + .from('company_invitations') + .update({ status: 'accepted' }) + .eq('id', invite.id) + + // Invited user goes straight to dashboard — no onboarding needed + redirectPath = '/' + + // Clear invite cookie and set company cookie on response + const response = NextResponse.redirect(new URL(redirectPath, origin)) + for (const { name, value, options } of pendingCookies) { + response.cookies.set({ name, value, ...options }) + } + response.cookies.set('gnubok-company-id', invite.company_id, { + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 365, + }) + response.cookies.delete('gnubok-invite-token') + return response + } + } catch (err) { + console.error('[auth/callback] invite acceptance failed:', err) + // Fall through to normal onboarding check + } + } + + // Check if user has completed onboarding (for any company they belong to) + const { data: membership } = await supabase + .from('company_members') + .select('company_id') .eq('user_id', user.id) + .limit(1) .single() - if (!settings?.onboarding_complete) { + if (membership?.company_id) { + const { data: settings } = await supabase + .from('company_settings') + .select('onboarding_complete') + .eq('company_id', membership.company_id) + .single() + + if (!settings?.onboarding_complete) { + redirectPath = '/onboarding' + } + } else { redirectPath = '/onboarding' } } @@ -76,6 +164,8 @@ export async function GET(request: NextRequest) { for (const { name, value, options } of pendingCookies) { response.cookies.set({ name, value, ...options }) } + // Keep the invite cookie alive so the onboarding page fallback can + // retry acceptance (only clear it when successfully processed above). return response } diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 5da73a57..cf6bb258 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -1,6 +1,7 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect, Suspense } from 'react' +import { useSearchParams } from 'next/navigation' import Link from 'next/link' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' @@ -12,14 +13,45 @@ import Image from 'next/image' import { getErrorMessage } from '@/lib/errors/get-error-message' export default function RegisterPage() { + return ( + + + + }> + + + ) +} + +function RegisterPageContent() { + const searchParams = useSearchParams() const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [isLoading, setIsLoading] = useState(false) const [isRegistered, setIsRegistered] = useState(false) + const [inviteEmail, setInviteEmail] = useState(null) const { toast } = useToast() const supabase = createClient() + // When arriving from an invite link, fetch the invite info to pre-fill + // and lock the email field so the user registers with the correct address. + useEffect(() => { + const inviteToken = searchParams.get('invite') + if (!inviteToken) return + + fetch(`/api/team/accept?token=${encodeURIComponent(inviteToken)}`) + .then((res) => res.ok ? res.json() : null) + .then((data) => { + if (data?.data?.email) { + setInviteEmail(data.data.email) + setEmail(data.data.email) + } + }) + .catch(() => {}) + }, [searchParams]) + function isStrongPassword(pw: string): boolean { return pw.length >= 8 && /[a-z]/.test(pw) @@ -102,6 +134,43 @@ export default function RegisterPage() { provider: data.user?.app_metadata?.provider, }) + // If auto-confirmed (local dev), process invite immediately and redirect + if (data.session) { + const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/) + const inviteToken = cookieMatch?.[1] + + if (inviteToken) { + try { + const res = await fetch('/api/team/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: inviteToken }), + }) + + if (res.ok) { + document.cookie = 'gnubok-invite-token=; path=/; max-age=0' + console.log('[register] invite accepted after auto-confirm — redirecting') + window.location.href = '/' + return + } + + // Log the error response so we can diagnose invite failures + const errBody = await res.json().catch(() => ({})) + console.error('[register] invite acceptance returned non-ok', { + status: res.status, + error: errBody.error, + }) + } catch (err) { + console.error('[register] invite acceptance failed:', err) + } + } + + // Auto-confirmed but no invite or invite failed — go to onboarding + // (invite cookie is preserved so the onboarding fallback can retry) + window.location.href = '/' + return + } + setEmail(emailValue) setIsRegistered(true) } catch (error) { @@ -188,9 +257,15 @@ export default function RegisterPage() { value={email} onChange={(e) => setEmail(e.target.value)} required - disabled={isLoading} + disabled={isLoading || !!inviteEmail} + readOnly={!!inviteEmail} className="h-11" /> + {inviteEmail && ( +

+ Inbjudan skickades till denna adress. +

+ )}
diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx index ba21e231..8b98f9c7 100644 --- a/app/(dashboard)/deadlines/page.tsx +++ b/app/(dashboard)/deadlines/page.tsx @@ -60,7 +60,7 @@ export default function DeadlinesPage() { }, [fetchData]) const handleDeadlineCreate = async ( - data: Omit + data: Omit ) => { try { const response = await fetch('/api/deadlines', { diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 2e3a5dfa..aba8a654 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -8,6 +8,7 @@ import { useToast } from '@/components/ui/use-toast' import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' +import { useCompany } from '@/contexts/CompanyContext' import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector' import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' @@ -566,6 +567,7 @@ function PSD2ConnectWizard() { const { toast } = useToast() const supabase = createClient() const { dialogProps, confirm } = useDestructiveConfirm() + const { company } = useCompany() const [bankConnections, setBankConnections] = useState([]) const [syncingConnectionId, setSyncingConnectionId] = useState(null) @@ -582,10 +584,12 @@ function PSD2ConnectWizard() { const { data: { user } } = await supabase.auth.getUser() if (!user) return + if (!company) return + const { data: connections } = await supabase .from('bank_connections') .select('*') - .eq('user_id', user.id) + .eq('company_id', company.id) .order('created_at', { ascending: false }) setBankConnections(connections || []) @@ -750,6 +754,7 @@ function PSD2ConnectWizard() { type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'migration' export default function ImportPage() { + const { company } = useCompany() const [mode, setMode] = useState(null) const [userId, setUserId] = useState('') const [isSandbox, setIsSandbox] = useState(false) @@ -760,10 +765,11 @@ export default function ImportPage() { supabase.auth.getUser().then(({ data: { user } }) => { if (!user) return setUserId(user.id) + if (!company) return supabase .from('company_settings') .select('is_sandbox') - .eq('user_id', user.id) + .eq('company_id', company.id) .single() .then(({ data }) => { if (data?.is_sandbox) setIsSandbox(true) diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 47c160f9..b3e4a5d7 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -1,11 +1,14 @@ import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' +import { cookies } from 'next/headers' import DashboardNav from '@/components/dashboard/DashboardNav' import { RecaptIdentify } from '@/components/RecaptIdentify' import { SentryIdentify } from '@/components/SentryIdentify' import { SandboxBanner } from '@/components/dashboard/SandboxBanner' import { getExtensionNavItems } from '@/lib/extensions/sectors' -import type { EntityType } from '@/types' +import { CompanyProvider } from '@/contexts/CompanyContext' +import { getActiveCompanyId } from '@/lib/company/context' +import type { EntityType, CompanyRole, Team } from '@/types' export default async function DashboardLayout({ children, @@ -20,21 +23,138 @@ export default async function DashboardLayout({ redirect('/login') } + const cookieStore = await cookies() + const companyId = cookieStore.get('gnubok-company-id')?.value + ?? await getActiveCompanyId(supabase, user.id) + + // 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 isTeamMember = !!teamMembership + + // Consultant with team but no companies — show dashboard with empty state + if (!companyId) { + if (isTeamMember) { + const companyContextValue = { + company: null, + role: null, + companies: [], + isTeamMember: true, + team, + } + + return ( + +
+ + Hoppa till innehåll + + +
+
+ {children} +
+
+ +
+
+ ) + } + + redirect('/onboarding') + } + + // Fetch company + membership for context provider + const [ + { data: companyRow }, + { data: memberRow }, + { data: allMemberships }, + ] = 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, created_by, team_id, archived_at, created_at, updated_at)').eq('user_id', user.id), + ]) + + if (!companyRow || !memberRow) { + // Stale cookie pointing to a deleted/inaccessible company. + // If the user is a team member, render the empty-state dashboard + // instead of redirecting to onboarding (which would cause a loop). + if (isTeamMember) { + const companyContextValue = { + company: null, + role: null, + companies: (allMemberships || []).filter(m => m.companies).map((m) => ({ + company: m.companies as unknown as import('@/types').Company, + role: m.role as CompanyRole, + })), + isTeamMember: true, + team, + } + + return ( + +
+ +
+
+ {children} +
+
+ +
+
+ ) + } + redirect('/onboarding') + } + const [{ data: settings }, { count: uncategorizedCount }, { count: pendingOpsCount }] = await Promise.all([ supabase .from('company_settings') .select('company_name, onboarding_complete, entity_type, is_sandbox') - .eq('user_id', user.id) + .eq('company_id', companyId) .single(), supabase .from('transactions') .select('*', { count: 'exact', head: true }) - .eq('user_id', user.id) + .eq('company_id', companyId) .is('is_business', null), supabase .from('pending_operations') .select('*', { count: 'exact', head: true }) - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('status', 'pending'), ]) @@ -42,41 +162,62 @@ export default async function DashboardLayout({ redirect('/onboarding') } + // Use company_name from settings as the display name (companies.name may be stale) + const displayName = settings.company_name || companyRow.name + const companyWithName = { ...companyRow, name: displayName } + + const companyContextValue = { + company: companyWithName, + role: memberRow.role as CompanyRole, + companies: (allMemberships || []).map((m) => { + const c = m.companies as unknown as import('@/types').Company + // Override active company's name with settings name + if (c.id === companyId) { + return { company: { ...c, name: displayName }, role: m.role as CompanyRole } + } + return { company: c, role: m.role as CompanyRole } + }), + isTeamMember, + team, + } + const entityType = (settings.entity_type as EntityType) || 'enskild_firma' const isSandbox = settings.is_sandbox === true return ( -
- {/* Skip to content link for keyboard/screen reader users */} - - Hoppa till innehåll - - {isSandbox && } - -
-
- {children} -
-
- - {!isSandbox && ( - +
+ {/* Skip to content link for keyboard/screen reader users */} + + Hoppa till innehåll + + {isSandbox && } + - )} -
+
+
+ {children} +
+
+ + {!isSandbox && ( + + )} +
+ ) } diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index d1b0255c..5c687d23 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -1,6 +1,9 @@ import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' +import { cookies } from 'next/headers' import DashboardContent from '@/components/dashboard/DashboardContent' +import ConsultantEmptyState from '@/components/dashboard/ConsultantEmptyState' +import { getActiveCompanyId } from '@/lib/company/context' import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' export const dynamic = 'force-dynamic' @@ -14,6 +17,44 @@ export default async function DashboardPage() { redirect('/login') } + const cookieStore = await cookies() + const rawCompanyId = cookieStore.get('gnubok-company-id')?.value + ?? await getActiveCompanyId(supabase, user.id) + + // Validate the cookie/preference points to a company the user can access + let companyId = rawCompanyId + if (companyId) { + const { data: membership } = await supabase + .from('company_members') + .select('company_id') + .eq('company_id', companyId) + .eq('user_id', user.id) + .maybeSingle() + if (!membership) companyId = null + } + + if (!companyId) { + // Consultants (team members) see an empty state; solo users go to onboarding + const { data: teamMembership } = await supabase + .from('team_members') + .select('team_id') + .eq('user_id', user.id) + .limit(1) + .maybeSingle() + + if (teamMembership) { + const { data: profile } = await supabase + .from('profiles') + .select('full_name') + .eq('id', user.id) + .single() + const firstName = profile?.full_name?.split(' ')[0] || null + return + } + + redirect('/onboarding') + } + // Fetch current year date boundaries const startOfYearStr = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0] const startOfMonthStr = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0] @@ -54,28 +95,29 @@ export default async function DashboardPage() { { count: staleUncategorizedCount }, ] = await Promise.all([ supabase.from('profiles').select('full_name').eq('id', user.id).single(), - supabase.from('company_settings').select('*').eq('user_id', user.id).single(), - supabase.from('customers').select('*', { count: 'exact', head: true }).eq('user_id', user.id), - supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('user_id', user.id), - supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id), - supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id), + supabase.from('company_settings').select('*').eq('company_id', companyId).single(), + supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId), + supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId), + supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId), + supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId), supabase.from('journal_entry_lines') - .select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status)') + .select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status, company_id)') .eq('journal_entry.status', 'posted') + .eq('journal_entry.company_id', companyId) .gte('journal_entry.entry_date', startOfYearStr), - supabase.from('transactions').select('amount, amount_sek, is_business').eq('user_id', user.id).gte('date', startOfYearStr), - supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status').eq('user_id', user.id).in('status', ['sent', 'overdue']), - supabase.from('bank_connections').select('id, accounts_data, status, consent_expires, bank_name').eq('user_id', user.id).eq('status', 'active'), - supabase.from('deadlines').select('*, customer:customers(id, name)').eq('user_id', user.id).eq('is_completed', false) + supabase.from('transactions').select('amount, amount_sek, is_business').eq('company_id', companyId).gte('date', startOfYearStr), + supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status').eq('company_id', companyId).in('status', ['sent', 'overdue']), + supabase.from('bank_connections').select('id, accounts_data, status, consent_expires, bank_name').eq('company_id', companyId).eq('status', 'active'), + supabase.from('deadlines').select('*, customer:customers(id, name)').eq('company_id', companyId).eq('is_completed', false) .or(`due_date.lt.${today},due_date.lte.${nextWeek}`).order('due_date', { ascending: true }), - supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'extracted'), - supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'confirmed').is('matched_transaction_id', null), - supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id).lt('amount', 0).is('receipt_id', null), - supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'posted').in('source_type', needsDocSourceTypes), - supabase.from('document_attachments').select('journal_entry_id').eq('user_id', user.id).eq('is_current_version', true).not('journal_entry_id', 'is', null), - supabase.from('receipts').select('created_at').eq('user_id', user.id).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30), - supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'completed'), - supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id).is('journal_entry_id', null).not('is_business', 'eq', false).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]), + supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'extracted'), + supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'confirmed').is('matched_transaction_id', null), + supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).lt('amount', 0).is('receipt_id', null), + supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'posted').in('source_type', needsDocSourceTypes), + supabase.from('document_attachments').select('journal_entry_id').eq('company_id', companyId).eq('is_current_version', true).not('journal_entry_id', 'is', null), + supabase.from('receipts').select('created_at').eq('company_id', companyId).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30), + supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'), + supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).not('is_business', 'eq', false).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]), ]) const firstName = profile?.full_name?.split(' ')[0] || null diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 6ec356b2..b8bafa7a 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -32,6 +32,7 @@ import { AlertTriangle, } from 'lucide-react' import { useTheme } from 'next-themes' +import { useCompany } from '@/contexts/CompanyContext' import type { CompanySettings } from '@/types' import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn' import { BankNameCombobox } from '@/components/settings/BankNameCombobox' @@ -41,6 +42,8 @@ import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry' import { SecuritySettings } from '@/components/settings/SecuritySettings' import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel' import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel' +import { TeamPanel } from '@/components/settings/TeamPanel' +import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' const BankingPanel = getSettingsPanel('enable-banking') @@ -50,6 +53,7 @@ export default function SettingsPage() { const searchParams = useSearchParams() const { toast } = useToast() const supabase = createClient() + const { company, isTeamMember } = useCompany() const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) @@ -67,15 +71,18 @@ export default function SettingsPage() { const { theme, setTheme } = useTheme() const [mounted, setMounted] = useState(false) - const initialTab = searchParams.get('tab') || 'company' + const hasCompany = !!company + const defaultTab = hasCompany ? 'company' : (isTeamMember ? 'team' : 'account') + const initialTab = searchParams.get('tab') || defaultTab const [activeTab, setActiveTab] = useState(initialTab) const settingsTabs = [ - { value: 'company', label: 'Företag', show: true }, - { value: 'banking', label: 'Bank (PSD2)', show: !settings?.is_sandbox && hasBankingExtension }, - { value: 'templates', label: 'Mallar', show: true }, + { value: 'company', label: 'Företag', show: hasCompany }, + { value: 'team', label: 'Lag', show: isTeamMember }, + { value: 'banking', label: 'Bank (PSD2)', show: hasCompany && !settings?.is_sandbox && hasBankingExtension }, + { value: 'templates', label: 'Mallar', show: hasCompany }, { value: 'account', label: 'Konto', show: true }, - { value: 'api', label: 'API', show: hasMcpExtension }, + { value: 'api', label: 'API', show: hasCompany && hasMcpExtension }, ].filter(t => t.show) useEffect(() => { @@ -91,9 +98,10 @@ export default function SettingsPage() { return } - const settingsRes = await supabase.from('company_settings').select('*').eq('user_id', user.id).single() - - setSettings(settingsRes.data) + if (company?.id) { + const settingsRes = await supabase.from('company_settings').select('*').eq('company_id', company.id).single() + setSettings(settingsRes.data) + } setIsLoading(false) } @@ -625,6 +633,15 @@ export default function SettingsPage() {
+ + + + + + + {/* Team management */} + + {/* Banking settings — loaded dynamically from extension, hidden for sandbox */} diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 221d6101..ffa99946 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -27,6 +27,7 @@ import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates' import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates' import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types' +import { useCompany } from '@/contexts/CompanyContext' import { formatCurrency, formatDate } from '@/lib/utils' import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType, LinePatternEntry } from '@/types' import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions' @@ -42,6 +43,7 @@ interface QuickReviewState { } export default function TransactionsPage() { + const { company } = useCompany() const [transactions, setTransactions] = useState([]) const [isLoading, setIsLoading] = useState(true) const [mode, setMode] = useState('inbox') @@ -484,6 +486,7 @@ export default function TransactionsPage() { const { data: transaction, error } = await supabase .from('transactions') .insert({ + company_id: company!.id, user_id: user.id, date: data.date, description: data.description, diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx index 63065a9b..a4618b0d 100644 --- a/app/(onboarding)/onboarding/page.tsx +++ b/app/(onboarding)/onboarding/page.tsx @@ -6,17 +6,24 @@ import Image from 'next/image' import * as Sentry from '@sentry/nextjs' import { createClient } from '@/lib/supabase/client' import { useToast } from '@/components/ui/use-toast' -import { Loader2 } from 'lucide-react' +import { Loader2, ArrowRight } from 'lucide-react' import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import type { CompanyLookupResult } from '@/lib/company-lookup/types' import type { CompanySettings, EntityType, MomsPeriod } from '@/types' +import Step0RoleChoice from '@/components/onboarding/Step0RoleChoice' import Step1EntityType from '@/components/onboarding/Step1EntityType' import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails' import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration' import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting' + +type OnboardingMode = 'choice' | 'self' | 'consultant' + const STEP_INFO = [ { title: 'Välkommen', subtitle: 'Välj din företagsform för att komma igång.', label: 'Företagsform' }, { title: 'Ditt företag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' }, @@ -24,6 +31,13 @@ const STEP_INFO = [ { title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.', label: 'Moms' }, ] +const STEP_INFO_CONSULTANT = [ + { title: 'Kundföretag', subtitle: 'Välj din kunds företagsform.', label: 'Företagsform' }, + { title: 'Kundföretag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' }, + { title: 'F-skatt & räkenskapsår', subtitle: 'Din kunds skatteregistrering och räkenskapsår.', label: 'Skatt' }, + { title: 'Moms & bokföring', subtitle: 'Din kunds momsregistrering och bokföringsmetod.', label: 'Moms' }, +] + function translatePeriodError(msg: string): string { if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.' if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.' @@ -72,8 +86,12 @@ function OnboardingPageContent() { const [isSaving, setIsSaving] = useState(false) const [currentStep, setCurrentStep] = useState(1) const [settings, setSettings] = useState>({}) + const [companyId, setCompanyId] = useState(null) const ticEnabled = ENABLED_EXTENSION_IDS.has('tic') const [ticLookup, setTicLookup] = useState(null) + const [mode, setMode] = useState('choice') + const [consultantLanding, setConsultantLanding] = useState(false) + const [teamName, setTeamName] = useState('') const totalSteps = 4 @@ -102,26 +120,95 @@ function OnboardingPageContent() { return } - const { data, error } = await supabase - .from('company_settings') - .select('*') - .eq('user_id', user.id) - .single() + // Check for unprocessed invite token (fallback if auth callback didn't process it) + const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/) + const inviteToken = cookieMatch?.[1] - if (error && error.code !== 'PGRST116') { - logError('failed to load settings', { message: error.message, code: error.code }) + if (inviteToken) { + try { + const res = await fetch('/api/team/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: inviteToken }), + }) + + if (res.ok) { + // Clear the cookie + document.cookie = 'gnubok-invite-token=; path=/; max-age=0' + console.log(LOG, 'invite accepted via fallback — redirecting to dashboard') + router.push('/') + return + } + + // Log the failure to help diagnose + const errBody = await res.json().catch(() => ({})) + console.error(LOG, 'fallback invite acceptance returned non-ok', { + status: res.status, + error: errBody.error, + }) + } catch (err) { + console.error(LOG, 'fallback invite acceptance failed:', err) + } + // Clear cookie regardless to avoid retry loops + document.cookie = 'gnubok-invite-token=; path=/; max-age=0' } - if (data) { - const step = data.onboarding_step || 1 - const clampedStep = step > totalSteps ? totalSteps : step - if (step > totalSteps) { - logError('onboarding_step exceeds totalSteps — clamped', { step, totalSteps }) + // Check if user is already in a team (consultant) — skip onboarding + const { data: teamMember } = await supabase + .from('team_members') + .select('id') + .eq('user_id', user.id) + .limit(1) + .maybeSingle() + + if (teamMember) { + console.log(LOG, 'user already in a team — redirecting to dashboard') + window.location.href = '/' + return + } + + // Check if user already has a company via company_members + const { data: membership } = await supabase + .from('company_members') + .select('company_id') + .eq('user_id', user.id) + .order('created_at', { ascending: true }) + .limit(1) + .single() + + if (membership?.company_id) { + const { data, error } = await supabase + .from('company_settings') + .select('*') + .eq('company_id', membership.company_id) + .single() + + if (error && error.code !== 'PGRST116') { + logError('failed to load settings', { message: error.message, code: error.code }) + } + + // If this company is already onboarded (invited user joining existing company), + // skip onboarding entirely and go to dashboard + if (data?.onboarding_complete) { + console.log(LOG, 'company already onboarded — redirecting to dashboard') + router.push('/') + return + } + + setCompanyId(membership.company_id) + setMode('self') // Resuming — skip role choice + + if (data) { + const step = data.onboarding_step || 1 + const clampedStep = step > totalSteps ? totalSteps : step + if (step > totalSteps) { + logError('onboarding_step exceeds totalSteps — clamped', { step, totalSteps }) + } + // Important milestone: where we resume + console.log(LOG, 'resuming at step', clampedStep, { entity_type: data.entity_type }) + setSettings(data) + setCurrentStep(clampedStep) } - // Important milestone: where we resume - console.log(LOG, 'resuming at step', clampedStep, { entity_type: data.entity_type }) - setSettings(data) - setCurrentStep(clampedStep) } setIsLoading(false) @@ -153,16 +240,21 @@ function OnboardingPageContent() { onboarding_step: targetStep, } + if (!companyId) { + logError('save aborted: no companyId', { step: targetStep }) + return false + } + // Remove read-only and transient fields before updating const { - id: _id, user_id: _uid, created_at: _ca, updated_at: _ua, + id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua, is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, ...settingsToSave } = updatedSettings as Record const { error } = await supabase .from('company_settings') - .upsert({ ...settingsToSave, user_id: user.id }, { onConflict: 'user_id' }) + .upsert({ ...settingsToSave, company_id: companyId }, { onConflict: 'company_id' }) if (error) { logError('save failed', { message: error.message, step: targetStep, code: error.code, details: error.details }) @@ -199,36 +291,121 @@ function OnboardingPageContent() { setTicLookup(null) } + // Step 1: Create company + membership + user_preferences if no companyId yet + let activeCompanyId = companyId + + if (currentStep === 1 && !activeCompanyId) { + try { + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError) { + logError('auth.getUser() failed before company creation', { message: authError.message }) + } + if (!user) { + logError('company creation skipped: no user') + router.push('/login') + return + } + + // Atomically create company + owner membership + set active + const { data: newCompanyId, error: rpcError } = await supabase.rpc('create_company_with_owner', { + p_name: 'Mitt företag', + p_entity_type: stepData.entity_type, + }) + + if (rpcError || !newCompanyId) { + logError('company creation failed', { message: rpcError?.message, code: rpcError?.code }) + toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' }) + return + } + + activeCompanyId = newCompanyId + setCompanyId(activeCompanyId) + console.log(LOG, 'created company', activeCompanyId) + } catch (err) { + logError('company creation threw', { error: String(err) }) + Sentry.captureException(err) + toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' }) + return + } + } + + if (!activeCompanyId) { + logError('handleNext aborted: no companyId', { step: currentStep }) + return + } + const nextStep = currentStep + 1 - const success = await saveSettings(stepData, nextStep) + + // For step 1, companyId state may not be updated yet (React batching). + // Save settings directly with activeCompanyId to avoid the race condition. + const needsDirectSave = currentStep === 1 && !companyId + const success = needsDirectSave + ? await (async () => { + setIsSaving(true) + try { + const updatedSettings = { ...settings, ...stepData, onboarding_step: nextStep } + const { + id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua, + is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, + ...settingsToSave + } = updatedSettings as Record + + const { error } = await supabase + .from('company_settings') + .upsert({ ...settingsToSave, company_id: activeCompanyId }, { onConflict: 'company_id' }) + + if (error) { + logError('save failed', { message: error.message, step: nextStep, code: error.code }) + toast({ title: 'Fel', description: error.message || 'Kunde inte spara. Försök igen.', variant: 'destructive' }) + return false + } + + setSettings(updatedSettings) + return true + } catch (err) { + logError('saveSettings threw', { message: String(err), step: nextStep }) + Sentry.captureException(err) + return false + } finally { + setIsSaving(false) + } + })() + : await saveSettings(stepData, nextStep) if (!success) { logError('handleNext aborted: saveSettings failed', { step: currentStep }) return } + // After step 2 (company details): sync company name to companies table + if (currentStep === 2 && stepData.company_name && activeCompanyId) { + const { error: nameError } = await supabase + .from('companies') + .update({ name: stepData.company_name }) + .eq('id', activeCompanyId) + + if (nameError) { + logError('failed to sync company name to companies table', { + message: nameError.message, + code: nameError.code, + }) + } + } + // After step 1 (entity type selection): seed chart of accounts if (currentStep === 1 && stepData.entity_type) { try { - const { data: { user }, error: authError } = await supabase.auth.getUser() - if (authError) { - logError('auth.getUser() failed before seeding chart of accounts', { message: authError.message }) - } - if (user) { - const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', { - p_user_id: user.id, - p_entity_type: stepData.entity_type, + const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', { + p_company_id: activeCompanyId, + p_entity_type: stepData.entity_type, + }) + if (rpcError) { + logError('chart of accounts seeding failed', { + entity_type: stepData.entity_type, + message: rpcError.message, + code: rpcError.code, + details: rpcError.details, }) - if (rpcError) { - logError('chart of accounts seeding failed', { - entity_type: stepData.entity_type, - message: rpcError.message, - code: rpcError.code, - details: rpcError.details, - }) - } - } else { - logError('chart of accounts seeding skipped: no user') } } catch (err) { logError('chart of accounts seeding threw', { error: String(err) }) @@ -237,15 +414,8 @@ function OnboardingPageContent() { } // After step 3 (tax registration): create initial fiscal period - if (currentStep === 3) { + if (currentStep === 3 && companyId) { try { - const { data: { user }, error: authError } = await supabase.auth.getUser() - if (authError) { - logError('auth.getUser() failed before fiscal period creation', { message: authError.message }) - } - if (!user) { - logError('fiscal period creation skipped: no user') - } else { const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined const firstYearStart = stepData.first_year_start as string | undefined const firstYearEnd = stepData.first_year_end as string | undefined @@ -314,7 +484,7 @@ function OnboardingPageContent() { const { data: existingPeriods, error: fetchPeriodsError } = await supabase .from('fiscal_periods') .select('id') - .eq('user_id', user.id) + .eq('company_id', companyId) if (fetchPeriodsError) { logError('failed to fetch existing fiscal periods', { @@ -348,12 +518,12 @@ function OnboardingPageContent() { } const { error: upsertError } = await supabase.from('fiscal_periods').upsert({ - user_id: user.id, + company_id: companyId, name: periodName, period_start: startStr, period_end: endStr, }, { - onConflict: 'user_id,period_start,period_end', + onConflict: 'company_id,period_start,period_end', }) if (upsertError) { @@ -361,7 +531,6 @@ function OnboardingPageContent() { message: upsertError.message, startStr, endStr, code: upsertError.code, details: upsertError.details, }) } - } } catch (err) { logError('fiscal period creation threw', { error: String(err) }) Sentry.captureException(err) @@ -394,6 +563,8 @@ function OnboardingPageContent() { const handleBack = () => { if (currentStep > 1) { setCurrentStep(currentStep - 1) + } else if (isConsultant) { + setConsultantLanding(true) } } @@ -408,6 +579,46 @@ function OnboardingPageContent() { setCurrentStep(nextStep) } + const handleConsultantCreateTeam = async () => { + if (!teamName.trim()) { + toast({ title: 'Ange ett namn', description: 'Ditt team behöver ett namn.', variant: 'destructive' }) + return + } + + setIsSaving(true) + try { + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + router.push('/login') + return + } + + const { data: newTeamId, error: teamError } = await supabase.rpc('create_team_with_owner', { + p_name: teamName.trim(), + }) + + if (teamError || !newTeamId) { + logError('consultant team creation failed', { message: teamError?.message }) + toast({ title: 'Fel', description: 'Kunde inte skapa team. Försök igen.', variant: 'destructive' }) + return + } + + console.log(LOG, 'created team', newTeamId) + toast({ + title: 'Välkommen!', + description: 'Lägg till ditt första kundföretag för att komma igång.', + }) + // Hard navigation to exit the (onboarding) route group and trigger middleware + window.location.href = '/' + } catch (err) { + logError('consultant team creation threw', { error: String(err) }) + Sentry.captureException(err) + toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' }) + } finally { + setIsSaving(false) + } + } + if (isLoading) { return (
@@ -416,7 +627,11 @@ function OnboardingPageContent() { ) } - const stepInfo = STEP_INFO[currentStep - 1] + const isConsultant = mode === 'consultant' + const stepInfoArr = isConsultant ? STEP_INFO_CONSULTANT : STEP_INFO + const stepInfo = stepInfoArr[currentStep - 1] + const showRoleChoice = mode === 'choice' + const showConsultantLanding = isConsultant && consultantLanding const renderSteps = () => ( <> @@ -475,10 +690,150 @@ function OnboardingPageContent() { isSaving={isSaving} /> )} - ) + // ── Role Choice Screen (Step 0) ── + if (showRoleChoice) { + return ( +
+
+
+
+
+
+
+ Gnubok + gnubok +
+
+

+ Välkommen till gnubok +

+

+ Hur vill du använda gnubok? +

+
+
+
+
+
+
+ setMode('self')} + onChooseConsultant={() => { + setMode('consultant') + setConsultantLanding(true) + }} + /> +
+
+
+
+ ) + } + + // ── Consultant Landing Screen ── + if (showConsultantLanding) { + return ( +
+
+
+
+
+
+
+ Gnubok + gnubok +
+
+

+ Namnge ditt team +

+

+ Skapa ett team som samlar dig och dina kollegor. +

+
+
+
+
+
+
+
+
+ + setTeamName(e.target.value)} + disabled={isSaving} + className="h-11" + autoFocus + /> +

+ Du kan ändra namnet senare i inställningar. +

+
+
+ + + +
+
+
+
+ ) + } + + // ── Steps 1–4 (self or consultant adding company) ── return (
{/* ── Branded Header ── */} @@ -511,7 +866,7 @@ function OnboardingPageContent() {
{/* Step indicator — inline with logo row */}
- {STEP_INFO.map((_, i) => { + {stepInfoArr.map((_, i) => { const num = i + 1 return (
({ ), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + import { createClient } from '@/lib/supabase/server' import { hasAiConsent, grantAiConsent, revokeAiConsent } from '@/lib/extensions/ai-consent' import { GET, POST, DELETE } from '../route' @@ -91,7 +96,7 @@ describe('POST /api/ai-consent', () => { expect(status).toBe(200) expect(body.data.consented).toBe(true) - expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'receipt-ocr') + expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'receipt-ocr') }) it('returns 400 for non-AI extension', async () => { @@ -131,6 +136,6 @@ describe('DELETE /api/ai-consent', () => { expect(status).toBe(200) expect(body.data.consented).toBe(false) - expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'ai-chat') + expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'ai-chat') }) }) diff --git a/app/api/ai-consent/route.ts b/app/api/ai-consent/route.ts index abed5871..9b61f50e 100644 --- a/app/api/ai-consent/route.ts +++ b/app/api/ai-consent/route.ts @@ -7,6 +7,7 @@ import { revokeAiConsent, isAiExtension, } from '@/lib/extensions/ai-consent' +import { requireCompanyId } from '@/lib/company/context' export async function GET() { const supabase = await createClient() @@ -16,9 +17,11 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const statuses: Record = {} for (const ext of AI_EXTENSIONS) { - statuses[ext] = await hasAiConsent(supabase, user.id, ext) + statuses[ext] = await hasAiConsent(supabase, companyId, ext) } return NextResponse.json({ data: statuses }) @@ -32,6 +35,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const body = await request.json() const { extension_id } = body @@ -42,7 +47,7 @@ export async function POST(request: Request) { ) } - await grantAiConsent(supabase, user.id, extension_id) + await grantAiConsent(supabase, user.id, companyId, extension_id) return NextResponse.json({ data: { consented: true } }) } @@ -54,6 +59,8 @@ export async function DELETE(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const body = await request.json() const { extension_id } = body @@ -64,6 +71,6 @@ export async function DELETE(request: Request) { ) } - await revokeAiConsent(supabase, user.id, extension_id) + await revokeAiConsent(supabase, user.id, companyId, extension_id) return NextResponse.json({ data: { consented: false } }) } diff --git a/app/api/audit-trail/__tests__/route.test.ts b/app/api/audit-trail/__tests__/route.test.ts index 976e72d2..4fa9550b 100644 --- a/app/api/audit-trail/__tests__/route.test.ts +++ b/app/api/audit-trail/__tests__/route.test.ts @@ -9,6 +9,11 @@ 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' diff --git a/app/api/audit-trail/route.ts b/app/api/audit-trail/route.ts index 90fa10bb..cf5c4b3a 100644 --- a/app/api/audit-trail/route.ts +++ b/app/api/audit-trail/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' 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() @@ -11,6 +12,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const filters = { diff --git a/app/api/bookkeeping/account-totals/route.ts b/app/api/bookkeeping/account-totals/route.ts index c13749b8..3540ea4a 100644 --- a/app/api/bookkeeping/account-totals/route.ts +++ b/app/api/bookkeeping/account-totals/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -9,6 +10,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const from = searchParams.get('from') const to = searchParams.get('to') @@ -27,7 +30,7 @@ export async function GET(request: Request) { let entriesQuery = supabase .from('journal_entries') .select('id, entry_date') - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('status', 'posted') if (dateFrom) { diff --git a/app/api/bookkeeping/accounts/[number]/route.ts b/app/api/bookkeeping/accounts/[number]/route.ts index 94847881..8f81f364 100644 --- a/app/api/bookkeeping/accounts/[number]/route.ts +++ b/app/api/bookkeeping/accounts/[number]/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { UpdateAccountSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' export async function DELETE( request: Request, @@ -15,11 +16,13 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // 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('user_id', user.id) + .eq('company_id', companyId) .eq('account_number', number) .single() @@ -51,7 +54,7 @@ export async function DELETE( .from('chart_of_accounts') .delete() .eq('id', account.id) - .eq('user_id', user.id) + .eq('company_id', companyId) if (deleteError) { return NextResponse.json({ error: deleteError.message }, { status: 500 }) @@ -72,6 +75,8 @@ export async function PUT( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, UpdateAccountSchema) if (!validation.success) return validation.response const body = validation.data @@ -79,7 +84,7 @@ export async function PUT( const { data, error } = await supabase .from('chart_of_accounts') .update(body) - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('account_number', number) .select() .single() diff --git a/app/api/bookkeeping/accounts/activate/route.ts b/app/api/bookkeeping/accounts/activate/route.ts index ee8781f6..69d70ac3 100644 --- a/app/api/bookkeeping/accounts/activate/route.ts +++ b/app/api/bookkeeping/accounts/activate/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { getBASReference } from '@/lib/bookkeeping/bas-reference' +import { requireCompanyId } from '@/lib/company/context' /** * POST /api/bookkeeping/accounts/activate @@ -17,6 +18,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const body = await request.json() const accountNumbers: string[] = body.account_numbers @@ -28,7 +31,7 @@ export async function POST(request: Request) { const { data: existing } = await supabase .from('chart_of_accounts') .select('account_number') - .eq('user_id', user.id) + .eq('company_id', companyId) .in('account_number', accountNumbers) const existingNumbers = new Set((existing || []).map((a) => a.account_number)) @@ -42,6 +45,7 @@ export async function POST(request: Request) { return { user_id: user.id, + company_id: companyId, account_number: ref.account_number, account_name: ref.account_name, account_class: ref.account_class, diff --git a/app/api/bookkeeping/accounts/reference/route.ts b/app/api/bookkeeping/accounts/reference/route.ts index 1f01ff0b..e547a49b 100644 --- a/app/api/bookkeeping/accounts/reference/route.ts +++ b/app/api/bookkeeping/accounts/reference/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { NextResponse } from 'next/server' import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference' +import { requireCompanyId } from '@/lib/company/context' /** * GET /api/bookkeeping/accounts/reference @@ -17,13 +18,15 @@ export async function GET() { 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) 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('user_id', user.id) + .eq('company_id', companyId) .range(from, to) ) diff --git a/app/api/bookkeeping/accounts/route.ts b/app/api/bookkeeping/accounts/route.ts index 4f00e95f..3a174184 100644 --- a/app/api/bookkeeping/accounts/route.ts +++ b/app/api/bookkeeping/accounts/route.ts @@ -3,6 +3,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all' import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { CreateAccountSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -12,6 +13,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const accountClass = searchParams.get('class') const activeOnly = searchParams.get('active') !== 'false' @@ -21,7 +24,7 @@ export async function GET(request: Request) { let query = supabase .from('chart_of_accounts') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .order('sort_order') if (activeOnly) { @@ -53,10 +56,13 @@ export async function POST(request: Request) { 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]), diff --git a/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts index 2b39890e..b9cff5ec 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/close/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { closePeriod } from '@/lib/core/bookkeeping/period-service' +import { requireCompanyId } from '@/lib/company/context' export async function POST( request: Request, @@ -14,8 +15,10 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + try { - const period = await closePeriod(supabase, user.id, id) + const period = await closePeriod(supabase, companyId, user.id, id) return NextResponse.json({ data: period }) } catch (err) { return NextResponse.json( diff --git a/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts index 7b98895c..71b0b98a 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts @@ -4,6 +4,7 @@ import { previewCurrencyRevaluation, executeCurrencyRevaluation, } from '@/lib/bookkeeping/currency-revaluation' +import { requireCompanyId } from '@/lib/company/context' /** * GET: Preview currency revaluation for a fiscal period @@ -20,20 +21,22 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + try { // Fetch period to get closing date const { data: period, error: periodError } = await supabase .from('fiscal_periods') .select('*') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (periodError || !period) { return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 }) } - const preview = await previewCurrencyRevaluation(supabase, user.id, period.period_end) + const preview = await previewCurrencyRevaluation(supabase, companyId, period.period_end) return NextResponse.json({ data: preview }) } catch (err) { return NextResponse.json( @@ -58,13 +61,15 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + try { // Fetch period to get closing date const { data: period, error: periodError } = await supabase .from('fiscal_periods') .select('*') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (periodError || !period) { @@ -75,7 +80,7 @@ export async function POST( return NextResponse.json({ error: 'Period is already closed' }, { status: 400 }) } - const result = await executeCurrencyRevaluation(supabase, user.id, period.period_end, id) + const result = await executeCurrencyRevaluation(supabase, companyId, period.period_end, id, user.id) if (!result) { return NextResponse.json({ data: null, message: 'No foreign currency items to revalue' }) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts index 80a0eb71..3512ed48 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { lockPeriod } from '@/lib/core/bookkeeping/period-service' +import { requireCompanyId } from '@/lib/company/context' export async function POST( request: Request, @@ -14,8 +15,10 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + try { - const period = await lockPeriod(supabase, user.id, id) + const period = await lockPeriod(supabase, companyId, user.id, id) return NextResponse.json({ data: period }) } catch (err) { return NextResponse.json( diff --git a/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts index 0f6e47c5..8f6660a7 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts @@ -5,6 +5,7 @@ import { previewYearEndClosing, executeYearEndClosing, } from '@/lib/core/bookkeeping/year-end-service' +import { requireCompanyId } from '@/lib/company/context' /** * GET: Validate readiness and preview year-end closing @@ -21,10 +22,12 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + try { const [validation, preview] = await Promise.all([ - validateYearEndReadiness(supabase, user.id, id), - previewYearEndClosing(supabase, user.id, id), + validateYearEndReadiness(supabase, companyId, user.id, id), + previewYearEndClosing(supabase, companyId, user.id, id), ]) return NextResponse.json({ data: { validation, preview } }) @@ -51,8 +54,10 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + try { - const result = await executeYearEndClosing(supabase, user.id, id) + const result = await executeYearEndClosing(supabase, companyId, user.id, id) return NextResponse.json({ data: result }) } catch (err) { return NextResponse.json( diff --git a/app/api/bookkeeping/fiscal-periods/route.ts b/app/api/bookkeeping/fiscal-periods/route.ts index 90a69b6c..1ae213a7 100644 --- a/app/api/bookkeeping/fiscal-periods/route.ts +++ b/app/api/bookkeeping/fiscal-periods/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' 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' export async function GET() { const supabase = await createClient() @@ -12,10 +13,12 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase .from('fiscal_periods') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .order('period_start', { ascending: false }) if (error) { @@ -33,6 +36,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, CreateFiscalPeriodSchema) if (!validation.success) return validation.response const body = validation.data @@ -47,7 +52,7 @@ export async function POST(request: Request) { const { data: overlapping } = await supabase .from('fiscal_periods') .select('id, name') - .eq('user_id', user.id) + .eq('company_id', companyId) .lte('period_start', body.period_end) .gte('period_end', body.period_start) .limit(1) @@ -63,6 +68,7 @@ export async function POST(request: Request) { .from('fiscal_periods') .insert({ user_id: user.id, + company_id: companyId, name: body.name, period_start: body.period_start, period_end: body.period_end, diff --git a/app/api/bookkeeping/journal-entries/[id]/chain/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/chain/__tests__/route.test.ts index bbc619bb..93924848 100644 --- a/app/api/bookkeeping/journal-entries/[id]/chain/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/chain/__tests__/route.test.ts @@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: () => mockCreateClient(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + import { GET } from '../route' function buildMockSupabase({ diff --git a/app/api/bookkeeping/journal-entries/[id]/chain/route.ts b/app/api/bookkeeping/journal-entries/[id]/chain/route.ts index 9a1106af..21001c9f 100644 --- a/app/api/bookkeeping/journal-entries/[id]/chain/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/chain/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' export async function GET( request: Request, @@ -13,12 +14,14 @@ export async function GET( 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 .from('journal_entries') .select('*, lines:journal_entry_lines(*)') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (error || !entry) { @@ -38,7 +41,7 @@ export async function GET( const { data: referencing } = await supabase .from('journal_entries') .select('id') - .eq('user_id', user.id) + .eq('company_id', companyId) .or(`reverses_id.eq.${id},reversed_by_id.eq.${id},correction_of_id.eq.${id}`) if (referencing) { @@ -57,7 +60,7 @@ export async function GET( const { data: batchEntries } = await supabase .from('journal_entries') .select('id, reverses_id, reversed_by_id, correction_of_id') - .eq('user_id', user.id) + .eq('company_id', companyId) .in('id', batch) if (!batchEntries) continue @@ -81,7 +84,7 @@ export async function GET( const { data: refs } = await supabase .from('journal_entries') .select('id') - .eq('user_id', user.id) + .eq('company_id', companyId) .or(batchOr) if (refs) { @@ -99,7 +102,7 @@ export async function GET( const { data: chainEntries } = await supabase .from('journal_entries') .select('*, lines:journal_entry_lines(*)') - .eq('user_id', user.id) + .eq('company_id', companyId) .in('id', chainIds) .order('created_at', { ascending: true }) diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts index 09dff667..d28af8a0 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts @@ -15,6 +15,10 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + const mockCorrectEntry = vi.fn() vi.mock('@/lib/core/bookkeeping/storno-service', () => ({ correctEntry: (...args: unknown[]) => mockCorrectEntry(...args), @@ -100,7 +104,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => { expect(status).toBe(200) expect(body.data.reversal).toEqual(reversal) expect(body.data.corrected).toEqual(corrected) - expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'entry-1', lines) + expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1', lines) }) it('returns 400 when correctEntry throws for unbalanced lines', async () => { diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts index 2e58eb13..79823ff1 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts @@ -4,6 +4,7 @@ import { correctEntry } from '@/lib/core/bookkeeping/storno-service' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CorrectJournalEntrySchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -19,12 +20,14 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, CorrectJournalEntrySchema) if (!validation.success) return validation.response const body = validation.data try { - const result = await correctEntry(supabase, user.id, id, body.lines) + const result = await correctEntry(supabase, companyId, user.id, id, body.lines) return NextResponse.json({ data: result }) } catch (err) { return NextResponse.json( diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts index e1a9bd03..2c61ddfc 100644 --- a/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts @@ -16,6 +16,10 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + const mockReverseEntry = vi.fn() vi.mock('@/lib/bookkeeping/engine', () => ({ reverseEntry: (...args: unknown[]) => mockReverseEntry(...args), @@ -64,7 +68,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => { expect(status).toBe(200) expect(body.data).toEqual(reversalEntry) - expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'entry-1') + expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1') }) it('returns 400 when engine throws', async () => { diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts index eca91f23..b243739b 100644 --- a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { reverseEntry } from '@/lib/bookkeeping/engine' import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -17,8 +18,10 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + try { - const reversalEntry = await reverseEntry(supabase, user.id, id) + const reversalEntry = await reverseEntry(supabase, companyId, user.id, id) return NextResponse.json({ data: reversalEntry }) } catch (err) { return NextResponse.json( diff --git a/app/api/bookkeeping/journal-entries/[id]/route.ts b/app/api/bookkeeping/journal-entries/[id]/route.ts index 5a0b9282..5b579465 100644 --- a/app/api/bookkeeping/journal-entries/[id]/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' export async function GET( request: Request, @@ -13,11 +14,13 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase .from('journal_entries') .select('*, lines:journal_entry_lines(*)') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (error) { diff --git a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts index 1a0a34c7..9c8ed521 100644 --- a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts @@ -16,6 +16,10 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + const mockCreateJournalEntry = vi.fn() vi.mock('@/lib/bookkeeping/engine', () => ({ createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), @@ -137,7 +141,7 @@ describe('POST /api/bookkeeping/journal-entries', () => { expect(status).toBe(200) expect(body.data).toEqual(entry) - expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', input) + expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', input) }) it('returns 400 when engine throws', async () => { diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index 9bdaae1b..7b9747c0 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -4,6 +4,7 @@ import { createJournalEntry } from '@/lib/bookkeeping/engine' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CreateJournalEntrySchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -15,6 +16,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') const status = searchParams.get('status') @@ -29,7 +32,7 @@ export async function GET(request: Request) { let query = supabase .from('journal_entries') .select('*, lines:journal_entry_lines(*)', { count: 'exact' }) - .eq('user_id', user.id) + .eq('company_id', companyId) if (sortDate === 'asc' || sortDate === 'desc') { query = query @@ -76,12 +79,14 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, CreateJournalEntrySchema) if (!validation.success) return validation.response const body = validation.data try { - const entry = await createJournalEntry(supabase, user.id, body) + const entry = await createJournalEntry(supabase, companyId, user.id, body) return NextResponse.json({ data: entry }) } catch (err) { return NextResponse.json( diff --git a/app/api/bookkeeping/mapping-rules/evaluate/route.ts b/app/api/bookkeeping/mapping-rules/evaluate/route.ts index 78f9bb65..6719e602 100644 --- a/app/api/bookkeeping/mapping-rules/evaluate/route.ts +++ b/app/api/bookkeeping/mapping-rules/evaluate/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' 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) { @@ -13,6 +14,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, EvaluateMappingRulesSchema) if (!validation.success) return validation.response const body = validation.data @@ -25,7 +28,7 @@ export async function POST(request: Request) { .from('transactions') .select('*') .eq('id', body.transaction_id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (error || !data) { @@ -38,7 +41,7 @@ export async function POST(request: Request) { } try { - const result = await evaluateMappingRules(supabase, user.id, transaction) + const result = await evaluateMappingRules(supabase, companyId, transaction) return NextResponse.json({ data: result }) } catch (err) { return NextResponse.json( diff --git a/app/api/bookkeeping/mapping-rules/route.ts b/app/api/bookkeeping/mapping-rules/route.ts index 80624a6d..5d44ed2d 100644 --- a/app/api/bookkeeping/mapping-rules/route.ts +++ b/app/api/bookkeeping/mapping-rules/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { CreateMappingRuleSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' export async function GET() { const supabase = await createClient() @@ -11,10 +12,12 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase .from('mapping_rules') .select('*') - .or(`user_id.eq.${user.id},user_id.is.null`) + .or(`company_id.eq.${companyId},company_id.is.null`) .eq('is_active', true) .order('priority') @@ -33,6 +36,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const result = await validateBody(request, CreateMappingRuleSchema) if (!result.success) return result.response const body = result.data @@ -41,6 +46,7 @@ export async function POST(request: Request) { .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, diff --git a/app/api/calendar/feed/[token]/route.ts b/app/api/calendar/feed/[token]/route.ts index 46e60c64..e1089491 100644 --- a/app/api/calendar/feed/[token]/route.ts +++ b/app/api/calendar/feed/[token]/route.ts @@ -101,7 +101,7 @@ export async function GET( ? supabase .from('deadlines') .select('*') - .eq('user_id', feed.user_id) + .eq('company_id', feed.company_id) .gte('due_date', startStr) .lte('due_date', endStr) .order('due_date') @@ -112,7 +112,7 @@ export async function GET( ? supabase .from('invoices') .select('*, customer:customers(*)') - .eq('user_id', feed.user_id) + .eq('company_id', feed.company_id) .gte('due_date', startStr) .lte('due_date', endStr) .order('due_date') diff --git a/app/api/calendar/feed/route.ts b/app/api/calendar/feed/route.ts index 644edbe0..41b8ab55 100644 --- a/app/api/calendar/feed/route.ts +++ b/app/api/calendar/feed/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' import type { UpdateCalendarFeedInput } from '@/types' /** @@ -15,10 +16,12 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data: feed, error } = await supabase .from('calendar_feeds') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (error && error.code !== 'PGRST116') { @@ -57,11 +60,13 @@ export async function POST() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Check if feed already exists const { data: existingFeed } = await supabase .from('calendar_feeds') .select('id') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (existingFeed) { @@ -76,6 +81,7 @@ export async function POST() { .from('calendar_feeds') .insert({ user_id: user.id, + company_id: companyId, is_active: true, include_tax_deadlines: true, include_invoices: true, @@ -111,12 +117,14 @@ export async function PUT(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + 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('user_id', user.id) + .eq('company_id', companyId) .select() .single() @@ -148,6 +156,8 @@ export async function DELETE() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + 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') @@ -156,7 +166,7 @@ export async function DELETE() { access_count: 0, last_accessed_at: null, }) - .eq('user_id', user.id) + .eq('company_id', companyId) .select() .single() diff --git a/app/api/company/members/[id]/route.ts b/app/api/company/members/[id]/route.ts new file mode 100644 index 00000000..fdb03257 --- /dev/null +++ b/app/api/company/members/[id]/route.ts @@ -0,0 +1,86 @@ +import { createClient, createServiceClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' + +/** + * DELETE /api/company/members/[id] + * Remove a member from the current company. + * 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 companyId = await requireCompanyId(supabase, user.id) + const { id: memberId } = await params + 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 }) + } + + // Look up the member (source column may not exist if migration not yet applied) + let member: { id: string; user_id: string; role: string; source?: string } | null = null + + const { data: memberWithSource } = await serviceClient + .from('company_members') + .select('id, user_id, role, source') + .eq('id', memberId) + .eq('company_id', companyId) + .single() + + if (memberWithSource) { + member = memberWithSource + } else { + const { data: memberFallback } = await serviceClient + .from('company_members') + .select('id, user_id, role') + .eq('id', memberId) + .eq('company_id', companyId) + .single() + member = memberFallback ? { ...memberFallback, source: 'direct' } : null + } + + if (!member) { + return NextResponse.json({ error: 'Medlem hittades inte.' }, { status: 404 }) + } + + if (member.user_id === user.id) { + return NextResponse.json({ error: 'Du kan inte ta bort dig själv.' }, { status: 400 }) + } + + if (member.role === 'owner') { + return NextResponse.json({ error: 'Ägaren kan inte tas bort.' }, { status: 400 }) + } + + if (member.source === 'team') { + return NextResponse.json({ + error: 'Denna medlem läggs till via teamet. Ta bort från teamet istället.', + }, { status: 400 }) + } + + const { error } = await serviceClient + .from('company_members') + .delete() + .eq('id', memberId) + .eq('company_id', companyId) + + if (error) { + return NextResponse.json({ error: 'Kunde inte ta bort medlem.' }, { status: 500 }) + } + + return NextResponse.json({ data: { removed: memberId } }) +} diff --git a/app/api/company/members/invite/[id]/route.ts b/app/api/company/members/invite/[id]/route.ts new file mode 100644 index 00000000..d58691ff --- /dev/null +++ b/app/api/company/members/invite/[id]/route.ts @@ -0,0 +1,62 @@ +import { createClient, createServiceClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/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 companyId = await requireCompanyId(supabase, user.id) + const { id: inviteId } = await params + 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 }) + } + + // Look up the invitation + const { data: invitation } = await serviceClient + .from('company_invitations') + .select('id, company_id, status') + .eq('id', inviteId) + .eq('company_id', companyId) + .single() + + if (!invitation) { + return NextResponse.json({ error: 'Inbjudan hittades inte.' }, { status: 404 }) + } + + if (invitation.status !== 'pending') { + return NextResponse.json({ error: 'Inbjudan är inte väntande.' }, { status: 400 }) + } + + // Revoke the invitation + const { error } = await serviceClient + .from('company_invitations') + .update({ status: 'revoked' }) + .eq('id', inviteId) + .eq('company_id', companyId) + + if (error) { + return NextResponse.json({ error: 'Kunde inte återkalla inbjudan.' }, { status: 500 }) + } + + return NextResponse.json({ data: { revoked: inviteId } }) +} diff --git a/app/api/company/members/invite/route.ts b/app/api/company/members/invite/route.ts new file mode 100644 index 00000000..a851383f --- /dev/null +++ b/app/api/company/members/invite/route.ts @@ -0,0 +1,158 @@ +import { createClient, createServiceClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' +import { generateInviteToken, getInviteExpiry } from '@/lib/auth/invite-tokens' +import { getEmailService } from '@/lib/email/service' +import { + generateInviteEmailSubject, + generateInviteEmailHtml, + generateInviteEmailText, +} from '@/lib/email/invite-templates' + +/** + * 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 }) + + 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, + } + + const result = await emailService.sendEmail({ + to: email, + subject: generateInviteEmailSubject(emailData), + html: generateInviteEmailHtml(emailData), + text: generateInviteEmailText(emailData), + }) + + if (!result.success) { + console.error('[company/members/invite] email send failed:', result.error) + } + } + + // 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 }) }, + }) +} diff --git a/app/api/company/members/route.ts b/app/api/company/members/route.ts new file mode 100644 index 00000000..946ee84f --- /dev/null +++ b/app/api/company/members/route.ts @@ -0,0 +1,80 @@ +import { createClient, createServiceClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' + +/** + * GET /api/company/members + * Returns members and pending invitations for the current company. + */ +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) + const serviceClient = await createServiceClient() + + // Fetch members (source column may not exist if migration not yet applied) + let members: { id: string; user_id: string; role: string; source?: string; joined_at: string }[] | null = null + + const { data: membersWithSource, error: membersError } = await serviceClient + .from('company_members') + .select('id, user_id, role, source, joined_at') + .eq('company_id', companyId) + .order('joined_at', { ascending: true }) + + if (membersError) { + // Fallback: query without source column + const { data: membersFallback, error: fallbackError } = await serviceClient + .from('company_members') + .select('id, user_id, role, joined_at') + .eq('company_id', companyId) + .order('joined_at', { ascending: true }) + + if (fallbackError) { + return NextResponse.json({ error: 'Kunde inte hämta medlemmar.' }, { status: 500 }) + } + members = (membersFallback || []).map((m) => ({ ...m, source: 'direct' as const })) + } else { + members = membersWithSource + } + + // Fetch emails from profiles + const userIds = (members || []).map((m) => m.user_id) + const { data: profiles } = userIds.length > 0 + ? await serviceClient + .from('profiles') + .select('id, email') + .in('id', userIds) + : { data: [] } + + const emailMap = new Map((profiles || []).map((p) => [p.id, p.email])) + + // Fetch pending company invitations + const { data: invitations } = await serviceClient + .from('company_invitations') + .select('id, email, role, status, expires_at, created_at') + .eq('company_id', companyId) + .eq('status', 'pending') + .order('created_at', { ascending: false }) + + // Get current user's role + const currentMember = members?.find((m) => m.user_id === user.id) + const canInvite = currentMember?.role === 'owner' || currentMember?.role === 'admin' + + return NextResponse.json({ + data: { + members: (members || []).map((m) => ({ + id: m.id, + user_id: m.user_id, + email: emailMap.get(m.user_id) || '', + role: m.role, + source: m.source, + joined_at: m.joined_at, + is_current_user: m.user_id === user.id, + })), + invitations: invitations || [], + canInvite, + }, + }) +} diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts index 04511729..4a9a7974 100644 --- a/app/api/customers/[id]/route.ts +++ b/app/api/customers/[id]/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { UpdateCustomerSchema } from '@/lib/api/schemas' import { validateVatNumber } from '@/lib/vat/vies-client' +import { requireCompanyId } from '@/lib/company/context' import { createLogger } from '@/lib/logger' const log = createLogger('api/customers/[id]') @@ -22,11 +23,13 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase .from('customers') .select('*') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (error) { @@ -41,7 +44,7 @@ export async function GET( .from('invoices') .select('id, invoice_number, invoice_date, due_date, status, total, currency') .eq('customer_id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .order('invoice_date', { ascending: false }) return NextResponse.json({ @@ -67,6 +70,8 @@ export async function PATCH( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const result = await validateBody(request, UpdateCustomerSchema) if (!result.success) return result.response const body = result.data @@ -91,7 +96,7 @@ export async function PATCH( .from('customers') .update(updateData) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .select() .single() @@ -113,7 +118,7 @@ export async function PATCH( vat_number_validated_at: new Date().toISOString(), }) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) data.vat_number_validated = true data.vat_number_validated_at = new Date().toISOString() @@ -125,7 +130,7 @@ export async function PATCH( vat_number_validated_at: null, }) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) data.vat_number_validated = false data.vat_number_validated_at = null @@ -139,7 +144,7 @@ export async function PATCH( vat_number_validated_at: null, }) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) data.vat_number_validated = false data.vat_number_validated_at = null @@ -167,11 +172,13 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { error } = await supabase .from('customers') .delete() .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) if (error) { return NextResponse.json({ error: error.message }, { status: 500 }) diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts index 2eb12d97..ed8b6e80 100644 --- a/app/api/customers/route.ts +++ b/app/api/customers/route.ts @@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CreateCustomerSchema } from '@/lib/api/schemas' import { validateVatNumber } from '@/lib/vat/vies-client' +import { requireCompanyId } from '@/lib/company/context' import { createLogger } from '@/lib/logger' import type { Customer } from '@/types' @@ -21,10 +22,12 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase .from('customers') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .order('name', { ascending: true }) if (error) { @@ -43,6 +46,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const result = await validateBody(request, CreateCustomerSchema) if (!result.success) return result.response const body = result.data @@ -51,6 +56,7 @@ export async function POST(request: Request) { .from('customers') .insert({ user_id: user.id, + company_id: companyId, name: body.name, customer_type: body.customer_type, email: body.email, @@ -84,7 +90,7 @@ export async function POST(request: Request) { vat_number_validated_at: new Date().toISOString(), }) .eq('id', data.id) - .eq('user_id', user.id) + .eq('company_id', companyId) data.vat_number_validated = true data.vat_number_validated_at = new Date().toISOString() @@ -96,7 +102,7 @@ export async function POST(request: Request) { await eventBus.emit({ type: 'customer.created', - payload: { customer: data as Customer, userId: user.id }, + payload: { customer: data as Customer, companyId, userId: user.id }, }) return NextResponse.json({ data }) diff --git a/app/api/deadlines/[id]/complete/route.ts b/app/api/deadlines/[id]/complete/route.ts index 5da624d9..06a7f49f 100644 --- a/app/api/deadlines/[id]/complete/route.ts +++ b/app/api/deadlines/[id]/complete/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' /** * POST /api/deadlines/[id]/complete @@ -20,12 +21,14 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + 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('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchError) { @@ -44,7 +47,7 @@ export async function POST( completed_at: newCompletedState ? new Date().toISOString() : null, }) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .select('*, customer:customers(id, name)') .single() diff --git a/app/api/deadlines/[id]/route.ts b/app/api/deadlines/[id]/route.ts index 9d9c21ec..e21a1c99 100644 --- a/app/api/deadlines/[id]/route.ts +++ b/app/api/deadlines/[id]/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' import type { CreateDeadlineInput } from '@/types' /** @@ -21,11 +22,13 @@ export async function GET( 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('user_id', user.id) + .eq('company_id', companyId) .single() if (error) { @@ -57,6 +60,8 @@ export async function PUT( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const body: Partial = await request.json() // First, get existing deadline to verify ownership @@ -64,7 +69,7 @@ export async function PUT( .from('deadlines') .select('*') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchError) { @@ -89,7 +94,7 @@ export async function PUT( .from('deadlines') .update(updateData) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .select('*, customer:customers(id, name)') .single() @@ -119,11 +124,13 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { error } = await supabase .from('deadlines') .delete() .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) if (error) { return NextResponse.json({ error: error.message }, { status: 500 }) diff --git a/app/api/deadlines/[id]/status/route.ts b/app/api/deadlines/[id]/status/route.ts index a008ddfb..514a1321 100644 --- a/app/api/deadlines/[id]/status/route.ts +++ b/app/api/deadlines/[id]/status/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine' +import { requireCompanyId } from '@/lib/company/context' import type { DeadlineStatus } from '@/types' /** @@ -19,6 +20,8 @@ export async function PATCH( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params const body = await request.json() @@ -41,7 +44,7 @@ export async function PATCH( return NextResponse.json({ error: 'Invalid status' }, { status: 400 }) } - const result = await updateDeadlineStatus(supabase, id, user.id, newStatus) + const result = await updateDeadlineStatus(supabase, id, companyId, newStatus) if (!result.success) { return NextResponse.json({ error: result.error }, { status: 400 }) @@ -66,13 +69,15 @@ export async function GET( 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('user_id', user.id) + .eq('company_id', companyId) .single() if (error || !deadline) { diff --git a/app/api/deadlines/route.ts b/app/api/deadlines/route.ts index d15512ad..4faae8a7 100644 --- a/app/api/deadlines/route.ts +++ b/app/api/deadlines/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { CreateDeadlineSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' /** * GET /api/deadlines @@ -23,6 +24,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Parse query params const { searchParams } = new URL(request.url) const status = searchParams.get('status') || 'all' @@ -34,7 +37,7 @@ export async function GET(request: Request) { let query = supabase .from('deadlines') .select('*, customer:customers(id, name)') - .eq('user_id', user.id) + .eq('company_id', companyId) // Apply filters if (status === 'pending') { @@ -79,6 +82,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, CreateDeadlineSchema) if (!validation.success) return validation.response const body = validation.data @@ -88,6 +93,7 @@ export async function POST(request: Request) { .from('deadlines') .insert({ user_id: user.id, + company_id: companyId, title: body.title, due_date: body.due_date, due_time: body.due_time || null, diff --git a/app/api/documents/[id]/link/route.ts b/app/api/documents/[id]/link/route.ts index 3bd1ab59..802e688a 100644 --- a/app/api/documents/[id]/link/route.ts +++ b/app/api/documents/[id]/link/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { linkToJournalEntry } from '@/lib/core/documents/document-service' +import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -25,6 +26,8 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params try { @@ -39,7 +42,7 @@ export async function POST( const document = await linkToJournalEntry( supabase, - user.id, + companyId, id, body.journal_entry_id, body.journal_entry_line_id diff --git a/app/api/documents/[id]/route.ts b/app/api/documents/[id]/route.ts index 54c9a1fe..aa0cdd74 100644 --- a/app/api/documents/[id]/route.ts +++ b/app/api/documents/[id]/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -20,6 +21,8 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params // Fetch document record @@ -27,7 +30,7 @@ export async function GET( .from('document_attachments') .select('*') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (docError || !doc) { diff --git a/app/api/documents/[id]/verify/route.ts b/app/api/documents/[id]/verify/route.ts index 33d499e2..e72a5932 100644 --- a/app/api/documents/[id]/verify/route.ts +++ b/app/api/documents/[id]/verify/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { verifyIntegrity } from '@/lib/core/documents/document-service' +import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -21,10 +22,12 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params try { - const result = await verifyIntegrity(supabase, user.id, id) + const result = await verifyIntegrity(supabase, companyId, id) return NextResponse.json({ data: result }) } catch (error) { diff --git a/app/api/documents/[id]/versions/route.ts b/app/api/documents/[id]/versions/route.ts index 5ec6b8e5..2459d087 100644 --- a/app/api/documents/[id]/versions/route.ts +++ b/app/api/documents/[id]/versions/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { createNewVersion } from '@/lib/core/documents/document-service' +import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -24,6 +25,8 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params try { @@ -68,14 +71,16 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params - // First, check if the document belongs to the user + // First, check if the document belongs to the company const { data: doc, error: docError } = await supabase .from('document_attachments') .select('id, original_id') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (docError || !doc) { @@ -89,7 +94,7 @@ export async function GET( const { data: versions, error: versionsError } = await supabase .from('document_attachments') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .or(`id.eq.${rootId},original_id.eq.${rootId}`) .order('version', { ascending: true }) diff --git a/app/api/documents/counts/route.ts b/app/api/documents/counts/route.ts index 2cf7e718..3e040984 100644 --- a/app/api/documents/counts/route.ts +++ b/app/api/documents/counts/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' /** * GET /api/documents/counts?journal_entry_ids=id1,id2,... @@ -15,6 +16,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const idsParam = searchParams.get('journal_entry_ids') @@ -35,7 +38,7 @@ export async function GET(request: Request) { const { data, error } = await supabase .from('document_attachments') .select('journal_entry_id') - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('is_current_version', true) .in('journal_entry_id', ids) diff --git a/app/api/documents/match-sweep/route.ts b/app/api/documents/match-sweep/route.ts index c1562317..59b884a8 100644 --- a/app/api/documents/match-sweep/route.ts +++ b/app/api/documents/match-sweep/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { runDocumentMatchingSweep } from '@/lib/documents/batch-match' +import { requireCompanyId } from '@/lib/company/context' export async function POST(request: Request) { const supabase = await createClient() @@ -10,6 +11,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Optional: pass specific inbox item IDs to match let inboxItemIds: string[] | undefined try { @@ -22,7 +25,7 @@ export async function POST(request: Request) { } try { - const result = await runDocumentMatchingSweep(supabase, user.id, inboxItemIds) + const result = await runDocumentMatchingSweep(supabase, companyId, inboxItemIds) return NextResponse.json({ data: result }) } catch (error) { console.error('[match-sweep] Failed:', error) diff --git a/app/api/documents/route.ts b/app/api/documents/route.ts index 7ed9e751..d30ff262 100644 --- a/app/api/documents/route.ts +++ b/app/api/documents/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { uploadDocument } from '@/lib/core/documents/document-service' +import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -24,6 +25,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + try { const formData = await request.formData() const file = formData.get('file') as File | null @@ -38,7 +41,7 @@ export async function POST(request: Request) { const buffer = await file.arrayBuffer() - const document = await uploadDocument(supabase, user.id, { + const document = await uploadDocument(supabase, user.id, companyId, { name: file.name, buffer, type: file.type, @@ -77,6 +80,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const journalEntryId = searchParams.get('journal_entry_id') const currentOnly = searchParams.get('current_only') !== 'false' @@ -86,7 +91,7 @@ export async function GET(request: Request) { let query = supabase .from('document_attachments') .select('*', { count: 'exact' }) - .eq('user_id', user.id) + .eq('company_id', companyId) .order('created_at', { ascending: false }) .range(offset, offset + limit - 1) diff --git a/app/api/events/__tests__/route.test.ts b/app/api/events/__tests__/route.test.ts index 2df60e50..a56ef766 100644 --- a/app/api/events/__tests__/route.test.ts +++ b/app/api/events/__tests__/route.test.ts @@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + // Mock API key auth const mockValidateApiKey = vi.fn() const mockExtractBearerToken = vi.fn() diff --git a/app/api/events/route.ts b/app/api/events/route.ts index 9d1410d5..76b996ae 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' import { validateQuery } from '@/lib/api/validate' import { EventsQuerySchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' import type { SupabaseClient } from '@supabase/supabase-js' /** @@ -40,6 +41,8 @@ export async function GET(request: Request) { userId = user.id } + const companyId = await requireCompanyId(supabase, userId) + // Validate query params const result = validateQuery(request, EventsQuerySchema) if (!result.success) return result.response @@ -49,7 +52,7 @@ export async function GET(request: Request) { let query = supabase .from('event_log') .select('sequence, event_type, entity_id, data, created_at') - .eq('user_id', userId) + .eq('company_id', companyId) .order('sequence', { ascending: true }) .limit(limit) diff --git a/app/api/extensions/[sector]/[slug]/data/route.ts b/app/api/extensions/[sector]/[slug]/data/route.ts index e0b0cb95..106ac12a 100644 --- a/app/api/extensions/[sector]/[slug]/data/route.ts +++ b/app/api/extensions/[sector]/[slug]/data/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' export async function GET( request: Request, @@ -13,6 +14,8 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const extensionId = `${sector}/${slug}` const { searchParams } = new URL(request.url) @@ -21,7 +24,7 @@ export async function GET( let query = supabase .from('extension_data') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('extension_id', extensionId) const prefix = searchParams.get('prefix') @@ -53,6 +56,8 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const body = await request.json() const { key, value } = body @@ -67,6 +72,7 @@ export async function POST( .upsert( { user_id: user.id, + company_id: companyId, extension_id: extensionId, key, value, @@ -95,6 +101,8 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const key = searchParams.get('key') @@ -107,7 +115,7 @@ export async function DELETE( const { error } = await supabase .from('extension_data') .delete() - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('extension_id', extensionId) .eq('key', key) diff --git a/app/api/extensions/[sector]/[slug]/settings/route.ts b/app/api/extensions/[sector]/[slug]/settings/route.ts index 5339166e..aa2df852 100644 --- a/app/api/extensions/[sector]/[slug]/settings/route.ts +++ b/app/api/extensions/[sector]/[slug]/settings/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' export async function GET( _request: Request, @@ -13,12 +14,14 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const extensionId = `${sector}/${slug}` const { data } = await supabase .from('extension_data') .select('value') - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('extension_id', extensionId) .eq('key', 'settings') .single() @@ -38,6 +41,8 @@ export async function PATCH( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const body = await request.json() const extensionId = `${sector}/${slug}` @@ -45,7 +50,7 @@ export async function PATCH( const { data: existing } = await supabase .from('extension_data') .select('value') - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('extension_id', extensionId) .eq('key', 'settings') .single() @@ -57,6 +62,7 @@ export async function PATCH( .upsert( { user_id: user.id, + company_id: companyId, extension_id: extensionId, key: 'settings', value: mergedSettings, diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index 4f35435c..47dbace5 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -81,7 +81,7 @@ export async function GET(request: Request) { // Look up pending connection by oauth_state (CSRF-safe) const { data: pendingConnection, error: findError } = await supabase .from('bank_connections') - .select('id, user_id') + .select('id, user_id, company_id') .eq('oauth_state', state) .eq('status', 'pending') .single() @@ -98,6 +98,7 @@ export async function GET(request: Request) { } const userId = pendingConnection.user_id + const companyId = pendingConnection.company_id console.log('[enable-banking] Exchanging code for session', { connectionId: pendingConnection.id, @@ -166,7 +167,7 @@ export async function GET(request: Request) { const { data: userSettings } = await supabase .from('company_settings') .select('onboarding_complete') - .eq('user_id', userId) + .eq('company_id', companyId) .single() const redirectTarget = userSettings?.onboarding_complete diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 81df6ee7..898781dc 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -143,7 +143,7 @@ export async function GET(request: Request) { const { data: sieOverlap } = await supabase .from('sie_imports') .select('id') - .eq('user_id', connection.user_id) + .eq('company_id', connection.company_id) .eq('status', 'completed') .gte('fiscal_year_end', fromDate) .limit(1) @@ -156,6 +156,7 @@ export async function GET(request: Request) { const syncResults = await Promise.all( accounts.map(account => syncAccountTransactions( supabase, + connection.company_id, connection.user_id, connection.id, account, @@ -284,7 +285,7 @@ async function sendConsentExpiryNotification( const { data: companySettings } = await supabase .from('company_settings') .select('company_name') - .eq('user_id', userId) + .eq('company_id', connection.company_id) .single() const emailData = { diff --git a/app/api/extensions/ext/[...path]/__tests__/route.test.ts b/app/api/extensions/ext/[...path]/__tests__/route.test.ts index 33c475d8..f0c0ae92 100644 --- a/app/api/extensions/ext/[...path]/__tests__/route.test.ts +++ b/app/api/extensions/ext/[...path]/__tests__/route.test.ts @@ -15,6 +15,11 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + vi.mock('@/lib/extensions/context-factory', () => ({ createExtensionContext: vi.fn().mockReturnValue({ userId: 'user-1', diff --git a/app/api/extensions/ext/[...path]/route.ts b/app/api/extensions/ext/[...path]/route.ts index d7affc1a..3be731a7 100644 --- a/app/api/extensions/ext/[...path]/route.ts +++ b/app/api/extensions/ext/[...path]/route.ts @@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init' import { extensionRegistry } from '@/lib/extensions/registry' import { createExtensionContext } from '@/lib/extensions/context-factory' import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent' +import { requireCompanyId } from '@/lib/company/context' import type { ApiRouteDefinition } from '@/lib/extensions/types' ensureInitialized() @@ -117,9 +118,11 @@ async function handleRequest( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // AI consent check if (isAiExtension(extensionId)) { - const consented = await hasAiConsent(supabase, user.id, extensionId) + const consented = await hasAiConsent(supabase, companyId, extensionId) if (!consented) { return NextResponse.json( { error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' }, @@ -145,7 +148,7 @@ async function handleRequest( } // Build context and dispatch - const ctx = createExtensionContext(supabase, user.id, extensionId) + const ctx = createExtensionContext(supabase, user.id, companyId, extensionId) return matchedRoute.handler(handlerRequest, ctx) } diff --git a/app/api/extensions/invoice-inbox/webhook/__tests__/route.test.ts b/app/api/extensions/invoice-inbox/webhook/__tests__/route.test.ts index 3473c180..ed2688c4 100644 --- a/app/api/extensions/invoice-inbox/webhook/__tests__/route.test.ts +++ b/app/api/extensions/invoice-inbox/webhook/__tests__/route.test.ts @@ -142,7 +142,7 @@ describe('Invoice Inbox Webhook Route', () => { created_at: '2024-06-15T10:00:00Z', }) mockExtractAttachments.mockReturnValue([]) - mockResolveUserFromEmail.mockResolvedValue('user-1') + mockResolveUserFromEmail.mockResolvedValue({ userId: 'user-1', companyId: 'company-1' }) // Insert inbox item with error status enqueueMany([ diff --git a/app/api/extensions/invoice-inbox/webhook/route.ts b/app/api/extensions/invoice-inbox/webhook/route.ts index 4eaae8bf..d9b6e855 100644 --- a/app/api/extensions/invoice-inbox/webhook/route.ts +++ b/app/api/extensions/invoice-inbox/webhook/route.ts @@ -79,13 +79,15 @@ export async function POST(request: Request) { const supabase = createServiceClient() // Resolve user from recipient email - const userId = await resolveUserFromEmail(payload.to, supabase) + const resolved = await resolveUserFromEmail(payload.to, supabase) - if (!userId) { + if (!resolved) { console.warn(`[document-inbox] No user found for email: ${payload.to}`) return NextResponse.json({ error: 'User not found' }, { status: 404 }) } + const { userId, companyId } = resolved + // Build raw email payload for BFL 7:2 archiving (no binary attachment content) const rawEmailPayload = buildRawEmailPayload(body, payload) @@ -96,6 +98,7 @@ export async function POST(request: Request) { await supabase .from('invoice_inbox_items') .insert({ + company_id: companyId, user_id: userId, status: 'error', source: 'email', @@ -131,6 +134,7 @@ export async function POST(request: Request) { const { data: document, error: docError } = await supabase .from('document_attachments') .insert({ + company_id: companyId, user_id: userId, storage_path: storagePath, file_name: attachment.filename, @@ -159,6 +163,7 @@ export async function POST(request: Request) { const { data: inboxItem, error: itemError } = await supabase .from('invoice_inbox_items') .insert({ + company_id: companyId, user_id: userId, status: 'processing', source: 'email', @@ -197,7 +202,7 @@ export async function POST(request: Request) { const { data: suppliers } = await supabase .from('suppliers') .select('*') - .eq('user_id', userId) + .eq('company_id', companyId) if (suppliers && suppliers.length > 0) { const match = matchSupplier(extraction, suppliers) @@ -222,7 +227,7 @@ export async function POST(request: Request) { // Use pre-extracted receipt data from unified call const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath) - const result = await processReceiptFromDocument(supabase, userId, attachment.content, attachment.content_type, { + const result = await processReceiptFromDocument(supabase, userId, companyId, attachment.content, attachment.content_type, { documentId: document.id, source: 'email', emailFrom: payload.from, diff --git a/app/api/import/bank-file/execute/route.ts b/app/api/import/bank-file/execute/route.ts index 61f3d1ab..23aec731 100644 --- a/app/api/import/bank-file/execute/route.ts +++ b/app/api/import/bank-file/execute/route.ts @@ -4,6 +4,7 @@ import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest' import { generateExternalId } from '@/lib/import/bank-file/parser' +import { requireCompanyId } from '@/lib/company/context' import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types' import type { Transaction } from '@/types' @@ -33,6 +34,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const body: ExecuteRequest = await request.json() const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true } = body @@ -46,6 +49,7 @@ export async function POST(request: Request) { .from('bank_file_imports') .upsert({ user_id: user.id, + company_id: companyId, filename, file_hash, file_format: format, @@ -76,7 +80,7 @@ export async function POST(request: Request) { })) // Run ingestion pipeline - const ingestResult = await ingestTransactions(supabase, user.id, rawTransactions) + const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions) // Update import record with results await supabase @@ -106,6 +110,7 @@ export async function POST(request: Request) { payload: { transactions: importedTransactions as Transaction[], userId: user.id, + companyId, }, }) } diff --git a/app/api/import/bank-file/parse/route.ts b/app/api/import/bank-file/parse/route.ts index bd57eda9..22175ca5 100644 --- a/app/api/import/bank-file/parse/route.ts +++ b/app/api/import/bank-file/parse/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser' import { decodeFileContent } from '@/lib/import/bank-file/encoding' +import { requireCompanyId } from '@/lib/company/context' import type { BankFileFormatId } from '@/lib/import/bank-file/types' /** @@ -18,6 +19,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const formData = await request.formData() const file = formData.get('file') as File | null const formatOverride = formData.get('format') as BankFileFormatId | null @@ -41,7 +44,7 @@ export async function POST(request: Request) { const { data: existingImport } = await supabase .from('bank_file_imports') .select('id, status, imported_count, created_at') - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('file_hash', fileHash) .single() @@ -67,7 +70,7 @@ export async function POST(request: Request) { const { count } = await supabase .from('transactions') .select('*', { count: 'exact', head: true }) - .eq('user_id', user.id) + .eq('company_id', companyId) .gte('date', parseResult.date_from || '1970-01-01') .lte('date', parseResult.date_to || '2099-12-31') diff --git a/app/api/import/sie/[id]/route.ts b/app/api/import/sie/[id]/route.ts index 23c5ed8f..8b30cc74 100644 --- a/app/api/import/sie/[id]/route.ts +++ b/app/api/import/sie/[id]/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' /** * GET /api/import/sie/[id] @@ -20,11 +21,13 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase .from('sie_imports') .select('*') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (error) { @@ -62,12 +65,14 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Check current status before deleting const { data: importRecord } = await supabase .from('sie_imports') .select('status') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (!importRecord) { @@ -84,7 +89,7 @@ export async function DELETE( .from('sie_imports') .delete() .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) if (error) { return NextResponse.json({ error: error.message }, { status: 500 }) diff --git a/app/api/import/sie/create-accounts/route.ts b/app/api/import/sie/create-accounts/route.ts index c2e0f866..755b9b8d 100644 --- a/app/api/import/sie/create-accounts/route.ts +++ b/app/api/import/sie/create-accounts/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' import type { SIEAccount } from '@/lib/import/types' /** @@ -63,6 +64,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + try { const body = await request.json() const accounts: SIEAccount[] = body.accounts @@ -80,6 +83,7 @@ export async function POST(request: Request) { return { user_id: user.id, + company_id: companyId, account_number: account.number, account_name: account.name, account_class: accountClass, diff --git a/app/api/import/sie/execute/route.ts b/app/api/import/sie/execute/route.ts index 57432285..9d9b7f74 100644 --- a/app/api/import/sie/execute/route.ts +++ b/app/api/import/sie/execute/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser' import { suggestMappings } from '@/lib/import/account-mapper' import { executeSIEImport } from '@/lib/import/sie-import' @@ -26,6 +27,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + try { // Get form data with file and options const formData = await request.formData() @@ -63,7 +66,7 @@ export async function POST(request: Request) { const { data: storedMappings } = await supabase .from('sie_account_mappings') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) mappings = suggestMappings( parsed.accounts, @@ -90,14 +93,15 @@ export async function POST(request: Request) { ...new Set(mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)), ] - const existingAccounts = await fetchAllRows(({ from, to }) => + const allCompanyAccounts = await fetchAllRows(({ from, to }) => supabase .from('chart_of_accounts') .select('account_number') - .eq('user_id', user.id) - .in('account_number', mappedAccountNumbers) + .eq('company_id', companyId) .range(from, to) ) + const mappedSet = new Set(mappedAccountNumbers) + const existingAccounts = allCompanyAccounts.filter((a) => mappedSet.has(a.account_number)) // Build a lookup from SIE mappings for account names (used for bas_range accounts) const mappingNameLookup = new Map() @@ -116,6 +120,7 @@ export async function POST(request: Request) { // Account exists in BAS reference — use full metadata return { user_id: user.id, + company_id: companyId, account_number: ref.account_number, account_name: ref.account_name, account_class: ref.account_class, @@ -146,6 +151,7 @@ export async function POST(request: Request) { return { user_id: user.id, + company_id: companyId, account_number: num, account_name: accountName, account_class: accountClass, @@ -176,6 +182,7 @@ export async function POST(request: Request) { // Execute the import const result = await executeSIEImport( supabase, + companyId, user.id, parsed, mappings, diff --git a/app/api/import/sie/mappings/route.ts b/app/api/import/sie/mappings/route.ts index 6ddd9688..5453c91c 100644 --- a/app/api/import/sie/mappings/route.ts +++ b/app/api/import/sie/mappings/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { saveMappings } from '@/lib/import/sie-import' +import { requireCompanyId } from '@/lib/company/context' import type { AccountMapping } from '@/lib/import/types' /** @@ -18,10 +19,12 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase .from('sie_account_mappings') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .order('source_account') if (error) { @@ -46,6 +49,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const body = await request.json() const mappings: AccountMapping[] = body.mappings @@ -79,6 +84,8 @@ export async function PUT(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const body = await request.json() const { sourceAccount, targetAccount } = body @@ -93,6 +100,7 @@ export async function PUT(request: Request) { .from('sie_account_mappings') .upsert({ user_id: user.id, + company_id: companyId, source_account: sourceAccount, target_account: targetAccount, confidence: 1.0, @@ -125,6 +133,8 @@ export async function DELETE(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const sourceAccount = searchParams.get('sourceAccount') @@ -133,7 +143,7 @@ export async function DELETE(request: Request) { const { error } = await supabase .from('sie_account_mappings') .delete() - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('source_account', sourceAccount) if (error) { @@ -144,7 +154,7 @@ export async function DELETE(request: Request) { const { error } = await supabase .from('sie_account_mappings') .delete() - .eq('user_id', user.id) + .eq('company_id', companyId) if (error) { return NextResponse.json({ error: error.message }, { status: 500 }) diff --git a/app/api/import/sie/parse/route.ts b/app/api/import/sie/parse/route.ts index 684377ff..0385f109 100644 --- a/app/api/import/sie/parse/route.ts +++ b/app/api/import/sie/parse/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' import { parseSIEFile, validateSIEFile, @@ -27,6 +28,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + try { // Get form data with file const formData = await request.formData() @@ -91,7 +94,7 @@ export async function POST(request: Request) { const { data: storedMappings } = await supabase .from('sie_account_mappings') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) // Match against the full BAS reference (1,276 accounts) instead of only // the user's active chart (~40 accounts). Accounts that match will be diff --git a/app/api/import/sie/route.ts b/app/api/import/sie/route.ts index e322606a..52cc4beb 100644 --- a/app/api/import/sie/route.ts +++ b/app/api/import/sie/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' /** * GET /api/import/sie @@ -16,6 +17,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Parse query params const { searchParams } = new URL(request.url) const limit = parseInt(searchParams.get('limit') || '20', 10) @@ -25,7 +28,7 @@ export async function GET(request: Request) { let query = supabase .from('sie_imports') .select('*', { count: 'exact' }) - .eq('user_id', user.id) + .eq('company_id', companyId) .order('created_at', { ascending: false }) .range(offset, offset + limit - 1) diff --git a/app/api/invoices/[id]/convert/route.ts b/app/api/invoices/[id]/convert/route.ts index f1f6dac9..d5b4da30 100644 --- a/app/api/invoices/[id]/convert/route.ts +++ b/app/api/invoices/[id]/convert/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' import type { Invoice } from '@/types' ensureInitialized() @@ -25,12 +26,14 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Fetch proforma with items const { data: proforma, error: proformaError } = await supabase .from('invoices') .select('*, items:invoice_items(*)') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (proformaError || !proforma) { @@ -61,6 +64,7 @@ export async function POST( .from('invoices') .insert({ user_id: user.id, + company_id: companyId, customer_id: proforma.customer_id, invoice_number: invoiceNumber, invoice_date: new Date().toISOString().split('T')[0], @@ -129,7 +133,7 @@ export async function POST( if (completeInvoice) { await eventBus.emit({ type: 'invoice.created', - payload: { invoice: completeInvoice as Invoice, userId: user.id }, + payload: { invoice: completeInvoice as Invoice, companyId, userId: user.id }, }) } diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index baba81d1..ed802f3e 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -17,6 +17,10 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + const mockCreateInvoicePaymentJournalEntry = vi.fn() const mockCreateInvoiceCashEntry = vi.fn() vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ @@ -137,6 +141,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => { expect(body.journal_entry_id).toBe('je-1') expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith( expect.anything(), + 'company-1', 'user-1', expect.objectContaining({ id: 'inv-1' }), expect.any(String), @@ -172,6 +177,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => { expect(body.journal_entry_id).toBe('je-2') expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith( expect.anything(), + 'company-1', 'user-1', expect.objectContaining({ id: 'inv-1' }), expect.any(String), @@ -235,6 +241,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => { // Should call createJournalEntry directly with custom lines expect(mockCreateJournalEntry).toHaveBeenCalledWith( expect.anything(), + 'company-1', 'user-1', expect.objectContaining({ entry_date: '2025-03-17', diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index a2f57ab0..7c6d247e 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -7,6 +7,7 @@ import { import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' import { MarkInvoicePaidSchema } from '@/lib/api/schemas' import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types' ensureInitialized() @@ -35,12 +36,14 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Fetch invoice const { data: invoice, error: invoiceError } = await supabase .from('invoices') .select('*, customer:customers(*), items:invoice_items(*)') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (invoiceError || !invoice) { @@ -83,7 +86,7 @@ export async function POST( const { data: settings } = await supabase .from('company_settings') .select('accounting_method, entity_type') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const accountingMethod = settings?.accounting_method || 'accrual' @@ -107,7 +110,7 @@ export async function POST( } // User-provided lines from PaymentBookingDialog - const fiscalPeriodId = await findFiscalPeriod(supabase, user.id, paymentDate) + const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate) if (!fiscalPeriodId) { return NextResponse.json( { error: 'Ingen öppen räkenskapsperiod för betalningsdatumet' }, @@ -125,12 +128,13 @@ export async function POST( source_id: invoice.id, lines: customLines, } - const journalEntry = await createJournalEntry(supabase, user.id, input) + const journalEntry = await createJournalEntry(supabase, companyId, user.id, input) journalEntryId = journalEntry?.id ?? null } else if (accountingMethod === 'accrual') { // Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510) const journalEntry = await createInvoicePaymentJournalEntry( supabase, + companyId, user.id, invoice as Invoice, paymentDate, @@ -142,6 +146,7 @@ export async function POST( // Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx) const journalEntry = await createInvoiceCashEntry( supabase, + companyId, user.id, invoice as Invoice, paymentDate, @@ -168,7 +173,7 @@ export async function POST( paid_amount: invoice.total, }) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) if (updateError) { return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 }) diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index 6a5f4978..189dcfae 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' import type { EntityType, Invoice } from '@/types' ensureInitialized() @@ -26,12 +27,14 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Fetch invoice const { data: invoice, error: invoiceError } = await supabase .from('invoices') .select('*, customer:customers(*), items:invoice_items(*)') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (invoiceError || !invoice) { @@ -50,7 +53,7 @@ export async function POST( .from('invoices') .update({ status: 'sent' }) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) if (updateError) { return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 }) @@ -60,7 +63,7 @@ export async function POST( const { data: settings } = await supabase .from('company_settings') .select('accounting_method, entity_type') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const accountingMethod = settings?.accounting_method || 'accrual' @@ -72,6 +75,7 @@ export async function POST( try { const journalEntry = await createInvoiceJournalEntry( supabase, + companyId, user.id, invoice as Invoice, (settings?.entity_type as EntityType) || 'enskild_firma', diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts index 29757dba..8f1304d0 100644 --- a/app/api/invoices/[id]/pdf/route.ts +++ b/app/api/invoices/[id]/pdf/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' +import { requireCompanyId } from '@/lib/company/context' import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' export async function GET( @@ -17,6 +18,8 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Fetch invoice with customer and items const { data: invoice, error: invoiceError } = await supabase .from('invoices') @@ -26,7 +29,7 @@ export async function GET( items:invoice_items(*) `) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (invoiceError || !invoice) { @@ -37,7 +40,7 @@ export async function GET( const { data: company, error: companyError } = await supabase .from('company_settings') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (companyError || !company) { diff --git a/app/api/invoices/[id]/route.ts b/app/api/invoices/[id]/route.ts index 31837a4d..2dc601ef 100644 --- a/app/api/invoices/[id]/route.ts +++ b/app/api/invoices/[id]/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' /** * DELETE /api/invoices/[id] @@ -21,12 +22,14 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Fetch invoice to verify ownership and status const { data: invoice, error: fetchError } = await supabase .from('invoices') .select('id, status, user_id') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchError || !invoice) { @@ -54,7 +57,7 @@ export async function DELETE( .from('invoices') .delete() .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) if (deleteError) { return NextResponse.json({ error: deleteError.message }, { status: 500 }) diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 8631ceff..7bcc413f 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -19,6 +19,11 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + const mockRenderToBuffer = vi.fn() vi.mock('@react-pdf/renderer', () => ({ renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args), @@ -184,6 +189,7 @@ describe('POST /api/invoices/[id]/send', () => { ) expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith( expect.anything(), + 'company-1', 'user-1', expect.objectContaining({ id: 'inv-1' }), 'enskild_firma' diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index b22fab23..6ae0cdd5 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -12,6 +12,7 @@ import { } from '@/lib/email/invoice-templates' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { uploadDocument } from '@/lib/core/documents/document-service' +import { requireCompanyId } from '@/lib/company/context' import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' ensureInitialized() @@ -29,6 +30,8 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Check if email is configured const emailService = getEmailService() if (!emailService.isConfigured()) { @@ -47,7 +50,7 @@ export async function POST( items:invoice_items(*) `) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (invoiceError || !invoice) { @@ -67,7 +70,7 @@ export async function POST( const { data: company, error: companyError } = await supabase .from('company_settings') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (companyError || !company) { @@ -161,7 +164,7 @@ export async function POST( .from('invoices') .update({ status: 'sent' }) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) if (updateError) { console.error('Failed to update invoice status:', updateError) @@ -175,6 +178,7 @@ export async function POST( try { const journalEntry = await createInvoiceJournalEntry( supabase, + companyId, user.id, invoice as Invoice, (company as CompanySettings).entity_type @@ -196,7 +200,7 @@ export async function POST( if (isRealInvoice) { try { const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer - await uploadDocument(supabase, user.id, { + await uploadDocument(supabase, user.id, companyId, { name: filename, buffer: pdfArrayBuffer, type: 'application/pdf', @@ -212,7 +216,7 @@ export async function POST( await eventBus.emit({ type: 'invoice.sent', - payload: { invoice: invoice as Invoice, userId: user.id }, + payload: { invoice: invoice as Invoice, companyId, userId: user.id }, }) return NextResponse.json({ diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index d7e68c95..2da2932f 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -17,6 +17,11 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + const mockGetVatRules = vi.fn() const mockCalculateVat = vi.fn() const mockGetAvailableVatRates = vi.fn() diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index 8f7d77df..cb9f64d0 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { getVatRules } from '@/lib/invoices/vat-rules' +import { requireCompanyId } from '@/lib/company/context' import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' /** @@ -20,6 +21,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const body = await request.json() const { customer_id, invoice_date, due_date, currency, items, your_reference, our_reference, notes, document_type } = body @@ -32,7 +35,7 @@ export async function POST(request: Request) { .from('customers') .select('*') .eq('id', customer_id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (customerError || !customer) { @@ -43,7 +46,7 @@ export async function POST(request: Request) { const { data: company, error: companyError } = await supabase .from('company_settings') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (companyError || !company) { diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 4724621f..5a24ed15 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -9,6 +9,7 @@ import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' import { createCreditNoteJournalEntry, } from '@/lib/bookkeeping/invoice-entries' +import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -21,6 +22,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const status = searchParams.get('status') const limit = parseInt(searchParams.get('limit') || '50') @@ -29,7 +32,7 @@ export async function GET(request: Request) { let query = supabase .from('invoices') .select('*, customer:customers(*)', { count: 'exact' }) - .eq('user_id', user.id) + .eq('company_id', companyId) .order('invoice_date', { ascending: false }) .range(offset, offset + limit - 1) @@ -55,6 +58,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + let rawBody: unknown try { rawBody = await request.json() @@ -78,7 +83,7 @@ export async function POST(request: Request) { { status: 400 }, ) } - return createCreditNote(supabase, user.id, parsed.data) + return createCreditNote(supabase, companyId, user.id, parsed.data) } const parsed = CreateInvoiceSchema.safeParse(rawBody) @@ -100,7 +105,7 @@ export async function POST(request: Request) { .from('customers') .select('*') .eq('id', invoiceInput.customer_id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (customerError || !customer) { @@ -178,6 +183,7 @@ export async function POST(request: Request) { .from('invoices') .insert({ user_id: user.id, + company_id: companyId, customer_id: invoiceInput.customer_id, invoice_number: invoiceNumber, invoice_date: invoiceInput.invoice_date, @@ -246,7 +252,7 @@ export async function POST(request: Request) { if (completeInvoice && documentType === 'invoice') { await eventBus.emit({ type: 'invoice.created', - payload: { invoice: completeInvoice as Invoice, userId: user.id }, + payload: { invoice: completeInvoice as Invoice, companyId, userId: user.id }, }) } @@ -256,6 +262,7 @@ export async function POST(request: Request) { // Create a credit note for an existing invoice async function createCreditNote( supabase: Awaited>, + companyId: string, userId: string, input: { credited_invoice_id: string; reason?: string } ) { @@ -264,7 +271,7 @@ async function createCreditNote( .from('invoices') .select('*, items:invoice_items(*)') .eq('id', input.credited_invoice_id) - .eq('user_id', userId) + .eq('company_id', companyId) .single() if (originalError || !originalInvoice) { @@ -300,6 +307,7 @@ async function createCreditNote( .from('invoices') .insert({ user_id: userId, + company_id: companyId, customer_id: originalInvoice.customer_id, invoice_number: creditNoteNumber, invoice_date: new Date().toISOString().split('T')[0], @@ -373,7 +381,7 @@ async function createCreditNote( const { data: creditNoteSettings } = await supabase .from('company_settings') .select('entity_type, accounting_method') - .eq('user_id', userId) + .eq('company_id', companyId) .single() const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma' @@ -385,6 +393,7 @@ async function createCreditNote( try { const journalEntry = await createCreditNoteJournalEntry( supabase, + companyId, userId, completeCreditNote as Invoice, entityType, @@ -402,7 +411,7 @@ async function createCreditNote( await eventBus.emit({ type: 'credit_note.created', - payload: { creditNote: completeCreditNote as CreditNote, userId }, + payload: { creditNote: completeCreditNote as CreditNote, companyId, userId }, }) } diff --git a/app/api/kpi/preferences/route.ts b/app/api/kpi/preferences/route.ts index 624f8596..b3932cf1 100644 --- a/app/api/kpi/preferences/route.ts +++ b/app/api/kpi/preferences/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { mergeWithDefaults } from '@/lib/reports/kpi-definitions' +import { requireCompanyId } from '@/lib/company/context' import type { KPIPreferences } from '@/types' const EXTENSION_ID = 'core/kpi' @@ -11,10 +12,12 @@ export async function GET() { const { data: { user } } = await supabase.auth.getUser() if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const companyId = await requireCompanyId(supabase, user.id) + const { data } = await supabase .from('extension_data') .select('value') - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('extension_id', EXTENSION_ID) .eq('key', KEY) .single() @@ -28,6 +31,8 @@ export async function PUT(request: Request) { const { data: { user } } = await supabase.auth.getUser() if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const companyId = await requireCompanyId(supabase, user.id) + let body: unknown try { body = await request.json() @@ -64,6 +69,7 @@ export async function PUT(request: Request) { .upsert( { user_id: user.id, + company_id: companyId, extension_id: EXTENSION_ID, key: KEY, value: merged, diff --git a/app/api/mcp-oauth/authorize/route.ts b/app/api/mcp-oauth/authorize/route.ts index 2474bf60..40d60207 100644 --- a/app/api/mcp-oauth/authorize/route.ts +++ b/app/api/mcp-oauth/authorize/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { createAuthCode } from '@/lib/auth/oauth-codes' +import { requireCompanyId } from '@/lib/company/context' /** * OAuth 2.0 Authorization Endpoint. @@ -88,11 +89,13 @@ export async function GET(request: Request) { return buildLoginRedirect(request) } + const companyId = await requireCompanyId(supabase, user.id) + // Get company name for the consent page const { data: settings } = await supabase .from('company_settings') .select('company_name') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const companyName = settings?.company_name || user.email @@ -180,6 +183,8 @@ export async function POST(request: Request) { return buildLoginRedirect(request) } + await requireCompanyId(supabase, user.id) + // Parse form body const formData = await request.formData() const consent = formData.get('consent') diff --git a/app/api/mcp-oauth/token/route.ts b/app/api/mcp-oauth/token/route.ts index 2498f75f..abf35387 100644 --- a/app/api/mcp-oauth/token/route.ts +++ b/app/api/mcp-oauth/token/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server' import { decryptAuthCode, verifyPkce, hashAuthCode } from '@/lib/auth/oauth-codes' import { generateApiKey, createServiceClientNoCookies, ALL_SCOPES } from '@/lib/auth/api-keys' +import { requireCompanyId } from '@/lib/company/context' /** * OAuth 2.0 Token Endpoint. @@ -100,6 +101,9 @@ export async function POST(request: Request) { .lt('created_at', new Date(Date.now() - 10 * 60 * 1000).toISOString()) .then(() => {}) + // Resolve company context for the user + const companyId = await requireCompanyId(supabase, payload.userId) + // Create the API key now (after PKCE verification — prevents orphaned keys) const { key, hash, prefix } = generateApiKey() @@ -107,6 +111,7 @@ export async function POST(request: Request) { .from('api_keys') .insert({ user_id: payload.userId, + company_id: companyId, key_hash: hash, key_prefix: prefix, name: 'MCP-klient (OAuth)', diff --git a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts index 6f7da7e0..ca3427fc 100644 --- a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts @@ -15,6 +15,11 @@ vi.mock('@/lib/supabase/server', () => ({ })) vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + // Mock the counterparty templates (non-critical side effect) vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined), diff --git a/app/api/pending-operations/[id]/commit/route.ts b/app/api/pending-operations/[id]/commit/route.ts index 38f4ab56..32fbeee8 100644 --- a/app/api/pending-operations/[id]/commit/route.ts +++ b/app/api/pending-operations/[id]/commit/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' @@ -48,13 +49,14 @@ ensureInitialized() async function ensureFiscalPeriod( supabase: Awaited>, userId: string, + companyId: string, date: string, fiscalYearStartMonth: number = 1 ): Promise { const { data: existing } = await supabase .from('fiscal_periods') .select('id') - .eq('user_id', userId) + .eq('company_id', companyId) .lte('period_start', date) .gte('period_end', date) .eq('is_closed', false) @@ -91,6 +93,7 @@ async function ensureFiscalPeriod( .from('fiscal_periods') .upsert({ user_id: userId, + company_id: companyId, name: periodName, period_start: periodStart, period_end: periodEnd, @@ -108,6 +111,7 @@ async function ensureFiscalPeriod( async function commitCategorizeTransaction( supabase: Awaited>, userId: string, + companyId: string, params: Record ): Promise<{ data?: Record; error?: string; status?: number }> { const txId = params.transaction_id as string @@ -119,7 +123,7 @@ async function commitCategorizeTransaction( .from('transactions') .select('*') .eq('id', txId) - .eq('user_id', userId) + .eq('company_id', companyId) .single() if (fetchError || !transaction) { @@ -136,7 +140,7 @@ async function commitCategorizeTransaction( const { data: settings } = await supabase .from('company_settings') .select('entity_type, fiscal_year_start_month') - .eq('user_id', userId) + .eq('company_id', companyId) .single() const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' @@ -156,13 +160,13 @@ async function commitCategorizeTransaction( } // Ensure fiscal period exists - await ensureFiscalPeriod(supabase, userId, transaction.date, fiscalYearStartMonth) + await ensureFiscalPeriod(supabase, userId, companyId, transaction.date, fiscalYearStartMonth) // Create journal entry let journalEntryId: string | null = null try { const journalEntry = await createTransactionJournalEntry( - supabase, userId, transaction as Transaction, mappingResult + supabase, companyId, userId, transaction as Transaction, mappingResult ) if (journalEntry) { journalEntryId = journalEntry.id @@ -202,6 +206,7 @@ async function commitCategorizeTransaction( account: mappingResult.debit_account, taxCode: mappingResult.vat_lines[0]?.account_number || '', userId, + companyId, }, }) @@ -211,12 +216,14 @@ async function commitCategorizeTransaction( async function commitCreateCustomer( supabase: Awaited>, userId: string, + companyId: string, params: Record ): Promise<{ data?: Record; error?: string; status?: number }> { const { data, error } = await supabase .from('customers') .insert({ user_id: userId, + company_id: companyId, name: params.name as string, customer_type: params.customer_type as string, email: (params.email as string) || null, @@ -247,7 +254,7 @@ async function commitCreateCustomer( vat_number_validated_at: new Date().toISOString(), }) .eq('id', data.id) - .eq('user_id', userId) + .eq('company_id', companyId) } } catch (err) { log.warn('Auto-VIES validation failed:', err) @@ -256,7 +263,7 @@ async function commitCreateCustomer( await eventBus.emit({ type: 'customer.created', - payload: { customer: data as Customer, userId }, + payload: { customer: data as Customer, userId, companyId }, }) return { data: { customer_id: data.id } } @@ -265,6 +272,7 @@ async function commitCreateCustomer( async function commitCreateInvoice( supabase: Awaited>, userId: string, + companyId: string, params: Record ): Promise<{ data?: Record; error?: string; status?: number }> { const customerId = params.customer_id as string @@ -281,7 +289,7 @@ async function commitCreateInvoice( .from('customers') .select('*') .eq('id', customerId) - .eq('user_id', userId) + .eq('company_id', companyId) .single() if (customerError || !customer) { @@ -340,6 +348,7 @@ async function commitCreateInvoice( .from('invoices') .insert({ user_id: userId, + company_id: companyId, customer_id: customerId, invoice_number: invoiceNumber, invoice_date: (params.invoice_date as string) || new Date().toISOString().split('T')[0], @@ -406,7 +415,7 @@ async function commitCreateInvoice( if (completeInvoice) { await eventBus.emit({ type: 'invoice.created', - payload: { invoice: completeInvoice as Invoice, userId }, + payload: { invoice: completeInvoice as Invoice, userId, companyId }, }) } @@ -416,6 +425,7 @@ async function commitCreateInvoice( async function commitMarkInvoicePaid( supabase: Awaited>, userId: string, + companyId: string, params: Record ): Promise<{ data?: Record; error?: string; status?: number }> { const invoiceId = params.invoice_id as string @@ -425,7 +435,7 @@ async function commitMarkInvoicePaid( .from('invoices') .select('*, customer:customers(*), items:invoice_items(*)') .eq('id', invoiceId) - .eq('user_id', userId) + .eq('company_id', companyId) .single() if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 } @@ -436,7 +446,7 @@ async function commitMarkInvoicePaid( const { data: settings } = await supabase .from('company_settings') .select('accounting_method, entity_type') - .eq('user_id', userId) + .eq('company_id', companyId) .single() const accountingMethod = settings?.accounting_method || 'accrual' @@ -447,12 +457,12 @@ async function commitMarkInvoicePaid( if (isRealInvoice) { if (accountingMethod === 'accrual') { const je = await createInvoicePaymentJournalEntry( - supabase, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name + supabase, companyId, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name ) journalEntryId = je?.id ?? null } else { const je = await createInvoiceCashEntry( - supabase, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name + supabase, companyId, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name ) journalEntryId = je?.id ?? null } @@ -463,7 +473,7 @@ async function commitMarkInvoicePaid( .from('invoices') .update({ status: 'paid', paid_at: now, paid_amount: invoice.total }) .eq('id', invoiceId) - .eq('user_id', userId) + .eq('company_id', companyId) if (updateError) return { error: 'Failed to update invoice status', status: 500 } @@ -473,6 +483,7 @@ async function commitMarkInvoicePaid( async function commitSendInvoice( supabase: Awaited>, userId: string, + companyId: string, params: Record, userEmail?: string ): Promise<{ data?: Record; error?: string; status?: number }> { @@ -487,7 +498,7 @@ async function commitSendInvoice( .from('invoices') .select('*, customer:customers(*), items:invoice_items(*)') .eq('id', invoiceId) - .eq('user_id', userId) + .eq('company_id', companyId) .single() if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 } @@ -501,7 +512,7 @@ async function commitSendInvoice( const { data: company, error: companyError } = await supabase .from('company_settings') .select('*') - .eq('user_id', userId) + .eq('company_id', companyId) .single() if (companyError || !company) return { error: 'Company settings missing', status: 500 } @@ -554,14 +565,14 @@ async function commitSendInvoice( if (!result.success) return { error: `Failed to send email: ${result.error}`, status: 500 } - await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('user_id', userId) + await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('company_id', companyId) const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' let createdJournalEntryId: string | undefined if (isRealInvoice && (company.accounting_method === 'accrual' || !company.accounting_method)) { try { const je = await createInvoiceJournalEntry( - supabase, userId, invoice as Invoice, (company as CompanySettings).entity_type + supabase, companyId, userId, invoice as Invoice, (company as CompanySettings).entity_type ) if (je) { createdJournalEntryId = je.id @@ -573,7 +584,7 @@ async function commitSendInvoice( if (isRealInvoice) { try { const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer - await uploadDocument(supabase, userId, { + await uploadDocument(supabase, userId, companyId, { name: filename, buffer: pdfArrayBuffer, type: 'application/pdf', @@ -584,7 +595,7 @@ async function commitSendInvoice( } catch { /* non-blocking */ } } - await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId } }) + await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId, companyId } }) return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } } } @@ -592,6 +603,7 @@ async function commitSendInvoice( async function commitMarkInvoiceSent( supabase: Awaited>, userId: string, + companyId: string, params: Record ): Promise<{ data?: Record; error?: string; status?: number }> { const invoiceId = params.invoice_id as string @@ -600,7 +612,7 @@ async function commitMarkInvoiceSent( .from('invoices') .select('*, customer:customers(*), items:invoice_items(*)') .eq('id', invoiceId) - .eq('user_id', userId) + .eq('company_id', companyId) .single() if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 } @@ -610,14 +622,14 @@ async function commitMarkInvoiceSent( .from('invoices') .update({ status: 'sent' }) .eq('id', invoiceId) - .eq('user_id', userId) + .eq('company_id', companyId) if (updateError) return { error: 'Failed to update invoice status', status: 500 } const { data: settings } = await supabase .from('company_settings') .select('accounting_method, entity_type') - .eq('user_id', userId) + .eq('company_id', companyId) .single() const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' @@ -626,7 +638,7 @@ async function commitMarkInvoiceSent( if (isRealInvoice && (settings?.accounting_method === 'accrual' || !settings?.accounting_method)) { try { const je = await createInvoiceJournalEntry( - supabase, userId, invoice as Invoice, + supabase, companyId, userId, invoice as Invoice, (settings?.entity_type as EntityType) || 'enskild_firma', invoice.customer?.name ) @@ -643,6 +655,7 @@ async function commitMarkInvoiceSent( async function commitMatchTransactionInvoice( supabase: Awaited>, userId: string, + companyId: string, params: Record ): Promise<{ data?: Record; error?: string; status?: number }> { const transactionId = params.transaction_id as string @@ -652,7 +665,7 @@ async function commitMatchTransactionInvoice( .from('transactions') .select('*') .eq('id', transactionId) - .eq('user_id', userId) + .eq('company_id', companyId) .single() if (txError || !transaction) return { error: 'Transaction not found', status: 404 } @@ -663,7 +676,7 @@ async function commitMatchTransactionInvoice( .from('invoices') .select('*, customer:customers(*), items:invoice_items(*)') .eq('id', invoiceId) - .eq('user_id', userId) + .eq('company_id', companyId) .single() if (invError || !invoice) return { error: 'Invoice not found', status: 404 } @@ -673,7 +686,7 @@ async function commitMatchTransactionInvoice( // Storno conflicting journal entry if (transaction.journal_entry_id) { - await reverseEntry(supabase, userId, transaction.journal_entry_id) + await reverseEntry(supabase, companyId, userId, transaction.journal_entry_id) await supabase.from('transactions').update({ journal_entry_id: null }).eq('id', transactionId) } @@ -688,7 +701,7 @@ async function commitMatchTransactionInvoice( const { data: settings } = await supabase .from('company_settings') .select('accounting_method, entity_type') - .eq('user_id', userId) + .eq('company_id', companyId) .single() const accountingMethod = settings?.accounting_method || 'accrual' @@ -698,12 +711,12 @@ async function commitMatchTransactionInvoice( try { if (accountingMethod === 'cash' && isFullyPaid) { const je = await createInvoiceCashEntry( - supabase, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name + supabase, companyId, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name ) journalEntryId = je?.id ?? null } else { const je = await createInvoicePaymentJournalEntry( - supabase, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount + supabase, companyId, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount ) journalEntryId = je?.id ?? null } @@ -733,6 +746,7 @@ async function commitMatchTransactionInvoice( await supabase.from('invoice_payments').insert({ user_id: userId, + company_id: companyId, invoice_id: invoiceId, payment_date: transaction.date, amount: paidAmount, @@ -757,7 +771,7 @@ async function commitMatchTransactionInvoice( try { await eventBus.emit({ type: 'invoice.match_confirmed', - payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId }, + payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId, companyId }, }) } catch { /* non-critical */ } @@ -778,12 +792,14 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Fetch the pending operation const { data: op, error: fetchError } = await supabase .from('pending_operations') .select('*') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchError || !op) { @@ -804,25 +820,25 @@ export async function POST( switch (pendingOp.operation_type) { case 'categorize_transaction': - result = await commitCategorizeTransaction(supabase, user.id, pendingOp.params) + result = await commitCategorizeTransaction(supabase, user.id, companyId, pendingOp.params) break case 'create_customer': - result = await commitCreateCustomer(supabase, user.id, pendingOp.params) + result = await commitCreateCustomer(supabase, user.id, companyId, pendingOp.params) break case 'create_invoice': - result = await commitCreateInvoice(supabase, user.id, pendingOp.params) + result = await commitCreateInvoice(supabase, user.id, companyId, pendingOp.params) break case 'mark_invoice_paid': - result = await commitMarkInvoicePaid(supabase, user.id, pendingOp.params) + result = await commitMarkInvoicePaid(supabase, user.id, companyId, pendingOp.params) break case 'send_invoice': - result = await commitSendInvoice(supabase, user.id, pendingOp.params, user.email) + result = await commitSendInvoice(supabase, user.id, companyId, pendingOp.params, user.email) break case 'mark_invoice_sent': - result = await commitMarkInvoiceSent(supabase, user.id, pendingOp.params) + result = await commitMarkInvoiceSent(supabase, user.id, companyId, pendingOp.params) break case 'match_transaction_invoice': - result = await commitMatchTransactionInvoice(supabase, user.id, pendingOp.params) + result = await commitMatchTransactionInvoice(supabase, user.id, companyId, pendingOp.params) break default: return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 }) diff --git a/app/api/pending-operations/[id]/reject/__tests__/route.test.ts b/app/api/pending-operations/[id]/reject/__tests__/route.test.ts index 3fa9c6fd..a779c591 100644 --- a/app/api/pending-operations/[id]/reject/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/reject/__tests__/route.test.ts @@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + import { POST } from '../../reject/route' describe('POST /api/pending-operations/:id/reject', () => { diff --git a/app/api/pending-operations/[id]/reject/route.ts b/app/api/pending-operations/[id]/reject/route.ts index 591084bb..f2a7a8f4 100644 --- a/app/api/pending-operations/[id]/reject/route.ts +++ b/app/api/pending-operations/[id]/reject/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' /** * POST /api/pending-operations/:id/reject @@ -18,11 +19,13 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data: op, error: fetchError } = await supabase .from('pending_operations') .select('id, status') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchError || !op) { diff --git a/app/api/pending-operations/__tests__/route.test.ts b/app/api/pending-operations/__tests__/route.test.ts index 65a99d1c..889e9d82 100644 --- a/app/api/pending-operations/__tests__/route.test.ts +++ b/app/api/pending-operations/__tests__/route.test.ts @@ -10,6 +10,11 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + import { GET } from '../route' describe('GET /api/pending-operations', () => { diff --git a/app/api/pending-operations/route.ts b/app/api/pending-operations/route.ts index ead138ed..6a250242 100644 --- a/app/api/pending-operations/route.ts +++ b/app/api/pending-operations/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateQuery } from '@/lib/api/validate' import { PendingOperationsQuerySchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' /** * GET /api/pending-operations @@ -16,6 +17,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const result = validateQuery(request, PendingOperationsQuerySchema) if (!result.success) return result.response const { status, limit, offset } = result.data @@ -23,7 +26,7 @@ export async function GET(request: Request) { const { data, error, count } = await supabase .from('pending_operations') .select('*', { count: 'exact' }) - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('status', status) .order('created_at', { ascending: false }) .range(offset, offset + limit - 1) diff --git a/app/api/reconciliation/bank/link/route.ts b/app/api/reconciliation/bank/link/route.ts index 866fda7e..a767110c 100644 --- a/app/api/reconciliation/bank/link/route.ts +++ b/app/api/reconciliation/bank/link/route.ts @@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init' import { manualLink } from '@/lib/reconciliation/bank-reconciliation' import { validateBody } from '@/lib/api/validate' import { BankLinkSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -15,11 +16,13 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, BankLinkSchema) if (!validation.success) return validation.response const { transaction_id, journal_entry_id } = validation.data - const result = await manualLink(supabase, user.id, transaction_id, journal_entry_id) + const result = await manualLink(supabase, companyId, transaction_id, journal_entry_id) if (!result.success) { return NextResponse.json({ error: result.error }, { status: 400 }) diff --git a/app/api/reconciliation/bank/run/route.ts b/app/api/reconciliation/bank/run/route.ts index 5247b783..c808202f 100644 --- a/app/api/reconciliation/bank/run/route.ts +++ b/app/api/reconciliation/bank/run/route.ts @@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init' import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation' import { validateBody } from '@/lib/api/validate' import { RunReconciliationSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -15,11 +16,13 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, RunReconciliationSchema) if (!validation.success) return validation.response const { date_from, date_to, dry_run } = validation.data - const result = await runReconciliation(supabase, user.id, { + const result = await runReconciliation(supabase, companyId, { dateFrom: date_from, dateTo: date_to, dryRun: dry_run ?? false, diff --git a/app/api/reconciliation/bank/status/route.ts b/app/api/reconciliation/bank/status/route.ts index 0928b649..7d9b9807 100644 --- a/app/api/reconciliation/bank/status/route.ts +++ b/app/api/reconciliation/bank/status/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -10,11 +11,13 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const dateFrom = searchParams.get('date_from') || undefined const dateTo = searchParams.get('date_to') || undefined - const status = await getReconciliationStatus(supabase, user.id, dateFrom, dateTo) + const status = await getReconciliationStatus(supabase, companyId, dateFrom, dateTo) return NextResponse.json({ data: status }) } diff --git a/app/api/reconciliation/bank/unlink/route.ts b/app/api/reconciliation/bank/unlink/route.ts index e2e10a9c..6a0cd16e 100644 --- a/app/api/reconciliation/bank/unlink/route.ts +++ b/app/api/reconciliation/bank/unlink/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation' import { validateBody } from '@/lib/api/validate' import { BankUnlinkSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' export async function POST(request: Request) { const supabase = await createClient() @@ -12,11 +13,13 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, BankUnlinkSchema) if (!validation.success) return validation.response const { transaction_id } = validation.data - const result = await unlinkReconciliation(supabase, user.id, transaction_id) + const result = await unlinkReconciliation(supabase, companyId, transaction_id) if (!result.success) { return NextResponse.json({ error: result.error }, { status: 400 }) diff --git a/app/api/reconciliation/bank/unmatched-entries/route.ts b/app/api/reconciliation/bank/unmatched-entries/route.ts index 10a102c4..47708296 100644 --- a/app/api/reconciliation/bank/unmatched-entries/route.ts +++ b/app/api/reconciliation/bank/unmatched-entries/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -10,11 +11,13 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const dateFrom = searchParams.get('date_from') || undefined const dateTo = searchParams.get('date_to') || undefined - const lines = await fetchUnlinkedGLLines(supabase, user.id, dateFrom, dateTo) + const lines = await fetchUnlinkedGLLines(supabase, companyId, dateFrom, dateTo) return NextResponse.json({ data: lines }) } diff --git a/app/api/reports/ar-ledger/route.ts b/app/api/reports/ar-ledger/route.ts index c5a8318b..de1492f4 100644 --- a/app/api/reports/ar-ledger/route.ts +++ b/app/api/reports/ar-ledger/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateARLedger } from '@/lib/reports/ar-ledger' import { generateARReconciliation } from '@/lib/reports/ar-reconciliation' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -12,15 +13,17 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const asOfDate = searchParams.get('as_of_date') || undefined const periodId = searchParams.get('period_id') || undefined - const ledger = await generateARLedger(supabase, user.id, asOfDate) + const ledger = await generateARLedger(supabase, companyId, asOfDate) let reconciliation = null if (periodId) { - reconciliation = await generateARReconciliation(supabase, user.id, periodId) + reconciliation = await generateARReconciliation(supabase, companyId, periodId) } return NextResponse.json({ diff --git a/app/api/reports/audit-trail/__tests__/route.test.ts b/app/api/reports/audit-trail/__tests__/route.test.ts index 99a19e70..675b6521 100644 --- a/app/api/reports/audit-trail/__tests__/route.test.ts +++ b/app/api/reports/audit-trail/__tests__/route.test.ts @@ -9,6 +9,11 @@ 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' diff --git a/app/api/reports/audit-trail/route.ts b/app/api/reports/audit-trail/route.ts index e11dc47f..fe733535 100644 --- a/app/api/reports/audit-trail/route.ts +++ b/app/api/reports/audit-trail/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { getAuditLog } from '@/lib/core/audit/audit-service' +import { requireCompanyId } from '@/lib/company/context' import type { AuditLogEntry, AuditAction } from '@/types' const CSV_HEADERS = 'timestamp,action,table_name,record_id,description,old_state,new_state' @@ -34,6 +35,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const format = searchParams.get('format') || 'json' @@ -52,7 +55,7 @@ export async function GET(request: Request) { const pageSize = 500 while (true) { - const result = await getAuditLog(supabase, user.id, { + const result = await getAuditLog(supabase, companyId, { ...filters, page, pageSize, diff --git a/app/api/reports/balance-sheet/route.ts b/app/api/reports/balance-sheet/route.ts index aaf9d62a..b7bdf70a 100644 --- a/app/api/reports/balance-sheet/route.ts +++ b/app/api/reports/balance-sheet/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateBalanceSheet } from '@/lib/reports/balance-sheet' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -10,6 +11,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -21,11 +24,11 @@ export async function GET(request: Request) { .from('fiscal_periods') .select('period_start, period_end') .eq('id', periodId) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() try { - const result = await generateBalanceSheet(supabase, user.id, periodId) + const result = await generateBalanceSheet(supabase, companyId, periodId) if (period) { result.period = { diff --git a/app/api/reports/full-archive/route.ts b/app/api/reports/full-archive/route.ts index 57f4d478..19eb7cf3 100644 --- a/app/api/reports/full-archive/route.ts +++ b/app/api/reports/full-archive/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateFullArchive } from '@/lib/reports/full-archive-export' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -10,6 +11,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -18,7 +21,7 @@ export async function GET(request: Request) { } try { - const zipBuffer = await generateFullArchive(supabase, user.id, { + const zipBuffer = await generateFullArchive(supabase, companyId, { period_id: periodId, include_documents: searchParams.get('include_documents') !== 'false', }) diff --git a/app/api/reports/general-ledger/route.ts b/app/api/reports/general-ledger/route.ts index 70d4fc11..e48e9d39 100644 --- a/app/api/reports/general-ledger/route.ts +++ b/app/api/reports/general-ledger/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateGeneralLedger } from '@/lib/reports/general-ledger' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -11,6 +12,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') const accountFrom = searchParams.get('account_from') || undefined @@ -20,7 +23,7 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) } - const data = await generateGeneralLedger(supabase, user.id, periodId, accountFrom, accountTo) + const data = await generateGeneralLedger(supabase, companyId, periodId, accountFrom, accountTo) return NextResponse.json({ data }) } diff --git a/app/api/reports/income-statement/route.ts b/app/api/reports/income-statement/route.ts index 4005669d..ed15db2f 100644 --- a/app/api/reports/income-statement/route.ts +++ b/app/api/reports/income-statement/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateIncomeStatement } from '@/lib/reports/income-statement' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -10,6 +11,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -22,11 +25,11 @@ export async function GET(request: Request) { .from('fiscal_periods') .select('period_start, period_end') .eq('id', periodId) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() try { - const result = await generateIncomeStatement(supabase, user.id, periodId) + const result = await generateIncomeStatement(supabase, companyId, periodId) if (period) { result.period = { diff --git a/app/api/reports/ink2/route.ts b/app/api/reports/ink2/route.ts index efcbcdc9..42af7e57 100644 --- a/app/api/reports/ink2/route.ts +++ b/app/api/reports/ink2/route.ts @@ -6,6 +6,7 @@ import { sruFileToString, getSRUFilename, } from '@/lib/reports/ink2/sru-generator' +import { requireCompanyId } from '@/lib/company/context' /** * GET /api/reports/ink2 @@ -24,6 +25,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') const format = searchParams.get('format') || 'json' @@ -36,7 +39,7 @@ export async function GET(request: Request) { } try { - const declaration = await generateINK2Declaration(supabase, user.id, periodId) + const declaration = await generateINK2Declaration(supabase, companyId, periodId) if (format === 'sru') { const sruFile = generateSRUFile(declaration) diff --git a/app/api/reports/journal-register/route.ts b/app/api/reports/journal-register/route.ts index db7365f0..ad025158 100644 --- a/app/api/reports/journal-register/route.ts +++ b/app/api/reports/journal-register/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateJournalRegister } from '@/lib/reports/journal-register' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -11,6 +12,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -18,7 +21,7 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) } - const data = await generateJournalRegister(supabase, user.id, periodId) + const data = await generateJournalRegister(supabase, companyId, periodId) return NextResponse.json({ data }) } diff --git a/app/api/reports/kpi/route.ts b/app/api/reports/kpi/route.ts index 3ce36dee..a0691121 100644 --- a/app/api/reports/kpi/route.ts +++ b/app/api/reports/kpi/route.ts @@ -11,6 +11,7 @@ import { calculateAvgPaymentDays, } from '@/lib/reports/kpi' import { mergeWithDefaults } from '@/lib/reports/kpi-definitions' +import { requireCompanyId } from '@/lib/company/context' import type { KPIReport, KPIPreferences } from '@/types' export async function GET(request: Request) { @@ -18,6 +19,8 @@ export async function GET(request: Request) { const { data: { user } } = await supabase.auth.getUser() if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') if (!periodId) { @@ -28,7 +31,7 @@ export async function GET(request: Request) { .from('fiscal_periods') .select('*') .eq('id', periodId) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (periodError || !period) { @@ -39,7 +42,7 @@ export async function GET(request: Request) { const { data: prefsData } = await supabase .from('extension_data') .select('value') - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('extension_id', 'core/kpi') .eq('key', 'preferences') .single() @@ -50,14 +53,14 @@ export async function GET(request: Request) { const [incomeStatement, trialBalanceResult, arLedger, monthlyBreakdown, paidInvoicesResult] = await Promise.all([ - generateIncomeStatement(supabase, user.id, periodId), - generateTrialBalance(supabase, user.id, periodId), - generateARLedger(supabase, user.id), - generateMonthlyBreakdown(supabase, user.id, periodId), + generateIncomeStatement(supabase, companyId, periodId), + generateTrialBalance(supabase, companyId, periodId), + generateARLedger(supabase, companyId), + generateMonthlyBreakdown(supabase, companyId, periodId), supabase .from('invoices') .select('invoice_date, paid_at') - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('status', 'paid') .not('paid_at', 'is', null), ]) diff --git a/app/api/reports/monthly-breakdown/route.ts b/app/api/reports/monthly-breakdown/route.ts index db3fc9c9..295cc375 100644 --- a/app/api/reports/monthly-breakdown/route.ts +++ b/app/api/reports/monthly-breakdown/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -10,6 +11,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -18,7 +21,7 @@ export async function GET(request: Request) { } try { - const data = await generateMonthlyBreakdown(supabase, user.id, periodId) + const data = await generateMonthlyBreakdown(supabase, companyId, periodId) return NextResponse.json({ data }) } catch { return NextResponse.json({ error: 'Failed to generate monthly breakdown' }, { status: 500 }) diff --git a/app/api/reports/ne-bilaga/route.ts b/app/api/reports/ne-bilaga/route.ts index 87548f36..a8cbc870 100644 --- a/app/api/reports/ne-bilaga/route.ts +++ b/app/api/reports/ne-bilaga/route.ts @@ -6,6 +6,7 @@ import { sruFileToString, getSRUFilename, } from '@/lib/reports/ne-bilaga/sru-generator' +import { requireCompanyId } from '@/lib/company/context' /** * GET /api/reports/ne-bilaga @@ -28,6 +29,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') const format = searchParams.get('format') || 'json' @@ -40,7 +43,7 @@ export async function GET(request: Request) { } try { - const declaration = await generateNEDeclaration(supabase, user.id, periodId) + const declaration = await generateNEDeclaration(supabase, companyId, periodId) if (format === 'sru') { // Generate and return SRU file diff --git a/app/api/reports/sie-export/route.ts b/app/api/reports/sie-export/route.ts index dcdb2e02..34b7643e 100644 --- a/app/api/reports/sie-export/route.ts +++ b/app/api/reports/sie-export/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateSIEExport } from '@/lib/reports/sie-export' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -10,6 +11,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -21,7 +24,7 @@ export async function GET(request: Request) { const { data: company } = await supabase .from('company_settings') .select('company_name, org_number') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (!company) { @@ -29,7 +32,7 @@ export async function GET(request: Request) { } try { - const sieContent = await generateSIEExport(supabase, user.id, { + const sieContent = await generateSIEExport(supabase, companyId, { fiscal_period_id: periodId, company_name: company.company_name || 'Unknown', org_number: company.org_number, diff --git a/app/api/reports/supplier-ledger/route.ts b/app/api/reports/supplier-ledger/route.ts index e0ddc6a3..6bae0496 100644 --- a/app/api/reports/supplier-ledger/route.ts +++ b/app/api/reports/supplier-ledger/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateSupplierLedger } from '@/lib/reports/supplier-ledger' import { generateReconciliation } from '@/lib/reports/supplier-reconciliation' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -12,15 +13,17 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const asOfDate = searchParams.get('as_of_date') || undefined const periodId = searchParams.get('period_id') || undefined - const ledger = await generateSupplierLedger(supabase, user.id, asOfDate) + const ledger = await generateSupplierLedger(supabase, companyId, asOfDate) let reconciliation = null if (periodId) { - reconciliation = await generateReconciliation(supabase, user.id, periodId) + reconciliation = await generateReconciliation(supabase, companyId, periodId) } return NextResponse.json({ diff --git a/app/api/reports/trial-balance/route.ts b/app/api/reports/trial-balance/route.ts index fb2b9daf..5aeee60e 100644 --- a/app/api/reports/trial-balance/route.ts +++ b/app/api/reports/trial-balance/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateTrialBalance } from '@/lib/reports/trial-balance' +import { requireCompanyId } from '@/lib/company/context' export async function GET(request: Request) { const supabase = await createClient() @@ -10,6 +11,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -18,7 +21,7 @@ export async function GET(request: Request) { } try { - const result = await generateTrialBalance(supabase, user.id, periodId) + const result = await generateTrialBalance(supabase, companyId, periodId) return NextResponse.json({ data: result }) } catch (err) { return NextResponse.json( diff --git a/app/api/reports/vat-declaration/route.ts b/app/api/reports/vat-declaration/route.ts index 8f1a2162..c4eb850a 100644 --- a/app/api/reports/vat-declaration/route.ts +++ b/app/api/reports/vat-declaration/route.ts @@ -4,6 +4,7 @@ import { calculateVatDeclaration, formatPeriodLabel, } from '@/lib/reports/vat-declaration' +import { requireCompanyId } from '@/lib/company/context' import type { VatPeriodType, AccountingMethod } from '@/types' /** @@ -29,6 +30,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const periodType = searchParams.get('periodType') as VatPeriodType | null const yearStr = searchParams.get('year') @@ -94,7 +97,7 @@ export async function GET(request: Request) { const { data: settings } = await supabase .from('company_settings') .select('accounting_method') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual' @@ -102,7 +105,7 @@ export async function GET(request: Request) { try { const declaration = await calculateVatDeclaration( supabase, - user.id, + companyId, periodType, year, period, diff --git a/app/api/sandbox/seed/route.ts b/app/api/sandbox/seed/route.ts index 308b1683..e643c923 100644 --- a/app/api/sandbox/seed/route.ts +++ b/app/api/sandbox/seed/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' /** * POST /api/sandbox/seed @@ -18,11 +19,13 @@ export async function POST() { return NextResponse.json({ error: 'Sandbox is only available for anonymous users' }, { status: 403 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Idempotency: if already seeded, return early const { data: existing } = await supabase .from('company_settings') .select('id') - .eq('user_id', user.id) + .eq('company_id', companyId) .maybeSingle() if (existing) { @@ -43,6 +46,7 @@ export async function POST() { .from('company_settings') .insert({ user_id: userId, + company_id: companyId, entity_type: 'enskild_firma', company_name: 'Sandlådan Konsult', org_number: '199001011234', @@ -80,6 +84,7 @@ export async function POST() { .from('fiscal_periods') .insert({ user_id: userId, + company_id: companyId, name: `Räkenskapsår ${currentYear}`, period_start: `${currentYear}-01-01`, period_end: `${currentYear}-12-31`, @@ -95,6 +100,7 @@ export async function POST() { .insert([ { user_id: userId, + company_id: companyId, name: 'Björk & Partner AB', customer_type: 'swedish_business', email: 'faktura@bjorkpartner.se', @@ -109,6 +115,7 @@ export async function POST() { }, { user_id: userId, + company_id: companyId, name: 'Schmidt GmbH', customer_type: 'eu_business', email: 'billing@schmidt.de', @@ -123,6 +130,7 @@ export async function POST() { }, { user_id: userId, + company_id: companyId, name: 'Anna Lindström', customer_type: 'individual', email: 'anna.lindstrom@example.com', @@ -158,6 +166,7 @@ export async function POST() { .insert([ { user_id: userId, + company_id: companyId, customer_id: customerMap['Björk & Partner AB'], invoice_number: 'F-2026001', invoice_date: toDateStr(thirtyDaysAgo), @@ -175,6 +184,7 @@ export async function POST() { }, { user_id: userId, + company_id: companyId, customer_id: customerMap['Schmidt GmbH'], invoice_number: 'F-2026002', invoice_date: toDateStr(fifteenDaysAgo), @@ -190,6 +200,7 @@ export async function POST() { }, { user_id: userId, + company_id: companyId, customer_id: customerMap['Anna Lindström'], invoice_number: 'F-2026003', invoice_date: toDateStr(thirtyDaysAgo), @@ -205,6 +216,7 @@ export async function POST() { }, { user_id: userId, + company_id: companyId, customer_id: customerMap['Björk & Partner AB'], invoice_number: 'F-2026004', invoice_date: toDateStr(today), @@ -273,7 +285,7 @@ export async function POST() { const { data: accounts } = await supabase .from('chart_of_accounts') .select('id, account_number') - .eq('user_id', userId) + .eq('company_id', companyId) .in('account_number', ['1510', '1930', '2611', '3001']) const accountMap = Object.fromEntries( @@ -291,6 +303,7 @@ export async function POST() { .from('journal_entries') .insert({ user_id: userId, + company_id: companyId, fiscal_period_id: fiscalPeriod.id, voucher_number: voucherNum1 ?? 1, voucher_series: 'A', @@ -316,6 +329,7 @@ export async function POST() { .from('journal_entries') .insert({ user_id: userId, + company_id: companyId, fiscal_period_id: fiscalPeriod.id, voucher_number: voucherNum2 ?? 2, voucher_series: 'A', @@ -388,6 +402,7 @@ export async function POST() { // Categorized expenses { user_id: userId, + company_id: companyId, date: toDateStr(thirtyDaysAgo), description: 'CLAS OHLSON STOCKHOLM', amount: -450, @@ -397,6 +412,7 @@ export async function POST() { }, { user_id: userId, + company_id: companyId, date: toDateStr(fifteenDaysAgo), description: 'GITHUB INC', amount: -999, @@ -406,6 +422,7 @@ export async function POST() { }, { user_id: userId, + company_id: companyId, date: toDateStr(fiveDaysAgo), description: 'SJ BILJETT', amount: -2500, @@ -416,6 +433,7 @@ export async function POST() { // Income matched to paid invoice { user_id: userId, + company_id: companyId, date: toDateStr(fifteenDaysAgo), description: 'BJÖRK & PARTNER AB BETALNING F-2026001', amount: 18750, @@ -428,6 +446,7 @@ export async function POST() { // Private transaction { user_id: userId, + company_id: companyId, date: toDateStr(fiveDaysAgo), description: 'PRIVAT INSÄTTNING', amount: 5000, @@ -437,6 +456,7 @@ export async function POST() { // Uncategorized transactions { user_id: userId, + company_id: companyId, date: toDateStr(fiveDaysAgo), description: 'SWISH BETALNING 0701234567', amount: -350, @@ -445,6 +465,7 @@ export async function POST() { }, { user_id: userId, + company_id: companyId, date: toDateStr(today), description: 'INSÄTTNING BANKGIRO', amount: 1200, @@ -453,6 +474,7 @@ export async function POST() { }, { user_id: userId, + company_id: companyId, date: toDateStr(today), description: 'KORTBETALNING RESTAURANG', amount: -680, @@ -473,6 +495,7 @@ export async function POST() { .insert([ { user_id: userId, + company_id: companyId, title: 'Momsdeklaration Q1 2026', due_date: toDateStr(momsDeadline), deadline_type: 'tax', @@ -485,6 +508,7 @@ export async function POST() { }, { user_id: userId, + company_id: companyId, title: 'Inkomstdeklaration 2025', due_date: `${currentYear}-05-02`, deadline_type: 'tax', diff --git a/app/api/settings/api-keys/[id]/route.ts b/app/api/settings/api-keys/[id]/route.ts index 9caa5ec7..0d0e0e6d 100644 --- a/app/api/settings/api-keys/[id]/route.ts +++ b/app/api/settings/api-keys/[id]/route.ts @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' /** * DELETE /api/settings/api-keys/[id] — Revoke an API key (soft delete) @@ -16,11 +17,13 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { error } = await supabase .from('api_keys') .update({ revoked_at: new Date().toISOString() }) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .is('revoked_at', null) if (error) { diff --git a/app/api/settings/api-keys/route.ts b/app/api/settings/api-keys/route.ts index fb2c52d4..3dae6b34 100644 --- a/app/api/settings/api-keys/route.ts +++ b/app/api/settings/api-keys/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateApiKey, hashApiKey, DEFAULT_SCOPES, validateScopes } from '@/lib/auth/api-keys' +import { requireCompanyId } from '@/lib/company/context' import type { ApiKeyScope } from '@/lib/auth/api-keys' /** @@ -14,10 +15,12 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase .from('api_keys') .select('id, key_prefix, name, scopes, rate_limit_rpm, last_used_at, revoked_at, created_at') - .eq('user_id', user.id) + .eq('company_id', companyId) .order('created_at', { ascending: false }) if (error) { @@ -39,6 +42,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + let name = 'Unnamed key' let scopes: ApiKeyScope[] = DEFAULT_SCOPES try { @@ -54,11 +59,11 @@ export async function POST(request: Request) { // Empty body is fine, use defaults } - // Limit to 10 active keys per user + // Limit to 10 active keys per company const { count } = await supabase .from('api_keys') .select('id', { count: 'exact', head: true }) - .eq('user_id', user.id) + .eq('company_id', companyId) .is('revoked_at', null) if (count !== null && count >= 10) { @@ -74,6 +79,7 @@ export async function POST(request: Request) { .from('api_keys') .insert({ user_id: user.id, + company_id: companyId, key_hash: hash, key_prefix: prefix, name, diff --git a/app/api/settings/counterparty-templates/route.ts b/app/api/settings/counterparty-templates/route.ts index c09db71f..739c7587 100644 --- a/app/api/settings/counterparty-templates/route.ts +++ b/app/api/settings/counterparty-templates/route.ts @@ -1,15 +1,18 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' 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) + const { data, error } = await supabase .from('categorization_templates') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('is_active', true) .order('occurrence_count', { ascending: false }) @@ -23,6 +26,8 @@ export async function DELETE(request: Request) { const { data: { user } } = await supabase.auth.getUser() if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const companyId = await requireCompanyId(supabase, user.id) + let id: string | undefined try { const body = await request.json() @@ -36,7 +41,7 @@ export async function DELETE(request: Request) { .from('categorization_templates') .update({ is_active: false }) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) if (error) return NextResponse.json({ error: error.message }, { status: 500 }) diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 98abad47..13bd0378 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { didTaxFieldsChange, regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator' import { validateBody } from '@/lib/api/validate' import { UpdateSettingsSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' export async function GET() { const supabase = await createClient() @@ -13,10 +14,12 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase .from('company_settings') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (error) { @@ -35,11 +38,13 @@ export async function PUT(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Fetch current settings to check for tax-relevant changes const { data: oldSettings } = await supabase .from('company_settings') .select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month, onboarding_complete') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const validation = await validateBody(request, UpdateSettingsSchema) @@ -65,7 +70,7 @@ export async function PUT(request: Request) { const { data, error } = await supabase .from('company_settings') .update(body) - .eq('user_id', user.id) + .eq('company_id', companyId) .select() .single() @@ -76,7 +81,7 @@ export async function PUT(request: Request) { // Check if tax-relevant fields changed and regenerate deadlines if (oldSettings && didTaxFieldsChange(oldSettings, data)) { try { - await regenerateTaxDeadlinesForUser(supabase, user.id, { + await regenerateTaxDeadlinesForUser(supabase, companyId, { entity_type: data.entity_type, moms_period: data.moms_period, f_skatt: data.f_skatt, diff --git a/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts index c1666595..0cde102e 100644 --- a/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts @@ -16,6 +16,11 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + import { eventBus } from '@/lib/events' import { POST } from '../route' diff --git a/app/api/supplier-invoices/[id]/approve/route.ts b/app/api/supplier-invoices/[id]/approve/route.ts index 30dd3c04..151c70e7 100644 --- a/app/api/supplier-invoices/[id]/approve/route.ts +++ b/app/api/supplier-invoices/[id]/approve/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' import type { SupplierInvoice } from '@/types' ensureInitialized() @@ -19,11 +20,13 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data: invoice } = await supabase .from('supplier_invoices') .select('*') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (!invoice) { @@ -41,7 +44,7 @@ export async function POST( .from('supplier_invoices') .update({ status: 'approved' }) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .select() .single() @@ -52,7 +55,7 @@ export async function POST( try { await eventBus.emit({ type: 'supplier_invoice.approved', - payload: { supplierInvoice: data as SupplierInvoice, userId: user.id }, + payload: { supplierInvoice: data as SupplierInvoice, companyId, userId: user.id }, }) } catch { // Non-blocking diff --git a/app/api/supplier-invoices/[id]/credit/route.ts b/app/api/supplier-invoices/[id]/credit/route.ts index 43989e3a..bcff6750 100644 --- a/app/api/supplier-invoices/[id]/credit/route.ts +++ b/app/api/supplier-invoices/[id]/credit/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import { requireCompanyId } from '@/lib/company/context' import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types' ensureInitialized() @@ -20,12 +21,14 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Fetch original invoice with supplier and items const { data: original, error: fetchError } = await supabase .from('supplier_invoices') .select('*, supplier:suppliers(*), items:supplier_invoice_items(*)') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchError || !original) { @@ -48,6 +51,7 @@ export async function POST( .from('supplier_invoices') .insert({ user_id: user.id, + company_id: companyId, supplier_id: original.supplier_id, arrival_number: arrivalNum, supplier_invoice_number: `KREDIT-${original.supplier_invoice_number}`, @@ -96,7 +100,7 @@ export async function POST( const { data: settings } = await supabase .from('company_settings') .select('accounting_method') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual' @@ -108,6 +112,7 @@ export async function POST( try { const journalEntry = await createSupplierCreditNoteEntry( supabase, + companyId, user.id, creditNote as SupplierInvoice, creditItems as SupplierInvoiceItem[], @@ -144,6 +149,7 @@ export async function POST( payload: { supplierInvoice: original as SupplierInvoice, creditNote: creditNote as SupplierInvoice, + companyId, userId: user.id, }, }) diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts index 4692cdb5..e065d43d 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts @@ -17,6 +17,11 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + const mockCreateSupplierInvoicePaymentEntry = vi.fn() const mockCreateSupplierInvoiceCashEntry = vi.fn() vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index c9b0b184..7915c8e3 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -8,6 +8,7 @@ import { } from '@/lib/bookkeeping/supplier-invoice-entries' import { validateBody } from '@/lib/api/validate' import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' ensureInitialized() @@ -25,6 +26,8 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, MarkSupplierInvoicePaidSchema) if (!validation.success) return validation.response const body = validation.data @@ -34,7 +37,7 @@ export async function POST( .from('supplier_invoices') .select('*, supplier:suppliers(*), items:supplier_invoice_items(*)') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchError || !invoice) { @@ -56,7 +59,7 @@ export async function POST( const { data: settings } = await supabase .from('company_settings') .select('accounting_method') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const accountingMethod = settings?.accounting_method || 'accrual' @@ -68,6 +71,7 @@ export async function POST( if (accountingMethod === 'cash') { const journalEntry = await createSupplierInvoiceCashEntry( supabase, + companyId, user.id, invoice as SupplierInvoice, (invoice.items || []) as SupplierInvoiceItem[], @@ -79,6 +83,7 @@ export async function POST( } else { const journalEntry = await createSupplierInvoicePaymentEntry( supabase, + companyId, user.id, invoice as SupplierInvoice, paymentAmount, @@ -134,7 +139,7 @@ export async function POST( try { await eventBus.emit({ type: 'supplier_invoice.paid', - payload: { supplierInvoice: invoice as SupplierInvoice, paymentAmount, userId: user.id }, + payload: { supplierInvoice: invoice as SupplierInvoice, paymentAmount, companyId, userId: user.id }, }) } catch { // Non-blocking diff --git a/app/api/supplier-invoices/[id]/route.ts b/app/api/supplier-invoices/[id]/route.ts index 22e54afd..72f01894 100644 --- a/app/api/supplier-invoices/[id]/route.ts +++ b/app/api/supplier-invoices/[id]/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' export async function GET( _request: Request, @@ -16,11 +17,13 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data: invoice, error } = await supabase .from('supplier_invoices') .select('*, supplier:suppliers(*), items:supplier_invoice_items(*), payments:supplier_invoice_payments(*)') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (error || !invoice) { @@ -43,12 +46,14 @@ export async function PUT( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Only allow editing registered invoices const { data: existing } = await supabase .from('supplier_invoices') .select('status') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (!existing) { @@ -70,7 +75,7 @@ export async function PUT( .from('supplier_invoices') .update(body) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .select() .single() @@ -94,12 +99,14 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Only allow deleting registered invoices without journal entries const { data: existing } = await supabase .from('supplier_invoices') .select('status, registration_journal_entry_id') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (!existing) { @@ -120,7 +127,7 @@ export async function DELETE( .from('supplier_invoices') .delete() .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) if (error) { return NextResponse.json({ error: error.message }, { status: 500 }) diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index 09d93030..a35312ba 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -16,6 +16,11 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + const mockFindFiscalPeriod = vi.fn() vi.mock('@/lib/bookkeeping/engine', () => ({ findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args), diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index 38648586..bbef5930 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -5,6 +5,7 @@ import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/suppli import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' ensureInitialized() @@ -18,13 +19,15 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { searchParams } = new URL(request.url) const status = searchParams.get('status') let query = supabase .from('supplier_invoices') .select('*, supplier:suppliers(id, name)') - .eq('user_id', user.id) + .eq('company_id', companyId) if (status && status !== 'all') { if (status === 'to_pay') { @@ -52,6 +55,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, CreateSupplierInvoiceSchema) if (!validation.success) return validation.response const body = validation.data @@ -61,7 +66,7 @@ export async function POST(request: Request) { .from('suppliers') .select('*') .eq('id', body.supplier_id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (supplierError || !supplier) { @@ -111,6 +116,7 @@ export async function POST(request: Request) { .from('supplier_invoices') .insert({ user_id: user.id, + company_id: companyId, supplier_id: body.supplier_id, arrival_number: arrivalNum, supplier_invoice_number: body.supplier_invoice_number, @@ -159,7 +165,7 @@ export async function POST(request: Request) { const { data: settings } = await supabase .from('company_settings') .select('accounting_method') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const accountingMethod = settings?.accounting_method || 'accrual' @@ -169,6 +175,7 @@ export async function POST(request: Request) { try { const journalEntry = await createSupplierInvoiceRegistrationEntry( supabase, + companyId, user.id, invoice as SupplierInvoice, items as SupplierInvoiceItem[], @@ -190,7 +197,7 @@ export async function POST(request: Request) { try { await eventBus.emit({ type: 'supplier_invoice.registered', - payload: { supplierInvoice: invoice as SupplierInvoice, userId: user.id }, + payload: { supplierInvoice: invoice as SupplierInvoice, companyId, userId: user.id }, }) } catch { // Non-blocking — event emission failure should not affect the response diff --git a/app/api/suppliers/[id]/route.ts b/app/api/suppliers/[id]/route.ts index 3fe18bc2..c4f9ab6c 100644 --- a/app/api/suppliers/[id]/route.ts +++ b/app/api/suppliers/[id]/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { UpdateSupplierSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' export async function GET( _request: Request, @@ -16,12 +17,14 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Fetch supplier const { data: supplier, error } = await supabase .from('suppliers') .select('*') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (error || !supplier) { @@ -33,7 +36,7 @@ export async function GET( .from('supplier_invoices') .select('status, total, remaining_amount, paid_amount') .eq('supplier_id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) const stats = { total_outstanding: 0, @@ -67,6 +70,8 @@ export async function PUT( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const result = await validateBody(request, UpdateSupplierSchema) if (!result.success) return result.response const body = result.data @@ -96,7 +101,7 @@ export async function PUT( notes: body.notes, }) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .select() .single() @@ -120,12 +125,14 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Check for linked invoices const { count } = await supabase .from('supplier_invoices') .select('id', { count: 'exact', head: true }) .eq('supplier_id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) if (count && count > 0) { return NextResponse.json( @@ -138,7 +145,7 @@ export async function DELETE( .from('suppliers') .delete() .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) if (error) { return NextResponse.json({ error: error.message }, { status: 500 }) diff --git a/app/api/suppliers/route.ts b/app/api/suppliers/route.ts index 7fe057ea..6985bbcf 100644 --- a/app/api/suppliers/route.ts +++ b/app/api/suppliers/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { CreateSupplierSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' export async function GET() { const supabase = await createClient() @@ -12,10 +13,12 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase .from('suppliers') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .order('name', { ascending: true }) if (error) { @@ -34,6 +37,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const result = await validateBody(request, CreateSupplierSchema) if (!result.success) return result.response const body = result.data @@ -42,6 +47,7 @@ export async function POST(request: Request) { .from('suppliers') .insert({ user_id: user.id, + company_id: companyId, name: body.name, supplier_type: body.supplier_type, email: body.email, diff --git a/app/api/support/contact/route.ts b/app/api/support/contact/route.ts index 34a108c1..3c28754b 100644 --- a/app/api/support/contact/route.ts +++ b/app/api/support/contact/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { getEmailService } from '@/lib/email/service' import { SUPPORT_RECIPIENT_EMAIL } from '@/lib/support' +import { requireCompanyId } from '@/lib/company/context' function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') @@ -12,6 +13,8 @@ export async function POST(request: Request) { const { data: { user } } = await supabase.auth.getUser() if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + await requireCompanyId(supabase, user.id) + let body: { subject?: string; message?: string } try { body = await request.json() diff --git a/app/api/tax-deadlines/generate/route.ts b/app/api/tax-deadlines/generate/route.ts index 07404e50..34ff35e7 100644 --- a/app/api/tax-deadlines/generate/route.ts +++ b/app/api/tax-deadlines/generate/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator' +import { requireCompanyId } from '@/lib/company/context' /** * POST /api/tax-deadlines/generate @@ -15,11 +16,13 @@ export async function POST() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + 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('user_id', user.id) + .eq('company_id', companyId) .single() if (settingsError || !settings) { @@ -30,7 +33,7 @@ export async function POST() { } try { - const result = await regenerateTaxDeadlinesForUser(supabase, user.id, { + const result = await regenerateTaxDeadlinesForUser(supabase, companyId, { entity_type: settings.entity_type, moms_period: settings.moms_period, f_skatt: settings.f_skatt, diff --git a/app/api/team/accept/route.ts b/app/api/team/accept/route.ts new file mode 100644 index 00000000..e97016c5 --- /dev/null +++ b/app/api/team/accept/route.ts @@ -0,0 +1,133 @@ +import { createClient, createServiceClient } from '@/lib/supabase/server' +import { NextResponse, type NextRequest } from 'next/server' +import { hashInviteToken } from '@/lib/auth/invite-tokens' + +/** + * GET /api/team/accept?token=xxx + * Validates an invite token and returns invite info (for the invite page). + * Only company invitations are supported — team invitations are disabled. + * No auth required — this is a public endpoint. + */ +export async function GET(request: NextRequest) { + const token = request.nextUrl.searchParams.get('token') + if (!token) { + return NextResponse.json({ error: 'Token saknas.' }, { status: 400 }) + } + + const tokenHash = hashInviteToken(token) + const serviceClient = createServiceClient() + + const { data: companyInvite } = await serviceClient + .from('company_invitations') + .select('id, email, status, expires_at, company_id, companies:company_id(name)') + .eq('token_hash', tokenHash) + .single() + + if (!companyInvite) { + return NextResponse.json({ error: 'Inbjudan hittades inte eller är ogiltig.' }, { status: 404 }) + } + + if (companyInvite.status !== 'pending') { + return NextResponse.json({ error: 'Inbjudan har redan använts.' }, { status: 410 }) + } + + const expired = new Date(companyInvite.expires_at) < new Date() + + const { data: existingUsers } = await serviceClient.auth.admin.listUsers() + const alreadyHasAccount = existingUsers?.users?.some( + (u) => u.email?.toLowerCase() === companyInvite.email.toLowerCase() + ) ?? false + + return NextResponse.json({ + data: { + type: 'company', + companyName: (companyInvite.companies as unknown as { name: string })?.name || 'Företag', + email: companyInvite.email, + expired, + alreadyHasAccount, + }, + }) +} + +/** + * POST /api/team/accept + * Accepts a company invite after the user has signed up. + * Team invitations are disabled — teams are single-user. + */ +export async function POST(request: NextRequest) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const token = body.token as string + if (!token) { + return NextResponse.json({ error: 'Token saknas.' }, { status: 400 }) + } + + const tokenHash = hashInviteToken(token) + const serviceClient = createServiceClient() + + const { data: companyInvite, error: companyLookupError } = await serviceClient + .from('company_invitations') + .select('id, company_id, email, role, status, expires_at') + .eq('token_hash', tokenHash) + .single() + + if (companyLookupError) { + console.error('[team/accept] company lookup error:', companyLookupError.message) + } + + if (!companyInvite || companyInvite.status !== 'pending') { + return NextResponse.json({ error: 'Inbjudan är ogiltig.' }, { status: 400 }) + } + + if (new Date(companyInvite.expires_at) < new Date()) { + await serviceClient + .from('company_invitations') + .update({ status: 'expired' }) + .eq('id', companyInvite.id) + return NextResponse.json({ error: 'Inbjudan har gått ut.' }, { status: 410 }) + } + + if (user.email?.toLowerCase() !== companyInvite.email.toLowerCase()) { + return NextResponse.json({ error: 'E-postadressen matchar inte inbjudan.' }, { status: 403 }) + } + + // Add user to company + const { error: memberError } = await serviceClient + .from('company_members') + .insert({ + company_id: companyInvite.company_id, + user_id: user.id, + role: companyInvite.role, + source: 'direct', + }) + + if (memberError) { + if (memberError.code === '23505') { + return NextResponse.json({ error: 'Du är redan medlem.' }, { status: 409 }) + } + return NextResponse.json({ error: 'Kunde inte lägga till medlem.' }, { status: 500 }) + } + + // Set active company + await serviceClient + .from('user_preferences') + .upsert({ + user_id: user.id, + active_company_id: companyInvite.company_id, + }, { onConflict: 'user_id' }) + + // Mark invite as accepted + await serviceClient + .from('company_invitations') + .update({ status: 'accepted' }) + .eq('id', companyInvite.id) + + return NextResponse.json({ + data: { type: 'company', companyId: companyInvite.company_id }, + }) +} diff --git a/app/api/team/invite/[id]/route.ts b/app/api/team/invite/[id]/route.ts new file mode 100644 index 00000000..ff7ac7d7 --- /dev/null +++ b/app/api/team/invite/[id]/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server' + +/** + * DELETE /api/team/invite/[id] + * Team-level invitations are disabled. + */ +export async function DELETE() { + return NextResponse.json( + { error: 'Teaminbjudningar är inaktiverade.' }, + { status: 403 } + ) +} diff --git a/app/api/team/invite/route.ts b/app/api/team/invite/route.ts new file mode 100644 index 00000000..90b8583b --- /dev/null +++ b/app/api/team/invite/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' + +/** + * POST /api/team/invite + * Team-level invitations are disabled. + * Invitations should be made at the företag (company) level instead. + */ +export async function POST() { + return NextResponse.json( + { error: 'Teaminbjudningar är inaktiverade. Bjud in via enskilda företag.' }, + { status: 403 } + ) +} diff --git a/app/api/team/members/[id]/route.ts b/app/api/team/members/[id]/route.ts new file mode 100644 index 00000000..609b73bd --- /dev/null +++ b/app/api/team/members/[id]/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server' + +/** + * DELETE /api/team/members/[id] + * Removing team members is disabled — teams are single-user. + */ +export async function DELETE() { + return NextResponse.json( + { error: 'Team har bara en ägare och kan inte ändras.' }, + { status: 403 } + ) +} diff --git a/app/api/team/members/route.ts b/app/api/team/members/route.ts new file mode 100644 index 00000000..fcc07a05 --- /dev/null +++ b/app/api/team/members/route.ts @@ -0,0 +1,91 @@ +import { createClient, createServiceClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' + +/** + * GET /api/team/members + * Returns team members (single-user teams — no invitations). + */ +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 serviceClient = await createServiceClient() + + // Find the user's team membership + const { data: myMembership } = await serviceClient + .from('team_members') + .select('team_id, role') + .eq('user_id', user.id) + .limit(1) + .single() + + if (!myMembership) { + // User is not in any team — check if they own a company (could start a team) + const { data: ownedCompany } = await serviceClient + .from('company_members') + .select('id') + .eq('user_id', user.id) + .eq('role', 'owner') + .limit(1) + .maybeSingle() + + return NextResponse.json({ + data: { + members: [], + teamName: null, + teamId: null, + isOwner: false, + hasTeam: false, + ownsCompany: !!ownedCompany, + }, + }) + } + + const teamId = myMembership.team_id + const isOwner = myMembership.role === 'owner' + + // Fetch team name + const { data: team } = await serviceClient + .from('teams') + .select('name') + .eq('id', teamId) + .single() + + // Fetch all team members (owner is a real row now) + const { data: members, error: membersError } = await serviceClient + .from('team_members') + .select('id, team_id, user_id, role, joined_at') + .eq('team_id', teamId) + .order('joined_at', { ascending: true }) + + if (membersError) { + return NextResponse.json({ error: 'Kunde inte hämta teammedlemmar.' }, { status: 500 }) + } + + // Fetch emails from profiles + const userIds = (members || []).map((m) => m.user_id) + const { data: profiles } = await serviceClient + .from('profiles') + .select('id, email') + .in('id', userIds) + + const emailMap = new Map((profiles || []).map((p) => [p.id, p.email])) + + return NextResponse.json({ + data: { + members: (members || []).map((m) => ({ + id: m.id, + user_id: m.user_id, + email: emailMap.get(m.user_id) || '', + role: m.role, + joined_at: m.joined_at, + is_current_user: m.user_id === user.id, + })), + teamName: team?.name || null, + teamId, + isOwner, + hasTeam: true, + }, + }) +} diff --git a/app/api/transactions/[id]/book/__tests__/route.test.ts b/app/api/transactions/[id]/book/__tests__/route.test.ts index d99ef199..3d75ea05 100644 --- a/app/api/transactions/[id]/book/__tests__/route.test.ts +++ b/app/api/transactions/[id]/book/__tests__/route.test.ts @@ -18,6 +18,10 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + const mockCreateJournalEntry = vi.fn() vi.mock('@/lib/bookkeeping/engine', () => ({ createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), @@ -155,7 +159,7 @@ describe('POST /api/transactions/[id]/book', () => { expect(body.journal_entry_id).toBe('je-new') expect(body.data.id).toBe('je-new') - expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', { + expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', { fiscal_period_id: VALID_UUID, entry_date: '2025-01-15', description: 'Test booking', diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts index 621a22cb..f2a2a057 100644 --- a/app/api/transactions/[id]/book/route.ts +++ b/app/api/transactions/[id]/book/route.ts @@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init' import { createJournalEntry } from '@/lib/bookkeeping/engine' import { validateBody } from '@/lib/api/validate' import { BookTransactionSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' import type { Transaction } from '@/types' ensureInitialized() @@ -22,6 +23,8 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, BookTransactionSchema) if (!validation.success) return validation.response const { fiscal_period_id, entry_date, description, lines } = validation.data @@ -31,7 +34,7 @@ export async function POST( .from('transactions') .select('*') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchError || !transaction) { @@ -49,7 +52,7 @@ export async function POST( // Create journal entry via the engine let journalEntry try { - journalEntry = await createJournalEntry(supabase, user.id, { + journalEntry = await createJournalEntry(supabase, companyId, user.id, { fiscal_period_id, entry_date, description, @@ -90,6 +93,7 @@ export async function POST( account: lines[0]?.account_number || '', taxCode: '', userId: user.id, + companyId, }, }) } catch { diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 0f97c3a6..cdff8a3a 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -17,6 +17,11 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + const mockBuildMappingResultFromCategory = vi.fn() vi.mock('@/lib/bookkeeping/category-mapping', () => ({ buildMappingResultFromCategory: (...args: unknown[]) => @@ -161,7 +166,7 @@ describe('POST /api/transactions/[id]/categorize', () => { expect(body.category).toBe('expense_software') expect(mockSaveUserMappingRule).toHaveBeenCalledWith( expect.anything(), - 'user-1', + 'company-1', 'GitHub', '6200', '1930', diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 99b1fa17..d961eaeb 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -7,6 +7,7 @@ import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEnt import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine' import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' +import { requireCompanyId } from '@/lib/company/context' import type { CategorizationTemplate } from '@/types' import { validateBody } from '@/lib/api/validate' import { CategorizeTransactionSchema } from '@/lib/api/schemas' @@ -20,6 +21,7 @@ ensureInitialized() async function ensureFiscalPeriod( supabase: Awaited>, userId: string, + companyId: string, date: string, fiscalYearStartMonth: number = 1 ): Promise { @@ -27,7 +29,7 @@ async function ensureFiscalPeriod( const { data: existing } = await supabase .from('fiscal_periods') .select('id') - .eq('user_id', userId) + .eq('company_id', companyId) .lte('period_start', date) .gte('period_end', date) .eq('is_closed', false) @@ -68,11 +70,12 @@ async function ensureFiscalPeriod( .from('fiscal_periods') .upsert({ user_id: userId, + company_id: companyId, name: periodName, period_start: periodStart, period_end: periodEnd, }, { - onConflict: 'user_id,period_start,period_end', + onConflict: 'company_id,period_start,period_end', }) if (error) { @@ -96,6 +99,8 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Parse and validate request body const validation = await validateBody(request, CategorizeTransactionSchema) if (!validation.success) return validation.response @@ -107,7 +112,7 @@ export async function POST( .from('transactions') .select('*') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchError || !transaction) { @@ -149,7 +154,7 @@ export async function POST( const { data: settings } = await supabase .from('company_settings') .select('entity_type, fiscal_year_start_month') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' @@ -188,7 +193,7 @@ export async function POST( .from('categorization_templates') .select('*') .eq('id', body.counterparty_template_id) - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('is_active', true) .maybeSingle() @@ -233,7 +238,7 @@ export async function POST( const { data: accountExists } = await supabase .from('chart_of_accounts') .select('account_number, account_class') - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('account_number', body.account_override) .single() @@ -266,7 +271,7 @@ export async function POST( } // Ensure fiscal period exists for the transaction date - await ensureFiscalPeriod(supabase, user.id, transaction.date, fiscalYearStartMonth) + await ensureFiscalPeriod(supabase, user.id, companyId, transaction.date, fiscalYearStartMonth) // Try to create journal entry let journalEntryCreated = false @@ -276,6 +281,7 @@ export async function POST( try { const journalEntry = await createTransactionJournalEntry( supabase, + companyId, user.id, transaction as Transaction, mappingResult @@ -296,7 +302,7 @@ export async function POST( try { await saveUserMappingRule( supabase, - user.id, + companyId, transaction.merchant_name, mappingResult.debit_account, mappingResult.credit_account, @@ -333,7 +339,7 @@ export async function POST( .from('document_attachments') .update({ journal_entry_id: journalEntryId }) .eq('id', receipt.document_id) - .eq('user_id', user.id) + .eq('company_id', companyId) } } catch (linkErr) { console.error('[categorize] Failed to link receipt document:', linkErr) @@ -347,7 +353,7 @@ export async function POST( .from('invoice_inbox_items') .update({ status: 'confirmed' }) .eq('id', body.inbox_item_id) - .eq('user_id', user.id) + .eq('company_id', companyId) // Link inbox item's document to the journal entry if (journalEntryId) { @@ -362,7 +368,7 @@ export async function POST( .from('document_attachments') .update({ journal_entry_id: journalEntryId }) .eq('id', inboxItem.document_id) - .eq('user_id', user.id) + .eq('company_id', companyId) } } } catch (inboxErr) { @@ -395,6 +401,7 @@ export async function POST( account: mappingResult.debit_account, taxCode: mappingResult.vat_lines[0]?.account_number || '', userId: user.id, + companyId, }, }) diff --git a/app/api/transactions/[id]/describe/__tests__/route.test.ts b/app/api/transactions/[id]/describe/__tests__/route.test.ts index d25f774f..f4427d4d 100644 --- a/app/api/transactions/[id]/describe/__tests__/route.test.ts +++ b/app/api/transactions/[id]/describe/__tests__/route.test.ts @@ -37,6 +37,11 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: (...args: unknown[]) => mockCreateClient(...args), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + describe('POST /api/transactions/[id]/describe', () => { let POST: typeof import('../route').POST diff --git a/app/api/transactions/[id]/describe/route.ts b/app/api/transactions/[id]/describe/route.ts index 50993814..2e2c7cf5 100644 --- a/app/api/transactions/[id]/describe/route.ts +++ b/app/api/transactions/[id]/describe/route.ts @@ -6,6 +6,7 @@ import { DescribeTransactionSchema } from '@/lib/api/schemas' import { extensionRegistry } from '@/lib/extensions/registry' import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates' import { findCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' +import { requireCompanyId } from '@/lib/company/context' import type { Transaction, EntityType, VatTreatment } from '@/types' import type { Extension } from '@/lib/extensions/types' @@ -82,6 +83,8 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, DescribeTransactionSchema) if (!validation.success) return validation.response const { description } = validation.data @@ -91,7 +94,7 @@ export async function POST( .from('transactions') .select('*') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchError || !transaction) { @@ -102,7 +105,7 @@ export async function POST( const { data: settings } = await supabase .from('company_settings') .select('entity_type') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' @@ -155,7 +158,7 @@ export async function POST( const { count } = await supabase .from('transactions') .select('id', { count: 'exact', head: true }) - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('merchant_name', transaction.merchant_name) .is('journal_entry_id', null) .neq('id', id) diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index f9f94343..4b3facef 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -40,6 +40,10 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + import { POST } from '../route' const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000' @@ -212,6 +216,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Verify accrual payment entry was called with paymentAmount expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith( expect.anything(), + 'company-1', 'user-1', expect.objectContaining({ id: VALID_UUID }), '2024-06-15', @@ -270,7 +275,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(status).toBe(200) expect(body.success).toBe(true) expect(body.journal_entry_id).toBe('je-payment') - expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'je-conflict') + expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'je-conflict') }) it('returns 500 when storno fails — no partial state change', async () => { diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 594f2678..4ebd6dcd 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -10,6 +10,7 @@ import { MatchInvoiceSchema } from '@/lib/api/schemas' import { logMatchEvent } from '@/lib/invoices/match-log' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' import type { EntityType, Invoice, Transaction } from '@/types' ensureInitialized() @@ -39,6 +40,8 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Parse and validate request body const validation = await validateBody(request, MatchInvoiceSchema) if (!validation.success) return validation.response @@ -49,7 +52,7 @@ export async function POST( .from('transactions') .select('*') .eq('id', transactionId) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchTxError || !transaction) { @@ -77,7 +80,7 @@ export async function POST( .from('invoices') .select('*, customer:customers(*), items:invoice_items(*)') .eq('id', invoice_id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchInvError || !invoice) { @@ -97,7 +100,7 @@ export async function POST( // If storno fails, return 500 immediately — nothing else has been modified. if (transaction.journal_entry_id) { try { - await reverseEntry(supabase, user.id, transaction.journal_entry_id) + await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id) // Clear the journal_entry_id on the transaction const { error: clearJeError } = await supabase @@ -136,7 +139,7 @@ export async function POST( const { data: settings } = await supabase .from('company_settings') .select('accounting_method, entity_type') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const accountingMethod = settings?.accounting_method || 'accrual' @@ -151,6 +154,7 @@ export async function POST( // Kontantmetoden, full payment: combined revenue entry with per-line VAT rates const journalEntry = await createInvoiceCashEntry( supabase, + companyId, user.id, invoice as Invoice, transaction.date, @@ -168,6 +172,7 @@ export async function POST( // final payment when createInvoiceCashEntry is called. const journalEntry = await createInvoicePaymentJournalEntry( supabase, + companyId, user.id, invoice as Invoice, transaction.date, @@ -180,6 +185,7 @@ export async function POST( // Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510) const journalEntry = await createInvoicePaymentJournalEntry( supabase, + companyId, user.id, invoice as Invoice, transaction.date, @@ -235,6 +241,7 @@ export async function POST( .from('invoice_payments') .insert({ user_id: user.id, + company_id: companyId, invoice_id, payment_date: transaction.date, amount: paidAmount, @@ -292,6 +299,7 @@ export async function POST( invoice: invoice as Invoice, transaction: transaction as Transaction, userId: user.id, + companyId, }, }) } catch { diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index 5673f7e7..27be3275 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -9,6 +9,7 @@ import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas' import { logMatchEvent } from '@/lib/invoices/match-log' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types' ensureInitialized() @@ -31,6 +32,8 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, MatchSupplierInvoiceSchema) if (!validation.success) return validation.response const { supplier_invoice_id } = validation.data @@ -40,7 +43,7 @@ export async function POST( .from('transactions') .select('*') .eq('id', transactionId) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchTxError || !transaction) { @@ -67,7 +70,7 @@ export async function POST( .from('supplier_invoices') .select('*, supplier:suppliers(*), items:supplier_invoice_items(*)') .eq('id', supplier_invoice_id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (fetchInvError || !invoice) { @@ -88,7 +91,7 @@ export async function POST( const { data: settings } = await supabase .from('company_settings') .select('accounting_method') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const accountingMethod = settings?.accounting_method || 'accrual' @@ -100,6 +103,7 @@ export async function POST( if (accountingMethod === 'cash') { const journalEntry = await createSupplierInvoiceCashEntry( supabase, + companyId, user.id, invoice as SupplierInvoice, (invoice.items || []) as SupplierInvoiceItem[], @@ -110,6 +114,7 @@ export async function POST( } else { const journalEntry = await createSupplierInvoicePaymentEntry( supabase, + companyId, user.id, invoice as SupplierInvoice, paymentAmount, @@ -157,6 +162,7 @@ export async function POST( .from('supplier_invoice_payments') .insert({ user_id: user.id, + company_id: companyId, supplier_invoice_id, payment_date: transaction.date, amount: paymentAmount, @@ -205,6 +211,7 @@ export async function POST( supplierInvoice: invoice as SupplierInvoice, transaction: transaction as Transaction, userId: user.id, + companyId, }, }) } catch { diff --git a/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts b/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts index cab34204..d40e67f5 100644 --- a/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts @@ -16,6 +16,10 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + const mockReverseEntry = vi.fn() vi.mock('@/lib/bookkeeping/engine', () => ({ reverseEntry: (...args: unknown[]) => mockReverseEntry(...args), @@ -103,6 +107,6 @@ describe('POST /api/transactions/[id]/uncategorize', () => { expect(status).toBe(200) expect(body).toEqual({ success: true }) - expect(mockReverseEntry).toHaveBeenCalledWith(mockSupabase, 'user-1', 'je-1') + expect(mockReverseEntry).toHaveBeenCalledWith(mockSupabase, 'company-1', 'user-1', 'je-1') }) }) diff --git a/app/api/transactions/[id]/uncategorize/route.ts b/app/api/transactions/[id]/uncategorize/route.ts index 9d84748c..b5d6538e 100644 --- a/app/api/transactions/[id]/uncategorize/route.ts +++ b/app/api/transactions/[id]/uncategorize/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { reverseEntry } from '@/lib/bookkeeping/engine' import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -15,6 +16,8 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params // Fetch transaction @@ -22,7 +25,7 @@ export async function POST( .from('transactions') .select('id, journal_entry_id') .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (txError || !transaction) { @@ -38,7 +41,7 @@ export async function POST( .from('journal_entries') .select('id, status') .eq('id', transaction.journal_entry_id) - .eq('user_id', user.id) + .eq('company_id', companyId) .single() if (entryError || !entry) { @@ -51,7 +54,7 @@ export async function POST( // Storno reversal (legally compliant — never deletes) try { - await reverseEntry(supabase, user.id, transaction.journal_entry_id) + await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id) } catch (err) { const message = err instanceof Error ? err.message : 'Reversal failed' return NextResponse.json({ error: message }, { status: 500 }) @@ -66,7 +69,7 @@ export async function POST( journal_entry_id: null, }) .eq('id', id) - .eq('user_id', user.id) + .eq('company_id', companyId) if (updateError) { return NextResponse.json({ error: 'Failed to reset transaction' }, { status: 500 }) diff --git a/app/api/transactions/batch-describe/__tests__/route.test.ts b/app/api/transactions/batch-describe/__tests__/route.test.ts index efa386cb..c12e8ee3 100644 --- a/app/api/transactions/batch-describe/__tests__/route.test.ts +++ b/app/api/transactions/batch-describe/__tests__/route.test.ts @@ -65,6 +65,11 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: (...args: unknown[]) => mockCreateClient(...args), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + describe('POST /api/transactions/batch-describe', () => { let POST: typeof import('../route').POST @@ -171,7 +176,7 @@ describe('POST /api/transactions/batch-describe', () => { // Verify mapping rule was saved with user description expect(mockSaveUserMappingRule).toHaveBeenCalledWith( expect.anything(), - 'user-1', + 'company-1', 'Staples', '6110', '1930', diff --git a/app/api/transactions/batch-describe/route.ts b/app/api/transactions/batch-describe/route.ts index d348296e..ec5ad7ea 100644 --- a/app/api/transactions/batch-describe/route.ts +++ b/app/api/transactions/batch-describe/route.ts @@ -7,6 +7,7 @@ import { BatchDescribeSchema } from '@/lib/api/schemas' import { getTemplateById, buildMappingResultFromTemplate } from '@/lib/bookkeeping/booking-templates' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine' +import { requireCompanyId } from '@/lib/company/context' import type { Transaction, EntityType, TransactionCategory } from '@/types' ensureInitialized() @@ -20,6 +21,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const validation = await validateBody(request, BatchDescribeSchema) if (!validation.success) return validation.response const { merchant_name, template_id, is_business, user_description } = validation.data @@ -34,7 +37,7 @@ export async function POST(request: Request) { const { data: settings } = await supabase .from('company_settings') .select('entity_type, fiscal_year_start_month') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' @@ -44,7 +47,7 @@ export async function POST(request: Request) { const { data: transactions, error: fetchError } = await supabase .from('transactions') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('merchant_name', merchant_name) .is('journal_entry_id', null) .order('date', { ascending: true }) @@ -99,16 +102,18 @@ export async function POST(request: Request) { .from('fiscal_periods') .upsert({ user_id: user.id, + company_id: companyId, name: periodName, period_start: periodStart, period_end: periodEnd, - }, { onConflict: 'user_id,period_start,period_end' }) + }, { onConflict: 'company_id,period_start,period_end' }) // Create journal entry let journalEntryId: string | null = null try { const journalEntry = await createTransactionJournalEntry( supabase, + companyId, user.id, tx as Transaction, mappingResult @@ -137,6 +142,7 @@ export async function POST(request: Request) { account: mappingResult.debit_account, taxCode: mappingResult.vat_lines[0]?.account_number || '', userId: user.id, + companyId, }, }) @@ -154,7 +160,7 @@ export async function POST(request: Request) { const sampleResult = buildMappingResultFromTemplate(template, sampleTx, entityType) await saveUserMappingRule( supabase, - user.id, + companyId, merchant_name, sampleResult.debit_account, sampleResult.credit_account, diff --git a/app/api/transactions/batch-match-invoices/route.ts b/app/api/transactions/batch-match-invoices/route.ts index f5e2050f..ce7bbf1c 100644 --- a/app/api/transactions/batch-match-invoices/route.ts +++ b/app/api/transactions/batch-match-invoices/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching' +import { requireCompanyId } from '@/lib/company/context' import type { Transaction } from '@/types' /** @@ -16,11 +17,13 @@ export async function POST() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + // Fetch uncategorized income transactions without a potential match const { data: transactions, error: txError } = await supabase .from('transactions') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .is('is_business', null) .gt('amount', 0) .is('potential_invoice_id', null) diff --git a/app/api/transactions/suggest-categories/route.ts b/app/api/transactions/suggest-categories/route.ts index 0c480b4c..dfe08ed2 100644 --- a/app/api/transactions/suggest-categories/route.ts +++ b/app/api/transactions/suggest-categories/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions' import { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' +import { requireCompanyId } from '@/lib/company/context' import type { Transaction, EntityType } from '@/types' /** @@ -17,6 +18,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const { transaction_ids } = await request.json() if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) { @@ -30,7 +33,7 @@ export async function POST(request: Request) { const { data: transactions, error: txError } = await supabase .from('transactions') .select('*') - .eq('user_id', user.id) + .eq('company_id', companyId) .in('id', ids) if (txError || !transactions) { @@ -41,7 +44,7 @@ export async function POST(request: Request) { const { data: mappingRules } = await supabase .from('mapping_rules') .select('*') - .or(`user_id.eq.${user.id},user_id.is.null`) + .or(`company_id.eq.${companyId},company_id.is.null`) .eq('is_active', true) .order('priority', { ascending: false }) @@ -49,7 +52,7 @@ export async function POST(request: Request) { const { data: historicalTxns } = await supabase .from('transactions') .select('category') - .eq('user_id', user.id) + .eq('company_id', companyId) .not('is_business', 'is', null) .neq('category', 'uncategorized') .neq('category', 'private') @@ -67,7 +70,7 @@ export async function POST(request: Request) { const { data: aiRecords } = await supabase .from('extension_data') .select('key, value') - .eq('user_id', user.id) + .eq('company_id', companyId) .eq('extension_id', 'ai-categorization') .in('key', aiKeys) @@ -90,12 +93,12 @@ export async function POST(request: Request) { const { data: settings } = await supabase .from('company_settings') .select('entity_type') - .eq('user_id', user.id) + .eq('company_id', companyId) .single() const entityType = (settings?.entity_type as EntityType) || undefined // Batch counterparty template matching (1 DB query, in-memory matching) - const counterpartyMatches = await findCounterpartyTemplatesBatch(supabase, user.id, transactions as Transaction[]) + const counterpartyMatches = await findCounterpartyTemplatesBatch(supabase, companyId, transactions as Transaction[]) // Generate initial suggestions for each transaction const suggestions: Record = {} @@ -147,7 +150,7 @@ export async function POST(request: Request) { const { data: matchedInboxItems } = await supabase .from('invoice_inbox_items') .select('matched_transaction_id, suggested_template_id, suggested_template_confidence') - .eq('user_id', user.id) + .eq('company_id', companyId) .in('matched_transaction_id', ids) .not('suggested_template_id', 'is', null) diff --git a/app/api/vat/validate/__tests__/route.test.ts b/app/api/vat/validate/__tests__/route.test.ts index 77bd2d2c..3741713a 100644 --- a/app/api/vat/validate/__tests__/route.test.ts +++ b/app/api/vat/validate/__tests__/route.test.ts @@ -6,6 +6,11 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn(), })) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + // Mock VIES client const mockValidateVatNumber = vi.fn() vi.mock('@/lib/vat/vies-client', () => ({ diff --git a/app/api/vat/validate/route.ts b/app/api/vat/validate/route.ts index 5c71d493..82d87a8d 100644 --- a/app/api/vat/validate/route.ts +++ b/app/api/vat/validate/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { ValidateVatNumberSchema } from '@/lib/api/schemas' import { validateVatNumber } from '@/lib/vat/vies-client' +import { requireCompanyId } from '@/lib/company/context' export async function POST(request: Request) { const supabase = await createClient() @@ -13,6 +14,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const companyId = await requireCompanyId(supabase, user.id) + const result = await validateBody(request, ValidateVatNumberSchema) if (!result.success) return result.response const { vat_number, customer_id } = result.data @@ -29,7 +32,7 @@ export async function POST(request: Request) { vat_number_validated_at: new Date().toISOString(), }) .eq('id', customer_id) - .eq('user_id', user.id) + .eq('company_id', companyId) } return NextResponse.json(validation) diff --git a/app/companies/new/page.tsx b/app/companies/new/page.tsx new file mode 100644 index 00000000..f52861e7 --- /dev/null +++ b/app/companies/new/page.tsx @@ -0,0 +1,509 @@ +'use client' + +import { useState, useEffect, Suspense } from 'react' +import { useRouter } from 'next/navigation' +import Image from 'next/image' +import Link from 'next/link' +import * as Sentry from '@sentry/nextjs' +import { createClient } from '@/lib/supabase/client' +import { switchCompany } from '@/lib/company/actions' +import { useToast } from '@/components/ui/use-toast' +import { Loader2, ArrowLeft } from 'lucide-react' +import { cn } from '@/lib/utils' +import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration' +import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' +import type { CompanyLookupResult } from '@/lib/company-lookup/types' +import type { CompanySettings, EntityType, MomsPeriod } from '@/types' + +import Step1EntityType from '@/components/onboarding/Step1EntityType' +import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails' +import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration' +import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting' + +const STEP_INFO = [ + { title: 'Nytt företag', subtitle: 'Välj företagsform för det nya företaget.', label: 'Företagsform' }, + { title: 'Företagsuppgifter', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' }, + { title: 'F-skatt & räkenskapsår', subtitle: 'Skatteregistrering och räkenskapsår.', label: 'Skatt' }, + { title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.', label: 'Moms' }, +] + +function translatePeriodError(msg: string): string { + if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.' + if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.' + if (msg.includes('end must be the last day')) return 'Slutdatumet måste vara sista dagen i en månad.' + if (msg.includes('exceeds maximum 18 months')) return 'Räkenskapsåret får inte överstiga 18 månader (BFL 3 kap.).' + return 'Ogiltigt räkenskapsår. Kontrollera datumen och försök igen.' +} + +export default function NewCompanyPage() { + return ( + + +
+ }> + + + ) +} + +const LOG = '[new-company]' + +function logError(message: string, extra?: Record) { + console.error(LOG, message, extra ?? '') + fetch('/api/log', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: `new-company: ${message}`, extra }), + }).catch(() => {}) + Sentry.captureMessage(`new-company: ${message}`, { + level: 'error', + extra: { ...extra, component: 'new-company' }, + }) +} + +function NewCompanyContent() { + const router = useRouter() + const { toast } = useToast() + const supabase = createClient() + + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [currentStep, setCurrentStep] = useState(1) + const [settings, setSettings] = useState>({}) + const [companyId, setCompanyId] = useState(null) + const ticEnabled = ENABLED_EXTENSION_IDS.has('tic') + const [ticLookup, setTicLookup] = useState(null) + + const totalSteps = 4 + + // Just verify auth on mount + useEffect(() => { + async function checkAuth() { + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + router.push('/login') + return + } + setIsLoading(false) + } + checkAuth() + }, [supabase, router]) + + const saveSettings = async (updates: Partial, nextStep?: number) => { + const targetStep = nextStep ?? currentStep + setIsSaving(true) + + try { + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + router.push('/login') + return false + } + + const updatedSettings = { + ...settings, + ...updates, + onboarding_step: targetStep, + } + + if (!companyId) { + logError('save aborted: no companyId', { step: targetStep }) + return false + } + + const { + id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua, + is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, + ...settingsToSave + } = updatedSettings as Record + + const { error } = await supabase + .from('company_settings') + .upsert({ ...settingsToSave, company_id: companyId }, { onConflict: 'company_id' }) + + if (error) { + logError('save failed', { message: error.message, step: targetStep, code: error.code }) + toast({ title: 'Fel', description: error.message || 'Kunde inte spara. Försök igen.', variant: 'destructive' }) + return false + } + + setSettings(updatedSettings) + return true + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + logError('saveSettings threw', { message, step: targetStep }) + Sentry.captureException(err) + toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' }) + return false + } finally { + setIsSaving(false) + } + } + + const handleNext = async (stepData: Partial) => { + if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) { + stepData = { ...stepData, org_number: '', company_name: '' } + setTicLookup(null) + } + + // Step 1: Create the new company + let activeCompanyId = companyId + + if (currentStep === 1 && !activeCompanyId) { + try { + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + router.push('/login') + return + } + + // Atomically create company + owner membership + set active + const { data: newCompanyId, error: companyError } = await supabase.rpc('create_company_with_owner', { + p_name: 'Nytt företag', + p_entity_type: stepData.entity_type, + }) + + if (companyError || !newCompanyId) { + logError('company creation failed', { message: companyError?.message }) + toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' }) + return + } + + activeCompanyId = newCompanyId + setCompanyId(activeCompanyId) + console.log(LOG, 'created company', activeCompanyId) + } catch (err) { + logError('company creation threw', { error: String(err) }) + Sentry.captureException(err) + toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' }) + return + } + } + + if (!activeCompanyId) { + logError('handleNext aborted: no companyId', { step: currentStep }) + return + } + + const nextStep = currentStep + 1 + + // Direct save for step 1 (React batching: companyId state not yet updated) + const needsDirectSave = currentStep === 1 && !companyId + const success = needsDirectSave + ? await (async () => { + setIsSaving(true) + try { + const updatedSettings = { ...settings, ...stepData, onboarding_step: nextStep } + const { + id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua, + is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, + ...settingsToSave + } = updatedSettings as Record + + const { error } = await supabase + .from('company_settings') + .upsert({ ...settingsToSave, company_id: activeCompanyId }, { onConflict: 'company_id' }) + + if (error) { + logError('save failed', { message: error.message, step: nextStep }) + toast({ title: 'Fel', description: error.message || 'Kunde inte spara. Försök igen.', variant: 'destructive' }) + return false + } + + setSettings(updatedSettings) + return true + } catch (err) { + logError('saveSettings threw', { message: String(err), step: nextStep }) + Sentry.captureException(err) + return false + } finally { + setIsSaving(false) + } + })() + : await saveSettings(stepData, nextStep) + + if (!success) return + + // Seed chart of accounts after step 1 + if (currentStep === 1 && stepData.entity_type) { + try { + const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', { + p_company_id: activeCompanyId, + p_entity_type: stepData.entity_type, + }) + if (rpcError) { + logError('COA seeding failed', { entity_type: stepData.entity_type, message: rpcError.message }) + } + } catch (err) { + logError('COA seeding threw', { error: String(err) }) + Sentry.captureException(err) + } + } + + // Create fiscal period after step 3 + if (currentStep === 3 && activeCompanyId) { + try { + const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined + const firstYearStart = stepData.first_year_start as string | undefined + const firstYearEnd = stepData.first_year_end as string | undefined + + let startStr: string + let endStr: string + let periodName: string + + if (isFirstYear && firstYearStart && firstYearEnd) { + startStr = firstYearStart + endStr = firstYearEnd + const startYear = new Date(firstYearStart).getFullYear() + const endYear = new Date(firstYearEnd).getFullYear() + periodName = startYear === endYear + ? `Första räkenskapsåret ${startYear}` + : `Första räkenskapsåret ${startYear}/${endYear}` + } else { + let startMonth = stepData.fiscal_year_start_month || settings.fiscal_year_start_month || 1 + if (settings.entity_type === 'enskild_firma') startMonth = 1 + + const currentYear = new Date().getFullYear() + startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01` + + let endYear: number + let endMonth: number + if (startMonth === 1) { + endYear = currentYear + endMonth = 12 + } else { + endYear = currentYear + 1 + endMonth = startMonth - 1 + } + const lastDay = new Date(endYear, endMonth, 0).getDate() + endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` + + periodName = startMonth === 1 + ? `Räkenskapsår ${currentYear}` + : `Räkenskapsår ${currentYear}/${currentYear + 1}` + } + + const validationError = validatePeriodDuration(startStr, endStr) + if (validationError) { + logError('fiscal period validation failed', { validationError, startStr, endStr }) + toast({ + title: 'Ogiltigt räkenskapsår', + description: translatePeriodError(validationError), + variant: 'destructive', + }) + setCurrentStep(3) + return + } + + // Clean up empty fiscal periods + const { data: existingPeriods } = await supabase + .from('fiscal_periods') + .select('id') + .eq('company_id', activeCompanyId) + + if (existingPeriods && existingPeriods.length > 0) { + for (const ep of existingPeriods) { + const { count } = await supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('fiscal_period_id', ep.id) + + if (count === 0) { + await supabase.from('fiscal_periods').delete().eq('id', ep.id) + } + } + } + + const { error: upsertError } = await supabase.from('fiscal_periods').upsert({ + company_id: activeCompanyId, + name: periodName, + period_start: startStr, + period_end: endStr, + }, { onConflict: 'company_id,period_start,period_end' }) + + if (upsertError) { + logError('fiscal period upsert failed', { message: upsertError.message, startStr, endStr }) + } + } catch (err) { + logError('fiscal period creation threw', { error: String(err) }) + Sentry.captureException(err) + } + } + + // Final step: mark complete, switch to new company, redirect + if (nextStep > totalSteps) { + const finalSuccess = await saveSettings({ onboarding_complete: true }, totalSteps) + if (!finalSuccess) { + logError('failed to set onboarding_complete') + return + } + + // Update company name from settings + if (settings.company_name || stepData.company_name) { + await supabase + .from('companies') + .update({ name: settings.company_name || stepData.company_name }) + .eq('id', activeCompanyId) + } + + // Switch active company to the new one + await switchCompany(activeCompanyId) + + toast({ + title: 'Företag skapat!', + description: 'Du har nu bytt till det nya företaget.', + }) + router.push('/') + } else { + setCurrentStep(nextStep) + } + } + + const handleBack = () => { + if (currentStep > 1) { + setCurrentStep(currentStep - 1) + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + const stepInfo = STEP_INFO[currentStep - 1] + + return ( +
+ {/* Header */} +
+
+
+ + {String(currentStep).padStart(2, '0')} + +
+ +
+
+
+ + + + Gnubok + gnubok +
+
+ {STEP_INFO.map((_, i) => { + const num = i + 1 + return ( +
currentStep && 'w-4 bg-white/[0.1]', + )} + /> + ) + })} +
+ + {currentStep} / {totalSteps} + +
+ +
+

+ {stepInfo.title} +

+

+ {stepInfo.subtitle} +

+
+
+
+ + {/* Form content */} +
+
+
+ {currentStep === 1 && ( + handleNext(data)} + isSaving={isSaving} + /> + )} + + {currentStep === 2 && ( + handleNext(data)} + onBack={handleBack} + isSaving={isSaving} + /> + )} + + {currentStep === 3 && ( + handleNext(data)} + onBack={handleBack} + isSaving={isSaving} + /> + )} + + {currentStep === 4 && ( + handleNext(data)} + onBack={handleBack} + isSaving={isSaving} + /> + )} +
+
+
+
+ ) +} diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx new file mode 100644 index 00000000..9ef51fb9 --- /dev/null +++ b/app/invite/[token]/page.tsx @@ -0,0 +1,175 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Image from 'next/image' +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Loader2, Building2, AlertCircle } from 'lucide-react' + +interface InviteInfo { + type: 'company' + companyName?: string + email: string + expired: boolean + alreadyHasAccount: boolean +} + +export default function InvitePage() { + const params = useParams() + const router = useRouter() + const token = params.token as string + + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [invite, setInvite] = useState(null) + + useEffect(() => { + async function loadInvite() { + try { + const res = await fetch(`/api/team/accept?token=${encodeURIComponent(token)}`) + const data = await res.json() + + if (!res.ok) { + setError(data.error || 'Inbjudan är ogiltig.') + return + } + + setInvite(data.data) + } catch { + setError('Kunde inte ladda inbjudan.') + } finally { + setIsLoading(false) + } + } + loadInvite() + }, [token]) + + const handleAccept = () => { + // Store invite token in cookie before redirecting to register + document.cookie = `gnubok-invite-token=${token}; path=/; max-age=3600; samesite=lax` + router.push(`/register?invite=${encodeURIComponent(token)}`) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+
+
+
+
+ Gnubok + gnubok +
+
+

+ {error ? 'Ogiltig inbjudan' : 'Du har blivit inbjuden'} +

+
+
+
+ +
+
+
+ {error ? ( + +
+ +
+

{error}

+

+ Kontakta personen som bjöd in dig för en ny inbjudan. +

+ + Gå till inloggning + +
+
+
+ ) : invite?.expired ? ( + +
+ +
+

Inbjudan har gått ut

+

+ Be personen som bjöd in dig att skicka en ny inbjudan. +

+
+
+
+ ) : invite?.alreadyHasAccount ? ( + +
+ +
+

E-postadressen har redan ett konto

+

+ {invite.email} är redan registrerad på gnubok. + Kontot måste tas bort innan du kan acceptera inbjudan. +

+ + Gå till inloggning + +
+
+
+ ) : invite ? ( +
+ +
+
+ +
+
+

{invite.companyName}

+

+ Du har bjudits in som medlem till detta företag. +

+
+
+
+ + + +

+ Genom att skapa ett konto godkänner du våra villkor. +

+
+ ) : null} +
+
+
+
+ ) +} diff --git a/components/dashboard/CompanySwitcher.tsx b/components/dashboard/CompanySwitcher.tsx new file mode 100644 index 00000000..d350eea3 --- /dev/null +++ b/components/dashboard/CompanySwitcher.tsx @@ -0,0 +1,253 @@ +'use client' + +import { useState, useRef, useEffect, useTransition, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { cn } from '@/lib/utils' +import { useCompany } from '@/contexts/CompanyContext' +import { switchCompany } from '@/lib/company/actions' +import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react' + +export default function CompanySwitcher() { + const { company, companies, isTeamMember, team } = useCompany() + const router = useRouter() + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() + const triggerRef = useRef(null) + const dropdownRef = useRef(null) + const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 }) + + const updatePosition = useCallback(() => { + if (!triggerRef.current || !dropdownRef.current) return + const triggerRect = triggerRef.current.getBoundingClientRect() + const dropdownRect = dropdownRef.current.getBoundingClientRect() + const margin = 8 + + let top = triggerRect.bottom + 4 + let left = triggerRect.left + + // Clamp right edge to viewport + if (left + dropdownRect.width > window.innerWidth - margin) { + left = Math.max(margin, window.innerWidth - dropdownRect.width - margin) + } + + // If dropdown would go below viewport, show above trigger + if (top + dropdownRect.height > window.innerHeight - margin) { + top = Math.max(margin, triggerRect.top - dropdownRect.height - 4) + } + + setDropdownPos({ top, left }) + }, []) + + // Update position when opening (run twice: once to render, once to measure) + useEffect(() => { + if (!open) return + // First frame: portal mounts, second frame: we can measure it + const raf = requestAnimationFrame(() => updatePosition()) + return () => cancelAnimationFrame(raf) + }, [open, updatePosition]) + + // Close on outside click + useEffect(() => { + if (!open) return + function handleClick(e: MouseEvent) { + const target = e.target as Node + if ( + (!triggerRef.current || !triggerRef.current.contains(target)) && + (!dropdownRef.current || !dropdownRef.current.contains(target)) + ) { + setOpen(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [open]) + + // Close on Escape + useEffect(() => { + if (!open) return + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [open]) + + const handleSwitch = (companyId: string) => { + if (company && companyId === company.id) { + setOpen(false) + return + } + startTransition(async () => { + const result = await switchCompany(companyId) + if (!result.error) { + setOpen(false) + router.refresh() + } + }) + } + + const canOpen = companies.length > 0 || isTeamMember + + // For team members: show team name as header, active company below + // For self-service: just show company name + if (team) { + return ( +
+ + + {open && createPortal( +
+ {companies.length > 0 && ( + <> +
+

+ Företag +

+
+ +
+ {companies.map(({ company: c, role }) => ( + + ))} +
+ + )} + +
0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}> + setOpen(false)} + className="flex items-center gap-2 px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground hover:bg-muted/40 rounded-md transition-colors md:whitespace-nowrap" + > + + Lägg till företag + +
+
, + document.body + )} +
+ ) + } + + // Self-service user (no team) — simple company display + const hasMultiple = companies.length > 1 + + return ( +
+ + + {open && createPortal( +
+
+ {companies.map(({ company: c, role }) => ( + + ))} +
+
, + document.body + )} +
+ ) +} diff --git a/components/dashboard/ConsultantEmptyState.tsx b/components/dashboard/ConsultantEmptyState.tsx new file mode 100644 index 00000000..cae7acd2 --- /dev/null +++ b/components/dashboard/ConsultantEmptyState.tsx @@ -0,0 +1,44 @@ +'use client' + +import Link from 'next/link' +import { Building2, Plus } from 'lucide-react' + +interface ConsultantEmptyStateProps { + firstName?: string | null +} + +export default function ConsultantEmptyState({ firstName }: ConsultantEmptyStateProps) { + const hour = new Date().getHours() + const greeting = hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll' + + return ( +
+
+

+ {greeting}{firstName ? `, ${firstName}` : ''} +

+
+ +
+
+ +
+ +

+ Inga företag ännu +

+

+ Lägg till ditt första kundföretag för att komma igång med bokföringen. +

+ + + + Lägg till företag + +
+
+ ) +} diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 24d4267c..c54c93f7 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -21,6 +21,7 @@ import { } from 'lucide-react' import { getAllExtensions } from '@/lib/extensions/sectors' import { resolveIcon } from '@/lib/extensions/icon-resolver' +import { useCompany } from '@/contexts/CompanyContext' import type { QuickActionDefinition } from '@/lib/extensions/types' import type { CompanySettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' @@ -50,11 +51,13 @@ interface DashboardContentProps { } export default function DashboardContent({ firstName, settings, summary, onboardingProgress }: DashboardContentProps) { + const { isTeamMember } = useCompany() const [showAllAlerts, setShowAllAlerts] = useState(false) const [showMore, setShowMore] = useState(false) // Setup gate — blocks dashboard until user imports data or chooses fresh start - const needsSetup = onboardingProgress && !onboardingProgress.hasBankConnected && !onboardingProgress.hasSIEImport + // Consultants (team members) skip this — they go straight to the dashboard + const needsSetup = !isTeamMember && onboardingProgress && !onboardingProgress.hasBankConnected && !onboardingProgress.hasSIEImport const [setupGateActive, setSetupGateActive] = useState(!!needsSetup) useEffect(() => { diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 7b4c3ce9..d3e0abac 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -29,6 +29,7 @@ import { } from 'lucide-react' import { resolveIcon } from '@/lib/extensions/icon-resolver' import { SupportLink } from '@/components/ui/support-link' +import CompanySwitcher from '@/components/dashboard/CompanySwitcher' import type { EntityType } from '@/types' interface ExtensionNavItem { @@ -158,15 +159,13 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra