feat: multi-tenant company refactor (GNU-19) (#153)

* feat: multi-tenant company refactor (GNU-19)

Introduce companies table, company_members, and user_preferences to
support multiple companies per user. All data scoping changes from
user_id to company_id across the entire codebase.

Key changes:
- Database migration: new tables, company_id on 40+ tables, backfill,
  RLS rewrite from user_id to company-member-based, updated RPCs
- Types: Company, CompanyMember, CompanyRole, UserPreferences types;
  company_id added to all entity interfaces; companyId on all events
- Engine: all 7 core functions take companyId; storno, period, year-end
  services updated; 16 report generators updated
- Middleware: company context resolution (cookie → prefs → first company)
- API routes: ~120 routes updated with requireCompanyId()
- Frontend: CompanyProvider context, layout/dashboard/onboarding updated
- Extensions: context factory, 9 extensions, all lib files updated
- Tests: 1880 tests passing, all helpers updated with company_id defaults

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add database migrations for multi-tenant company and team system (GNU-19)

Adds company_invitations, company creation RPC, team_members, account
deletion RPC, and teams table refactor migrations. Updates base
multi-tenant migration with cascading FKs and onboarding_step column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add team types and update core infrastructure for multi-tenancy (GNU-19)

Adds TeamRole, MemberSource, and Team types. Refactors Supabase service
client to be stateless, updates middleware for team-aware routing, extends
CompanyContext with team/role fields, and updates extension service types
to accept companyId.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: thread company_id through business logic functions (GNU-19)

Replaces user_id scoping with company_id across all lib modules:
bookkeeping, documents, transactions, invoices, reconciliation, tax,
deadlines, and import. Updates corresponding tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: thread company_id through API routes and extensions (GNU-19)

Updates all existing API routes to extract and pass companyId. Updates
enable-banking and arcim-migration extensions for company-scoped
transaction ingestion and sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add company and team management API routes (GNU-19)

Adds CRUD endpoints for company members, company invitations, team
members, and team invitations. Includes invite token utilities, email
templates, and company switch server action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add team/company UI components, pages, and dashboard updates (GNU-19)

Adds CompanySwitcher, ConsultantEmptyState, Step0RoleChoice, company
members and team management panels. Updates dashboard layout for
team-aware routing, onboarding for multi-step role choice, and auth
callback for team invite acceptance. Ignores supabase/.branches/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add null guards for company in import page (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move appUrl declaration to outer scope in invite route (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add optional chaining for company.name in members section (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add optional chaining for second company.name in members section (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add null guards for company in extension components (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: pass companyId to executeSIEImport in arcim-migration extension (GNU-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update tests to use companyId instead of userId and improve type handling

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-03-31 16:41:52 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 1756af6c5e
commit 0dd1f5ebc1
290 changed files with 7690 additions and 1339 deletions
+1
View File
@@ -55,3 +55,4 @@ supabase/.temp/
# Run `npm run setup:extensions` to regenerate after changing extensions.config.json # Run `npm run setup:extensions` to regenerate after changing extensions.config.json
# The empty defaults in lib/extensions/_generated/ are committed so core compiles # The empty defaults in lib/extensions/_generated/ are committed so core compiles
# out of the box without running the generator. # out of the box without running the generator.
supabase/.branches/
+96 -6
View File
@@ -1,5 +1,6 @@
import { createServerClient } from '@supabase/ssr' import { createServerClient } from '@supabase/ssr'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { hashInviteToken } from '@/lib/auth/invite-tokens'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url) const { searchParams, origin } = new URL(request.url)
@@ -22,7 +23,13 @@ export async function GET(request: NextRequest) {
}, },
setAll(cookiesToSet) { setAll(cookiesToSet) {
pendingCookies.length = 0 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 return response
} }
// Check if user has completed onboarding // Check for pending invite token (set by invite page before redirecting to register)
const { data: settings } = await supabase const inviteToken = request.cookies.get('gnubok-invite-token')?.value
.from('company_settings') if (inviteToken) {
.select('onboarding_complete') 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) .eq('user_id', user.id)
.limit(1)
.single() .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' redirectPath = '/onboarding'
} }
} }
@@ -76,6 +164,8 @@ export async function GET(request: NextRequest) {
for (const { name, value, options } of pendingCookies) { for (const { name, value, options } of pendingCookies) {
response.cookies.set({ name, value, ...options }) 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 return response
} }
+77 -2
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -12,14 +13,45 @@ import Image from 'next/image'
import { getErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage } from '@/lib/errors/get-error-message'
export default function RegisterPage() { export default function RegisterPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
}>
<RegisterPageContent />
</Suspense>
)
}
function RegisterPageContent() {
const searchParams = useSearchParams()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isRegistered, setIsRegistered] = useState(false) const [isRegistered, setIsRegistered] = useState(false)
const [inviteEmail, setInviteEmail] = useState<string | null>(null)
const { toast } = useToast() const { toast } = useToast()
const supabase = createClient() 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 { function isStrongPassword(pw: string): boolean {
return pw.length >= 8 return pw.length >= 8
&& /[a-z]/.test(pw) && /[a-z]/.test(pw)
@@ -102,6 +134,43 @@ export default function RegisterPage() {
provider: data.user?.app_metadata?.provider, 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) setEmail(emailValue)
setIsRegistered(true) setIsRegistered(true)
} catch (error) { } catch (error) {
@@ -188,9 +257,15 @@ export default function RegisterPage() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
disabled={isLoading} disabled={isLoading || !!inviteEmail}
readOnly={!!inviteEmail}
className="h-11" className="h-11"
/> />
{inviteEmail && (
<p className="text-xs text-muted-foreground">
Inbjudan skickades till denna adress.
</p>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Lösenord</Label> <Label htmlFor="password">Lösenord</Label>
+1 -1
View File
@@ -60,7 +60,7 @@ export default function DeadlinesPage() {
}, [fetchData]) }, [fetchData])
const handleDeadlineCreate = async ( const handleDeadlineCreate = async (
data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'> data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>
) => { ) => {
try { try {
const response = await fetch('/api/deadlines', { const response = await fetch('/api/deadlines', {
+8 -2
View File
@@ -8,6 +8,7 @@ import { useToast } from '@/components/ui/use-toast'
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight } from 'lucide-react' import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector' import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector'
import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus' import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
@@ -566,6 +567,7 @@ function PSD2ConnectWizard() {
const { toast } = useToast() const { toast } = useToast()
const supabase = createClient() const supabase = createClient()
const { dialogProps, confirm } = useDestructiveConfirm() const { dialogProps, confirm } = useDestructiveConfirm()
const { company } = useCompany()
const [bankConnections, setBankConnections] = useState<BankConnection[]>([]) const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null) const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
@@ -582,10 +584,12 @@ function PSD2ConnectWizard() {
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
if (!user) return if (!user) return
if (!company) return
const { data: connections } = await supabase const { data: connections } = await supabase
.from('bank_connections') .from('bank_connections')
.select('*') .select('*')
.eq('user_id', user.id) .eq('company_id', company.id)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
setBankConnections(connections || []) setBankConnections(connections || [])
@@ -750,6 +754,7 @@ function PSD2ConnectWizard() {
type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'migration' type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'migration'
export default function ImportPage() { export default function ImportPage() {
const { company } = useCompany()
const [mode, setMode] = useState<ImportMode>(null) const [mode, setMode] = useState<ImportMode>(null)
const [userId, setUserId] = useState('') const [userId, setUserId] = useState('')
const [isSandbox, setIsSandbox] = useState(false) const [isSandbox, setIsSandbox] = useState(false)
@@ -760,10 +765,11 @@ export default function ImportPage() {
supabase.auth.getUser().then(({ data: { user } }) => { supabase.auth.getUser().then(({ data: { user } }) => {
if (!user) return if (!user) return
setUserId(user.id) setUserId(user.id)
if (!company) return
supabase supabase
.from('company_settings') .from('company_settings')
.select('is_sandbox') .select('is_sandbox')
.eq('user_id', user.id) .eq('company_id', company.id)
.single() .single()
.then(({ data }) => { .then(({ data }) => {
if (data?.is_sandbox) setIsSandbox(true) if (data?.is_sandbox) setIsSandbox(true)
+175 -34
View File
@@ -1,11 +1,14 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav' import DashboardNav from '@/components/dashboard/DashboardNav'
import { RecaptIdentify } from '@/components/RecaptIdentify' import { RecaptIdentify } from '@/components/RecaptIdentify'
import { SentryIdentify } from '@/components/SentryIdentify' import { SentryIdentify } from '@/components/SentryIdentify'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner' import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors' 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({ export default async function DashboardLayout({
children, children,
@@ -20,21 +23,138 @@ export default async function DashboardLayout({
redirect('/login') 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 (
<CompanyProvider value={companyContextValue}>
<div className="min-h-screen bg-background">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
>
Hoppa till innehåll
</a>
<DashboardNav
companyName={team?.name || 'Mitt team'}
entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
<SentryIdentify userId={user.id} email={user.email} />
</div>
</CompanyProvider>
)
}
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 (
<CompanyProvider value={companyContextValue}>
<div className="min-h-screen bg-background">
<DashboardNav
companyName={team?.name || 'Mitt team'}
entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
<SentryIdentify userId={user.id} email={user.email} />
</div>
</CompanyProvider>
)
}
redirect('/onboarding')
}
const [{ data: settings }, { count: uncategorizedCount }, { count: pendingOpsCount }] = await Promise.all([ const [{ data: settings }, { count: uncategorizedCount }, { count: pendingOpsCount }] = await Promise.all([
supabase supabase
.from('company_settings') .from('company_settings')
.select('company_name, onboarding_complete, entity_type, is_sandbox') .select('company_name, onboarding_complete, entity_type, is_sandbox')
.eq('user_id', user.id) .eq('company_id', companyId)
.single(), .single(),
supabase supabase
.from('transactions') .from('transactions')
.select('*', { count: 'exact', head: true }) .select('*', { count: 'exact', head: true })
.eq('user_id', user.id) .eq('company_id', companyId)
.is('is_business', null), .is('is_business', null),
supabase supabase
.from('pending_operations') .from('pending_operations')
.select('*', { count: 'exact', head: true }) .select('*', { count: 'exact', head: true })
.eq('user_id', user.id) .eq('company_id', companyId)
.eq('status', 'pending'), .eq('status', 'pending'),
]) ])
@@ -42,41 +162,62 @@ export default async function DashboardLayout({
redirect('/onboarding') 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 entityType = (settings.entity_type as EntityType) || 'enskild_firma'
const isSandbox = settings.is_sandbox === true const isSandbox = settings.is_sandbox === true
return ( return (
<div className="min-h-screen bg-background"> <CompanyProvider value={companyContextValue}>
{/* Skip to content link for keyboard/screen reader users */} <div className="min-h-screen bg-background">
<a {/* Skip to content link for keyboard/screen reader users */}
href="#main-content" <a
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium" href="#main-content"
> className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
Hoppa till innehåll >
</a> Hoppa till innehåll
{isSandbox && <SandboxBanner />} </a>
<DashboardNav {isSandbox && <SandboxBanner />}
companyName={settings.company_name || 'Min verksamhet'} <DashboardNav
entityType={entityType} companyName={settings.company_name || 'Min verksamhet'}
uncategorizedTransactionCount={uncategorizedCount ?? 0} entityType={entityType}
pendingOperationsCount={pendingOpsCount ?? 0} uncategorizedTransactionCount={uncategorizedCount ?? 0}
isSandbox={isSandbox} pendingOperationsCount={pendingOpsCount ?? 0}
extensionNavItems={getExtensionNavItems()} isSandbox={isSandbox}
/> extensionNavItems={getExtensionNavItems()}
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
<SentryIdentify userId={user.id} email={user.email} />
{!isSandbox && (
<RecaptIdentify
userId={user.id}
email={user.email}
displayName={settings.company_name || undefined}
/> />
)} <main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
</div> <div key={companyId} className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
<SentryIdentify userId={user.id} email={user.email} />
{!isSandbox && (
<RecaptIdentify
userId={user.id}
email={user.email}
displayName={settings.company_name || undefined}
/>
)}
</div>
</CompanyProvider>
) )
} }
+60 -18
View File
@@ -1,6 +1,9 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import DashboardContent from '@/components/dashboard/DashboardContent' 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' import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -14,6 +17,44 @@ export default async function DashboardPage() {
redirect('/login') 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 <ConsultantEmptyState firstName={firstName} />
}
redirect('/onboarding')
}
// Fetch current year date boundaries // Fetch current year date boundaries
const startOfYearStr = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0] 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] 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 }, { count: staleUncategorizedCount },
] = await Promise.all([ ] = await Promise.all([
supabase.from('profiles').select('full_name').eq('id', user.id).single(), supabase.from('profiles').select('full_name').eq('id', user.id).single(),
supabase.from('company_settings').select('*').eq('user_id', user.id).single(), supabase.from('company_settings').select('*').eq('company_id', companyId).single(),
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('user_id', user.id), supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('user_id', user.id), supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id), supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id), supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('journal_entry_lines') 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.status', 'posted')
.eq('journal_entry.company_id', companyId)
.gte('journal_entry.entry_date', startOfYearStr), .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('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('user_id', user.id).in('status', ['sent', 'overdue']), 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('user_id', user.id).eq('status', 'active'), 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('user_id', user.id).eq('is_completed', false) 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 }), .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('company_id', companyId).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('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('user_id', user.id).lt('amount', 0).is('receipt_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('user_id', user.id).eq('status', 'posted').in('source_type', needsDocSourceTypes), 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('user_id', user.id).eq('is_current_version', true).not('journal_entry_id', 'is', null), 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('user_id', user.id).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30), 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('user_id', user.id).eq('status', 'completed'), 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('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('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 const firstName = profile?.full_name?.split(' ')[0] || null
+25 -8
View File
@@ -32,6 +32,7 @@ import {
AlertTriangle, AlertTriangle,
} from 'lucide-react' } from 'lucide-react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { useCompany } from '@/contexts/CompanyContext'
import type { CompanySettings } from '@/types' import type { CompanySettings } from '@/types'
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn' import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
import { BankNameCombobox } from '@/components/settings/BankNameCombobox' 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 { SecuritySettings } from '@/components/settings/SecuritySettings'
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel' import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel' 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' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
const BankingPanel = getSettingsPanel('enable-banking') const BankingPanel = getSettingsPanel('enable-banking')
@@ -50,6 +53,7 @@ export default function SettingsPage() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { toast } = useToast() const { toast } = useToast()
const supabase = createClient() const supabase = createClient()
const { company, isTeamMember } = useCompany()
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
@@ -67,15 +71,18 @@ export default function SettingsPage() {
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false) 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 [activeTab, setActiveTab] = useState(initialTab)
const settingsTabs = [ const settingsTabs = [
{ value: 'company', label: 'Företag', show: true }, { value: 'company', label: 'Företag', show: hasCompany },
{ value: 'banking', label: 'Bank (PSD2)', show: !settings?.is_sandbox && hasBankingExtension }, { value: 'team', label: 'Lag', show: isTeamMember },
{ value: 'templates', label: 'Mallar', show: true }, { value: 'banking', label: 'Bank (PSD2)', show: hasCompany && !settings?.is_sandbox && hasBankingExtension },
{ value: 'templates', label: 'Mallar', show: hasCompany },
{ value: 'account', label: 'Konto', show: true }, { value: 'account', label: 'Konto', show: true },
{ value: 'api', label: 'API', show: hasMcpExtension }, { value: 'api', label: 'API', show: hasCompany && hasMcpExtension },
].filter(t => t.show) ].filter(t => t.show)
useEffect(() => { useEffect(() => {
@@ -91,9 +98,10 @@ export default function SettingsPage() {
return return
} }
const settingsRes = await supabase.from('company_settings').select('*').eq('user_id', user.id).single() if (company?.id) {
const settingsRes = await supabase.from('company_settings').select('*').eq('company_id', company.id).single()
setSettings(settingsRes.data) setSettings(settingsRes.data)
}
setIsLoading(false) setIsLoading(false)
} }
@@ -625,6 +633,15 @@ export default function SettingsPage() {
</Button> </Button>
</div> </div>
</form> </form>
<Separator className="my-8" />
<CompanyMembersSection />
</TabsContent>
{/* Team management */}
<TabsContent value="team">
<TeamPanel />
</TabsContent> </TabsContent>
{/* Banking settings — loaded dynamically from extension, hidden for sandbox */} {/* Banking settings — loaded dynamically from extension, hidden for sandbox */}
+3
View File
@@ -27,6 +27,7 @@ import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates' import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates' import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types' import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import { useCompany } from '@/contexts/CompanyContext'
import { formatCurrency, formatDate } from '@/lib/utils' import { formatCurrency, formatDate } from '@/lib/utils'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType, LinePatternEntry } from '@/types' import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType, LinePatternEntry } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions' import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
@@ -42,6 +43,7 @@ interface QuickReviewState {
} }
export default function TransactionsPage() { export default function TransactionsPage() {
const { company } = useCompany()
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([]) const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [mode, setMode] = useState<ViewMode>('inbox') const [mode, setMode] = useState<ViewMode>('inbox')
@@ -484,6 +486,7 @@ export default function TransactionsPage() {
const { data: transaction, error } = await supabase const { data: transaction, error } = await supabase
.from('transactions') .from('transactions')
.insert({ .insert({
company_id: company!.id,
user_id: user.id, user_id: user.id,
date: data.date, date: data.date,
description: data.description, description: data.description,
+408 -53
View File
@@ -6,17 +6,24 @@ import Image from 'next/image'
import * as Sentry from '@sentry/nextjs' import * as Sentry from '@sentry/nextjs'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react' import { Loader2, ArrowRight } from 'lucide-react'
import { cn } from '@/lib/utils' 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 { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import type { CompanyLookupResult } from '@/lib/company-lookup/types' import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import type { CompanySettings, EntityType, MomsPeriod } from '@/types' import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
import Step0RoleChoice from '@/components/onboarding/Step0RoleChoice'
import Step1EntityType from '@/components/onboarding/Step1EntityType' import Step1EntityType from '@/components/onboarding/Step1EntityType'
import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails' import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration' import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting' import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting'
type OnboardingMode = 'choice' | 'self' | 'consultant'
const STEP_INFO = [ const STEP_INFO = [
{ title: 'Välkommen', subtitle: 'Välj din företagsform för att komma igång.', label: 'Företagsform' }, { 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' }, { 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' }, { 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 { function translatePeriodError(msg: string): string {
if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.' 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('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 [isSaving, setIsSaving] = useState(false)
const [currentStep, setCurrentStep] = useState(1) const [currentStep, setCurrentStep] = useState(1)
const [settings, setSettings] = useState<Partial<CompanySettings>>({}) const [settings, setSettings] = useState<Partial<CompanySettings>>({})
const [companyId, setCompanyId] = useState<string | null>(null)
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic') const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null) const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null)
const [mode, setMode] = useState<OnboardingMode>('choice')
const [consultantLanding, setConsultantLanding] = useState(false)
const [teamName, setTeamName] = useState('')
const totalSteps = 4 const totalSteps = 4
@@ -102,26 +120,95 @@ function OnboardingPageContent() {
return return
} }
const { data, error } = await supabase // Check for unprocessed invite token (fallback if auth callback didn't process it)
.from('company_settings') const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
.select('*') const inviteToken = cookieMatch?.[1]
.eq('user_id', user.id)
.single()
if (error && error.code !== 'PGRST116') { if (inviteToken) {
logError('failed to load settings', { message: error.message, code: error.code }) 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) { // Check if user is already in a team (consultant) — skip onboarding
const step = data.onboarding_step || 1 const { data: teamMember } = await supabase
const clampedStep = step > totalSteps ? totalSteps : step .from('team_members')
if (step > totalSteps) { .select('id')
logError('onboarding_step exceeds totalSteps — clamped', { step, totalSteps }) .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) setIsLoading(false)
@@ -153,16 +240,21 @@ function OnboardingPageContent() {
onboarding_step: targetStep, onboarding_step: targetStep,
} }
if (!companyId) {
logError('save aborted: no companyId', { step: targetStep })
return false
}
// Remove read-only and transient fields before updating // Remove read-only and transient fields before updating
const { 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, is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye,
...settingsToSave ...settingsToSave
} = updatedSettings as Record<string, unknown> } = updatedSettings as Record<string, unknown>
const { error } = await supabase const { error } = await supabase
.from('company_settings') .from('company_settings')
.upsert({ ...settingsToSave, user_id: user.id }, { onConflict: 'user_id' }) .upsert({ ...settingsToSave, company_id: companyId }, { onConflict: 'company_id' })
if (error) { if (error) {
logError('save failed', { message: error.message, step: targetStep, code: error.code, details: error.details }) logError('save failed', { message: error.message, step: targetStep, code: error.code, details: error.details })
@@ -199,36 +291,121 @@ function OnboardingPageContent() {
setTicLookup(null) 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 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<string, unknown>
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) { if (!success) {
logError('handleNext aborted: saveSettings failed', { step: currentStep }) logError('handleNext aborted: saveSettings failed', { step: currentStep })
return 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 // After step 1 (entity type selection): seed chart of accounts
if (currentStep === 1 && stepData.entity_type) { if (currentStep === 1 && stepData.entity_type) {
try { try {
const { data: { user }, error: authError } = await supabase.auth.getUser() const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', {
if (authError) { p_company_id: activeCompanyId,
logError('auth.getUser() failed before seeding chart of accounts', { message: authError.message }) p_entity_type: stepData.entity_type,
} })
if (user) { if (rpcError) {
const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', { logError('chart of accounts seeding failed', {
p_user_id: user.id, entity_type: stepData.entity_type,
p_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) { } catch (err) {
logError('chart of accounts seeding threw', { error: String(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 // After step 3 (tax registration): create initial fiscal period
if (currentStep === 3) { if (currentStep === 3 && companyId) {
try { 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 isFirstYear = stepData.is_first_fiscal_year as boolean | undefined
const firstYearStart = stepData.first_year_start as string | undefined const firstYearStart = stepData.first_year_start as string | undefined
const firstYearEnd = stepData.first_year_end 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 const { data: existingPeriods, error: fetchPeriodsError } = await supabase
.from('fiscal_periods') .from('fiscal_periods')
.select('id') .select('id')
.eq('user_id', user.id) .eq('company_id', companyId)
if (fetchPeriodsError) { if (fetchPeriodsError) {
logError('failed to fetch existing fiscal periods', { logError('failed to fetch existing fiscal periods', {
@@ -348,12 +518,12 @@ function OnboardingPageContent() {
} }
const { error: upsertError } = await supabase.from('fiscal_periods').upsert({ const { error: upsertError } = await supabase.from('fiscal_periods').upsert({
user_id: user.id, company_id: companyId,
name: periodName, name: periodName,
period_start: startStr, period_start: startStr,
period_end: endStr, period_end: endStr,
}, { }, {
onConflict: 'user_id,period_start,period_end', onConflict: 'company_id,period_start,period_end',
}) })
if (upsertError) { if (upsertError) {
@@ -361,7 +531,6 @@ function OnboardingPageContent() {
message: upsertError.message, startStr, endStr, code: upsertError.code, details: upsertError.details, message: upsertError.message, startStr, endStr, code: upsertError.code, details: upsertError.details,
}) })
} }
}
} catch (err) { } catch (err) {
logError('fiscal period creation threw', { error: String(err) }) logError('fiscal period creation threw', { error: String(err) })
Sentry.captureException(err) Sentry.captureException(err)
@@ -394,6 +563,8 @@ function OnboardingPageContent() {
const handleBack = () => { const handleBack = () => {
if (currentStep > 1) { if (currentStep > 1) {
setCurrentStep(currentStep - 1) setCurrentStep(currentStep - 1)
} else if (isConsultant) {
setConsultantLanding(true)
} }
} }
@@ -408,6 +579,46 @@ function OnboardingPageContent() {
setCurrentStep(nextStep) 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) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background"> <div className="min-h-screen flex items-center justify-center bg-background">
@@ -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 = () => ( const renderSteps = () => (
<> <>
@@ -475,10 +690,150 @@ function OnboardingPageContent() {
isSaving={isSaving} isSaving={isSaving}
/> />
)} )}
</> </>
) )
// ── Role Choice Screen (Step 0) ──
if (showRoleChoice) {
return (
<div className="min-h-screen flex flex-col bg-background">
<header className="relative bg-[#141414] text-white overflow-hidden">
<div className="absolute inset-0 pointer-events-none" aria-hidden>
<div
className="absolute inset-0"
style={{
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
}}
/>
</div>
<div className="relative z-10 max-w-2xl mx-auto w-full px-6 md:px-10 pt-5 pb-6 md:pt-6 md:pb-8">
<div className="flex items-center gap-2.5 mb-5 md:mb-6">
<Image
src="/gnubokiceon-removebg-preview.png"
alt="Gnubok"
width={30}
height={30}
className="invert opacity-90"
/>
<span className="font-display text-base tracking-tight">gnubok</span>
</div>
<div className="animate-fade-in">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight leading-[1.1]">
Välkommen till gnubok
</h1>
<p className="text-white/40 mt-1.5 text-sm max-w-sm leading-relaxed">
Hur vill du använda gnubok?
</p>
</div>
</div>
</header>
<main className="flex-1">
<div className="max-w-lg mx-auto px-6 md:px-10 py-6 md:py-8">
<div className="animate-slide-up">
<Step0RoleChoice
onChooseSelf={() => setMode('self')}
onChooseConsultant={() => {
setMode('consultant')
setConsultantLanding(true)
}}
/>
</div>
</div>
</main>
</div>
)
}
// ── Consultant Landing Screen ──
if (showConsultantLanding) {
return (
<div className="min-h-screen flex flex-col bg-background">
<header className="relative bg-[#141414] text-white overflow-hidden">
<div className="absolute inset-0 pointer-events-none" aria-hidden>
<div
className="absolute inset-0"
style={{
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
}}
/>
</div>
<div className="relative z-10 max-w-2xl mx-auto w-full px-6 md:px-10 pt-5 pb-6 md:pt-6 md:pb-8">
<div className="flex items-center gap-2.5 mb-5 md:mb-6">
<Image
src="/gnubokiceon-removebg-preview.png"
alt="Gnubok"
width={30}
height={30}
className="invert opacity-90"
/>
<span className="font-display text-base tracking-tight">gnubok</span>
</div>
<div className="animate-fade-in">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight leading-[1.1]">
Namnge ditt team
</h1>
<p className="text-white/40 mt-1.5 text-sm max-w-sm leading-relaxed">
Skapa ett team som samlar dig och dina kollegor.
</p>
</div>
</div>
</header>
<main className="flex-1">
<div className="max-w-lg mx-auto px-6 md:px-10 py-6 md:py-8">
<div className="animate-slide-up space-y-5">
<div className="rounded-xl border bg-card p-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="team-name">Teamnamn</Label>
<Input
id="team-name"
placeholder="T.ex. Redovisningsbyrån AB"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
disabled={isSaving}
className="h-11"
autoFocus
/>
<p className="text-xs text-muted-foreground">
Du kan ändra namnet senare i inställningar.
</p>
</div>
</div>
<Button
size="lg"
className="w-full"
onClick={handleConsultantCreateTeam}
disabled={isSaving || !teamName.trim()}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar team...
</>
) : (
<>
Skapa team
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
<button
onClick={() => {
setMode('choice')
setConsultantLanding(false)
}}
className="block mx-auto text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
Tillbaka
</button>
</div>
</div>
</main>
</div>
)
}
// ── Steps 1–4 (self or consultant adding company) ──
return ( return (
<div className="min-h-screen flex flex-col bg-background"> <div className="min-h-screen flex flex-col bg-background">
{/* ── Branded Header ── */} {/* ── Branded Header ── */}
@@ -511,7 +866,7 @@ function OnboardingPageContent() {
</div> </div>
{/* Step indicator — inline with logo row */} {/* Step indicator — inline with logo row */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{STEP_INFO.map((_, i) => { {stepInfoArr.map((_, i) => {
const num = i + 1 const num = i + 1
return ( return (
<div <div
+6 -4
View File
@@ -1,4 +1,4 @@
import { createClient, createServiceClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
@@ -31,9 +31,11 @@ export async function POST(request: Request) {
} }
try { try {
// Delete the auth user via service role — all data cascades via ON DELETE CASCADE // RPC disables protective triggers, deletes from auth.users (CASCADE
const serviceClient = await createServiceClient() // cleans up all public tables), then re-enables triggers — all in one tx.
const { error } = await serviceClient.auth.admin.deleteUser(user.id) const { error } = await supabase.rpc('delete_user_account', {
target_user_id: user.id,
})
if (error) { if (error) {
console.error('Failed to delete user:', error) console.error('Failed to delete user:', error)
+7 -2
View File
@@ -15,6 +15,11 @@ vi.mock('@/lib/extensions/ai-consent', () => ({
), ),
})) }))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { hasAiConsent, grantAiConsent, revokeAiConsent } from '@/lib/extensions/ai-consent' import { hasAiConsent, grantAiConsent, revokeAiConsent } from '@/lib/extensions/ai-consent'
import { GET, POST, DELETE } from '../route' import { GET, POST, DELETE } from '../route'
@@ -91,7 +96,7 @@ describe('POST /api/ai-consent', () => {
expect(status).toBe(200) expect(status).toBe(200)
expect(body.data.consented).toBe(true) 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 () => { it('returns 400 for non-AI extension', async () => {
@@ -131,6 +136,6 @@ describe('DELETE /api/ai-consent', () => {
expect(status).toBe(200) expect(status).toBe(200)
expect(body.data.consented).toBe(false) 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')
}) })
}) })
+10 -3
View File
@@ -7,6 +7,7 @@ import {
revokeAiConsent, revokeAiConsent,
isAiExtension, isAiExtension,
} from '@/lib/extensions/ai-consent' } from '@/lib/extensions/ai-consent'
import { requireCompanyId } from '@/lib/company/context'
export async function GET() { export async function GET() {
const supabase = await createClient() const supabase = await createClient()
@@ -16,9 +17,11 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const statuses: Record<string, boolean> = {} const statuses: Record<string, boolean> = {}
for (const ext of AI_EXTENSIONS) { 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 }) return NextResponse.json({ data: statuses })
@@ -32,6 +35,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json() const body = await request.json()
const { extension_id } = body 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 } }) return NextResponse.json({ data: { consented: true } })
} }
@@ -54,6 +59,8 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json() const body = await request.json()
const { extension_id } = body 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 } }) return NextResponse.json({ data: { consented: false } })
} }
@@ -9,6 +9,11 @@ vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn(), 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 { createClient } from '@/lib/supabase/server'
import { getAuditLog } from '@/lib/core/audit/audit-service' import { getAuditLog } from '@/lib/core/audit/audit-service'
import { GET } from '../route' import { GET } from '../route'
+3
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { getAuditLog } from '@/lib/core/audit/audit-service' import { getAuditLog } from '@/lib/core/audit/audit-service'
import type { AuditAction } from '@/types' import type { AuditAction } from '@/types'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) { export async function GET(request: Request) {
const supabase = await createClient() const supabase = await createClient()
@@ -11,6 +12,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const filters = { const filters = {
+4 -1
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) { export async function GET(request: Request) {
const supabase = await createClient() const supabase = await createClient()
@@ -9,6 +10,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const from = searchParams.get('from') const from = searchParams.get('from')
const to = searchParams.get('to') const to = searchParams.get('to')
@@ -27,7 +30,7 @@ export async function GET(request: Request) {
let entriesQuery = supabase let entriesQuery = supabase
.from('journal_entries') .from('journal_entries')
.select('id, entry_date') .select('id, entry_date')
.eq('user_id', user.id) .eq('company_id', companyId)
.eq('status', 'posted') .eq('status', 'posted')
if (dateFrom) { if (dateFrom) {
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { UpdateAccountSchema } from '@/lib/api/schemas' import { UpdateAccountSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function DELETE( export async function DELETE(
request: Request, request: Request,
@@ -15,11 +16,13 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 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 // Fetch the account to check if it's a system account
const { data: account, error: fetchError } = await supabase const { data: account, error: fetchError } = await supabase
.from('chart_of_accounts') .from('chart_of_accounts')
.select('id, is_system_account') .select('id, is_system_account')
.eq('user_id', user.id) .eq('company_id', companyId)
.eq('account_number', number) .eq('account_number', number)
.single() .single()
@@ -51,7 +54,7 @@ export async function DELETE(
.from('chart_of_accounts') .from('chart_of_accounts')
.delete() .delete()
.eq('id', account.id) .eq('id', account.id)
.eq('user_id', user.id) .eq('company_id', companyId)
if (deleteError) { if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 }) return NextResponse.json({ error: deleteError.message }, { status: 500 })
@@ -72,6 +75,8 @@ export async function PUT(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, UpdateAccountSchema) const validation = await validateBody(request, UpdateAccountSchema)
if (!validation.success) return validation.response if (!validation.success) return validation.response
const body = validation.data const body = validation.data
@@ -79,7 +84,7 @@ export async function PUT(
const { data, error } = await supabase const { data, error } = await supabase
.from('chart_of_accounts') .from('chart_of_accounts')
.update(body) .update(body)
.eq('user_id', user.id) .eq('company_id', companyId)
.eq('account_number', number) .eq('account_number', number)
.select() .select()
.single() .single()
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { getBASReference } from '@/lib/bookkeeping/bas-reference' import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* POST /api/bookkeeping/accounts/activate * POST /api/bookkeeping/accounts/activate
@@ -17,6 +18,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json() const body = await request.json()
const accountNumbers: string[] = body.account_numbers const accountNumbers: string[] = body.account_numbers
@@ -28,7 +31,7 @@ export async function POST(request: Request) {
const { data: existing } = await supabase const { data: existing } = await supabase
.from('chart_of_accounts') .from('chart_of_accounts')
.select('account_number') .select('account_number')
.eq('user_id', user.id) .eq('company_id', companyId)
.in('account_number', accountNumbers) .in('account_number', accountNumbers)
const existingNumbers = new Set((existing || []).map((a) => a.account_number)) const existingNumbers = new Set((existing || []).map((a) => a.account_number))
@@ -42,6 +45,7 @@ export async function POST(request: Request) {
return { return {
user_id: user.id, user_id: user.id,
company_id: companyId,
account_number: ref.account_number, account_number: ref.account_number,
account_name: ref.account_name, account_name: ref.account_name,
account_class: ref.account_class, account_class: ref.account_class,
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all' import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference' import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* GET /api/bookkeeping/accounts/reference * GET /api/bookkeeping/accounts/reference
@@ -17,13 +18,15 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 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) // Fetch user's chart of accounts (paginated to avoid 1000-row limit)
try { try {
const userAccounts = await fetchAllRows<{ account_number: string; is_active: boolean; is_system_account: boolean }>(({ from, to }) => const userAccounts = await fetchAllRows<{ account_number: string; is_active: boolean; is_system_account: boolean }>(({ from, to }) =>
supabase supabase
.from('chart_of_accounts') .from('chart_of_accounts')
.select('account_number, is_active, is_system_account') .select('account_number, is_active, is_system_account')
.eq('user_id', user.id) .eq('company_id', companyId)
.range(from, to) .range(from, to)
) )
+7 -1
View File
@@ -3,6 +3,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { CreateAccountSchema } from '@/lib/api/schemas' import { CreateAccountSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) { export async function GET(request: Request) {
const supabase = await createClient() const supabase = await createClient()
@@ -12,6 +13,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const accountClass = searchParams.get('class') const accountClass = searchParams.get('class')
const activeOnly = searchParams.get('active') !== 'false' const activeOnly = searchParams.get('active') !== 'false'
@@ -21,7 +24,7 @@ export async function GET(request: Request) {
let query = supabase let query = supabase
.from('chart_of_accounts') .from('chart_of_accounts')
.select('*') .select('*')
.eq('user_id', user.id) .eq('company_id', companyId)
.order('sort_order') .order('sort_order')
if (activeOnly) { if (activeOnly) {
@@ -53,10 +56,13 @@ export async function POST(request: Request) {
if (!validation.success) return validation.response if (!validation.success) return validation.response
const body = validation.data const body = validation.data
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase const { data, error } = await supabase
.from('chart_of_accounts') .from('chart_of_accounts')
.insert({ .insert({
user_id: user.id, user_id: user.id,
company_id: companyId,
account_number: body.account_number, account_number: body.account_number,
account_name: body.account_name, account_name: body.account_name,
account_class: parseInt(body.account_number[0]), account_class: parseInt(body.account_number[0]),
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { closePeriod } from '@/lib/core/bookkeeping/period-service' import { closePeriod } from '@/lib/core/bookkeeping/period-service'
import { requireCompanyId } from '@/lib/company/context'
export async function POST( export async function POST(
request: Request, request: Request,
@@ -14,8 +15,10 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
try { try {
const period = await closePeriod(supabase, user.id, id) const period = await closePeriod(supabase, companyId, user.id, id)
return NextResponse.json({ data: period }) return NextResponse.json({ data: period })
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
@@ -4,6 +4,7 @@ import {
previewCurrencyRevaluation, previewCurrencyRevaluation,
executeCurrencyRevaluation, executeCurrencyRevaluation,
} from '@/lib/bookkeeping/currency-revaluation' } from '@/lib/bookkeeping/currency-revaluation'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* GET: Preview currency revaluation for a fiscal period * GET: Preview currency revaluation for a fiscal period
@@ -20,20 +21,22 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
try { try {
// Fetch period to get closing date // Fetch period to get closing date
const { data: period, error: periodError } = await supabase const { data: period, error: periodError } = await supabase
.from('fiscal_periods') .from('fiscal_periods')
.select('*') .select('*')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (periodError || !period) { if (periodError || !period) {
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 }) 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 }) return NextResponse.json({ data: preview })
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
@@ -58,13 +61,15 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
try { try {
// Fetch period to get closing date // Fetch period to get closing date
const { data: period, error: periodError } = await supabase const { data: period, error: periodError } = await supabase
.from('fiscal_periods') .from('fiscal_periods')
.select('*') .select('*')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (periodError || !period) { if (periodError || !period) {
@@ -75,7 +80,7 @@ export async function POST(
return NextResponse.json({ error: 'Period is already closed' }, { status: 400 }) 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) { if (!result) {
return NextResponse.json({ data: null, message: 'No foreign currency items to revalue' }) return NextResponse.json({ data: null, message: 'No foreign currency items to revalue' })
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { lockPeriod } from '@/lib/core/bookkeeping/period-service' import { lockPeriod } from '@/lib/core/bookkeeping/period-service'
import { requireCompanyId } from '@/lib/company/context'
export async function POST( export async function POST(
request: Request, request: Request,
@@ -14,8 +15,10 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
try { try {
const period = await lockPeriod(supabase, user.id, id) const period = await lockPeriod(supabase, companyId, user.id, id)
return NextResponse.json({ data: period }) return NextResponse.json({ data: period })
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
@@ -5,6 +5,7 @@ import {
previewYearEndClosing, previewYearEndClosing,
executeYearEndClosing, executeYearEndClosing,
} from '@/lib/core/bookkeeping/year-end-service' } from '@/lib/core/bookkeeping/year-end-service'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* GET: Validate readiness and preview year-end closing * GET: Validate readiness and preview year-end closing
@@ -21,10 +22,12 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
try { try {
const [validation, preview] = await Promise.all([ const [validation, preview] = await Promise.all([
validateYearEndReadiness(supabase, user.id, id), validateYearEndReadiness(supabase, companyId, user.id, id),
previewYearEndClosing(supabase, user.id, id), previewYearEndClosing(supabase, companyId, user.id, id),
]) ])
return NextResponse.json({ data: { validation, preview } }) return NextResponse.json({ data: { validation, preview } })
@@ -51,8 +54,10 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
try { try {
const result = await executeYearEndClosing(supabase, user.id, id) const result = await executeYearEndClosing(supabase, companyId, user.id, id)
return NextResponse.json({ data: result }) return NextResponse.json({ data: result })
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
+8 -2
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration' import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas' import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function GET() { export async function GET() {
const supabase = await createClient() const supabase = await createClient()
@@ -12,10 +13,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase const { data, error } = await supabase
.from('fiscal_periods') .from('fiscal_periods')
.select('*') .select('*')
.eq('user_id', user.id) .eq('company_id', companyId)
.order('period_start', { ascending: false }) .order('period_start', { ascending: false })
if (error) { if (error) {
@@ -33,6 +36,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateFiscalPeriodSchema) const validation = await validateBody(request, CreateFiscalPeriodSchema)
if (!validation.success) return validation.response if (!validation.success) return validation.response
const body = validation.data const body = validation.data
@@ -47,7 +52,7 @@ export async function POST(request: Request) {
const { data: overlapping } = await supabase const { data: overlapping } = await supabase
.from('fiscal_periods') .from('fiscal_periods')
.select('id, name') .select('id, name')
.eq('user_id', user.id) .eq('company_id', companyId)
.lte('period_start', body.period_end) .lte('period_start', body.period_end)
.gte('period_end', body.period_start) .gte('period_end', body.period_start)
.limit(1) .limit(1)
@@ -63,6 +68,7 @@ export async function POST(request: Request) {
.from('fiscal_periods') .from('fiscal_periods')
.insert({ .insert({
user_id: user.id, user_id: user.id,
company_id: companyId,
name: body.name, name: body.name,
period_start: body.period_start, period_start: body.period_start,
period_end: body.period_end, period_end: body.period_end,
@@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => mockCreateClient(), createClient: () => mockCreateClient(),
})) }))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route' import { GET } from '../route'
function buildMockSupabase({ function buildMockSupabase({
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET( export async function GET(
request: Request, request: Request,
@@ -13,12 +14,14 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Fetch the requested entry with lines // Fetch the requested entry with lines
const { data: entry, error } = await supabase const { data: entry, error } = await supabase
.from('journal_entries') .from('journal_entries')
.select('*, lines:journal_entry_lines(*)') .select('*, lines:journal_entry_lines(*)')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (error || !entry) { if (error || !entry) {
@@ -38,7 +41,7 @@ export async function GET(
const { data: referencing } = await supabase const { data: referencing } = await supabase
.from('journal_entries') .from('journal_entries')
.select('id') .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}`) .or(`reverses_id.eq.${id},reversed_by_id.eq.${id},correction_of_id.eq.${id}`)
if (referencing) { if (referencing) {
@@ -57,7 +60,7 @@ export async function GET(
const { data: batchEntries } = await supabase const { data: batchEntries } = await supabase
.from('journal_entries') .from('journal_entries')
.select('id, reverses_id, reversed_by_id, correction_of_id') .select('id, reverses_id, reversed_by_id, correction_of_id')
.eq('user_id', user.id) .eq('company_id', companyId)
.in('id', batch) .in('id', batch)
if (!batchEntries) continue if (!batchEntries) continue
@@ -81,7 +84,7 @@ export async function GET(
const { data: refs } = await supabase const { data: refs } = await supabase
.from('journal_entries') .from('journal_entries')
.select('id') .select('id')
.eq('user_id', user.id) .eq('company_id', companyId)
.or(batchOr) .or(batchOr)
if (refs) { if (refs) {
@@ -99,7 +102,7 @@ export async function GET(
const { data: chainEntries } = await supabase const { data: chainEntries } = await supabase
.from('journal_entries') .from('journal_entries')
.select('*, lines:journal_entry_lines(*)') .select('*, lines:journal_entry_lines(*)')
.eq('user_id', user.id) .eq('company_id', companyId)
.in('id', chainIds) .in('id', chainIds)
.order('created_at', { ascending: true }) .order('created_at', { ascending: true })
@@ -15,6 +15,10 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(), ensureInitialized: vi.fn(),
})) }))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCorrectEntry = vi.fn() const mockCorrectEntry = vi.fn()
vi.mock('@/lib/core/bookkeeping/storno-service', () => ({ vi.mock('@/lib/core/bookkeeping/storno-service', () => ({
correctEntry: (...args: unknown[]) => mockCorrectEntry(...args), correctEntry: (...args: unknown[]) => mockCorrectEntry(...args),
@@ -100,7 +104,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
expect(status).toBe(200) expect(status).toBe(200)
expect(body.data.reversal).toEqual(reversal) expect(body.data.reversal).toEqual(reversal)
expect(body.data.corrected).toEqual(corrected) 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 () => { it('returns 400 when correctEntry throws for unbalanced lines', async () => {
@@ -4,6 +4,7 @@ import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { CorrectJournalEntrySchema } from '@/lib/api/schemas' import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized() ensureInitialized()
@@ -19,12 +20,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CorrectJournalEntrySchema) const validation = await validateBody(request, CorrectJournalEntrySchema)
if (!validation.success) return validation.response if (!validation.success) return validation.response
const body = validation.data const body = validation.data
try { 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 }) return NextResponse.json({ data: result })
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
@@ -16,6 +16,10 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(), ensureInitialized: vi.fn(),
})) }))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockReverseEntry = vi.fn() const mockReverseEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({ vi.mock('@/lib/bookkeeping/engine', () => ({
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args), reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
@@ -64,7 +68,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => {
expect(status).toBe(200) expect(status).toBe(200)
expect(body.data).toEqual(reversalEntry) 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 () => { it('returns 400 when engine throws', async () => {
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { reverseEntry } from '@/lib/bookkeeping/engine' import { reverseEntry } from '@/lib/bookkeeping/engine'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized() ensureInitialized()
@@ -17,8 +18,10 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
try { try {
const reversalEntry = await reverseEntry(supabase, user.id, id) const reversalEntry = await reverseEntry(supabase, companyId, user.id, id)
return NextResponse.json({ data: reversalEntry }) return NextResponse.json({ data: reversalEntry })
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET( export async function GET(
request: Request, request: Request,
@@ -13,11 +14,13 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase const { data, error } = await supabase
.from('journal_entries') .from('journal_entries')
.select('*, lines:journal_entry_lines(*)') .select('*, lines:journal_entry_lines(*)')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (error) { if (error) {
@@ -16,6 +16,10 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(), ensureInitialized: vi.fn(),
})) }))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCreateJournalEntry = vi.fn() const mockCreateJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({ vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
@@ -137,7 +141,7 @@ describe('POST /api/bookkeeping/journal-entries', () => {
expect(status).toBe(200) expect(status).toBe(200)
expect(body.data).toEqual(entry) 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 () => { it('returns 400 when engine throws', async () => {
+7 -2
View File
@@ -4,6 +4,7 @@ import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { CreateJournalEntrySchema } from '@/lib/api/schemas' import { CreateJournalEntrySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized() ensureInitialized()
@@ -15,6 +16,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id') const periodId = searchParams.get('period_id')
const status = searchParams.get('status') const status = searchParams.get('status')
@@ -29,7 +32,7 @@ export async function GET(request: Request) {
let query = supabase let query = supabase
.from('journal_entries') .from('journal_entries')
.select('*, lines:journal_entry_lines(*)', { count: 'exact' }) .select('*, lines:journal_entry_lines(*)', { count: 'exact' })
.eq('user_id', user.id) .eq('company_id', companyId)
if (sortDate === 'asc' || sortDate === 'desc') { if (sortDate === 'asc' || sortDate === 'desc') {
query = query query = query
@@ -76,12 +79,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateJournalEntrySchema) const validation = await validateBody(request, CreateJournalEntrySchema)
if (!validation.success) return validation.response if (!validation.success) return validation.response
const body = validation.data const body = validation.data
try { try {
const entry = await createJournalEntry(supabase, user.id, body) const entry = await createJournalEntry(supabase, companyId, user.id, body)
return NextResponse.json({ data: entry }) return NextResponse.json({ data: entry })
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine' import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { EvaluateMappingRulesSchema } from '@/lib/api/schemas' import { EvaluateMappingRulesSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction } from '@/types' import type { Transaction } from '@/types'
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -13,6 +14,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, EvaluateMappingRulesSchema) const validation = await validateBody(request, EvaluateMappingRulesSchema)
if (!validation.success) return validation.response if (!validation.success) return validation.response
const body = validation.data const body = validation.data
@@ -25,7 +28,7 @@ export async function POST(request: Request) {
.from('transactions') .from('transactions')
.select('*') .select('*')
.eq('id', body.transaction_id) .eq('id', body.transaction_id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (error || !data) { if (error || !data) {
@@ -38,7 +41,7 @@ export async function POST(request: Request) {
} }
try { try {
const result = await evaluateMappingRules(supabase, user.id, transaction) const result = await evaluateMappingRules(supabase, companyId, transaction)
return NextResponse.json({ data: result }) return NextResponse.json({ data: result })
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
+7 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { CreateMappingRuleSchema } from '@/lib/api/schemas' import { CreateMappingRuleSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function GET() { export async function GET() {
const supabase = await createClient() const supabase = await createClient()
@@ -11,10 +12,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase const { data, error } = await supabase
.from('mapping_rules') .from('mapping_rules')
.select('*') .select('*')
.or(`user_id.eq.${user.id},user_id.is.null`) .or(`company_id.eq.${companyId},company_id.is.null`)
.eq('is_active', true) .eq('is_active', true)
.order('priority') .order('priority')
@@ -33,6 +36,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, CreateMappingRuleSchema) const result = await validateBody(request, CreateMappingRuleSchema)
if (!result.success) return result.response if (!result.success) return result.response
const body = result.data const body = result.data
@@ -41,6 +46,7 @@ export async function POST(request: Request) {
.from('mapping_rules') .from('mapping_rules')
.insert({ .insert({
user_id: user.id, user_id: user.id,
company_id: companyId,
rule_name: body.rule_name, rule_name: body.rule_name,
rule_type: body.rule_type, rule_type: body.rule_type,
priority: body.priority || 10, priority: body.priority || 10,
+2 -2
View File
@@ -101,7 +101,7 @@ export async function GET(
? supabase ? supabase
.from('deadlines') .from('deadlines')
.select('*') .select('*')
.eq('user_id', feed.user_id) .eq('company_id', feed.company_id)
.gte('due_date', startStr) .gte('due_date', startStr)
.lte('due_date', endStr) .lte('due_date', endStr)
.order('due_date') .order('due_date')
@@ -112,7 +112,7 @@ export async function GET(
? supabase ? supabase
.from('invoices') .from('invoices')
.select('*, customer:customers(*)') .select('*, customer:customers(*)')
.eq('user_id', feed.user_id) .eq('company_id', feed.company_id)
.gte('due_date', startStr) .gte('due_date', startStr)
.lte('due_date', endStr) .lte('due_date', endStr)
.order('due_date') .order('due_date')
+14 -4
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import type { UpdateCalendarFeedInput } from '@/types' import type { UpdateCalendarFeedInput } from '@/types'
/** /**
@@ -15,10 +16,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { data: feed, error } = await supabase const { data: feed, error } = await supabase
.from('calendar_feeds') .from('calendar_feeds')
.select('*') .select('*')
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (error && error.code !== 'PGRST116') { if (error && error.code !== 'PGRST116') {
@@ -57,11 +60,13 @@ export async function POST() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Check if feed already exists // Check if feed already exists
const { data: existingFeed } = await supabase const { data: existingFeed } = await supabase
.from('calendar_feeds') .from('calendar_feeds')
.select('id') .select('id')
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (existingFeed) { if (existingFeed) {
@@ -76,6 +81,7 @@ export async function POST() {
.from('calendar_feeds') .from('calendar_feeds')
.insert({ .insert({
user_id: user.id, user_id: user.id,
company_id: companyId,
is_active: true, is_active: true,
include_tax_deadlines: true, include_tax_deadlines: true,
include_invoices: true, include_invoices: true,
@@ -111,12 +117,14 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const body: UpdateCalendarFeedInput = await request.json() const body: UpdateCalendarFeedInput = await request.json()
const { data: feed, error } = await supabase const { data: feed, error } = await supabase
.from('calendar_feeds') .from('calendar_feeds')
.update(body) .update(body)
.eq('user_id', user.id) .eq('company_id', companyId)
.select() .select()
.single() .single()
@@ -148,6 +156,8 @@ export async function DELETE() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Generate a new token by updating with a new UUID // Generate a new token by updating with a new UUID
const { data: feed, error } = await supabase const { data: feed, error } = await supabase
.from('calendar_feeds') .from('calendar_feeds')
@@ -156,7 +166,7 @@ export async function DELETE() {
access_count: 0, access_count: 0,
last_accessed_at: null, last_accessed_at: null,
}) })
.eq('user_id', user.id) .eq('company_id', companyId)
.select() .select()
.single() .single()
+86
View File
@@ -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 } })
}
@@ -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 } })
}
+158
View File
@@ -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 }) },
})
}
+80
View File
@@ -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,
},
})
}
+14 -7
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { UpdateCustomerSchema } from '@/lib/api/schemas' import { UpdateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client' import { validateVatNumber } from '@/lib/vat/vies-client'
import { requireCompanyId } from '@/lib/company/context'
import { createLogger } from '@/lib/logger' import { createLogger } from '@/lib/logger'
const log = createLogger('api/customers/[id]') const log = createLogger('api/customers/[id]')
@@ -22,11 +23,13 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase const { data, error } = await supabase
.from('customers') .from('customers')
.select('*') .select('*')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (error) { if (error) {
@@ -41,7 +44,7 @@ export async function GET(
.from('invoices') .from('invoices')
.select('id, invoice_number, invoice_date, due_date, status, total, currency') .select('id, invoice_number, invoice_date, due_date, status, total, currency')
.eq('customer_id', id) .eq('customer_id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.order('invoice_date', { ascending: false }) .order('invoice_date', { ascending: false })
return NextResponse.json({ return NextResponse.json({
@@ -67,6 +70,8 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, UpdateCustomerSchema) const result = await validateBody(request, UpdateCustomerSchema)
if (!result.success) return result.response if (!result.success) return result.response
const body = result.data const body = result.data
@@ -91,7 +96,7 @@ export async function PATCH(
.from('customers') .from('customers')
.update(updateData) .update(updateData)
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.select() .select()
.single() .single()
@@ -113,7 +118,7 @@ export async function PATCH(
vat_number_validated_at: new Date().toISOString(), vat_number_validated_at: new Date().toISOString(),
}) })
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
data.vat_number_validated = true data.vat_number_validated = true
data.vat_number_validated_at = new Date().toISOString() data.vat_number_validated_at = new Date().toISOString()
@@ -125,7 +130,7 @@ export async function PATCH(
vat_number_validated_at: null, vat_number_validated_at: null,
}) })
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
data.vat_number_validated = false data.vat_number_validated = false
data.vat_number_validated_at = null data.vat_number_validated_at = null
@@ -139,7 +144,7 @@ export async function PATCH(
vat_number_validated_at: null, vat_number_validated_at: null,
}) })
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
data.vat_number_validated = false data.vat_number_validated = false
data.vat_number_validated_at = null data.vat_number_validated_at = null
@@ -167,11 +172,13 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { error } = await supabase const { error } = await supabase
.from('customers') .from('customers')
.delete() .delete()
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: error.message }, { status: 500 })
+9 -3
View File
@@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { CreateCustomerSchema } from '@/lib/api/schemas' import { CreateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client' import { validateVatNumber } from '@/lib/vat/vies-client'
import { requireCompanyId } from '@/lib/company/context'
import { createLogger } from '@/lib/logger' import { createLogger } from '@/lib/logger'
import type { Customer } from '@/types' import type { Customer } from '@/types'
@@ -21,10 +22,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase const { data, error } = await supabase
.from('customers') .from('customers')
.select('*') .select('*')
.eq('user_id', user.id) .eq('company_id', companyId)
.order('name', { ascending: true }) .order('name', { ascending: true })
if (error) { if (error) {
@@ -43,6 +46,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, CreateCustomerSchema) const result = await validateBody(request, CreateCustomerSchema)
if (!result.success) return result.response if (!result.success) return result.response
const body = result.data const body = result.data
@@ -51,6 +56,7 @@ export async function POST(request: Request) {
.from('customers') .from('customers')
.insert({ .insert({
user_id: user.id, user_id: user.id,
company_id: companyId,
name: body.name, name: body.name,
customer_type: body.customer_type, customer_type: body.customer_type,
email: body.email, email: body.email,
@@ -84,7 +90,7 @@ export async function POST(request: Request) {
vat_number_validated_at: new Date().toISOString(), vat_number_validated_at: new Date().toISOString(),
}) })
.eq('id', data.id) .eq('id', data.id)
.eq('user_id', user.id) .eq('company_id', companyId)
data.vat_number_validated = true data.vat_number_validated = true
data.vat_number_validated_at = new Date().toISOString() data.vat_number_validated_at = new Date().toISOString()
@@ -96,7 +102,7 @@ export async function POST(request: Request) {
await eventBus.emit({ await eventBus.emit({
type: 'customer.created', type: 'customer.created',
payload: { customer: data as Customer, userId: user.id }, payload: { customer: data as Customer, companyId, userId: user.id },
}) })
return NextResponse.json({ data }) return NextResponse.json({ data })
+5 -2
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* POST /api/deadlines/[id]/complete * POST /api/deadlines/[id]/complete
@@ -20,12 +21,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// First, get current deadline state // First, get current deadline state
const { data: existing, error: fetchError } = await supabase const { data: existing, error: fetchError } = await supabase
.from('deadlines') .from('deadlines')
.select('is_completed') .select('is_completed')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (fetchError) { if (fetchError) {
@@ -44,7 +47,7 @@ export async function POST(
completed_at: newCompletedState ? new Date().toISOString() : null, completed_at: newCompletedState ? new Date().toISOString() : null,
}) })
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.select('*, customer:customers(id, name)') .select('*, customer:customers(id, name)')
.single() .single()
+11 -4
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import type { CreateDeadlineInput } from '@/types' import type { CreateDeadlineInput } from '@/types'
/** /**
@@ -21,11 +22,13 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase const { data, error } = await supabase
.from('deadlines') .from('deadlines')
.select('*, customer:customers(id, name)') .select('*, customer:customers(id, name)')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (error) { if (error) {
@@ -57,6 +60,8 @@ export async function PUT(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const body: Partial<CreateDeadlineInput> = await request.json() const body: Partial<CreateDeadlineInput> = await request.json()
// First, get existing deadline to verify ownership // First, get existing deadline to verify ownership
@@ -64,7 +69,7 @@ export async function PUT(
.from('deadlines') .from('deadlines')
.select('*') .select('*')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (fetchError) { if (fetchError) {
@@ -89,7 +94,7 @@ export async function PUT(
.from('deadlines') .from('deadlines')
.update(updateData) .update(updateData)
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.select('*, customer:customers(id, name)') .select('*, customer:customers(id, name)')
.single() .single()
@@ -119,11 +124,13 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { error } = await supabase const { error } = await supabase
.from('deadlines') .from('deadlines')
.delete() .delete()
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: error.message }, { status: 500 })
+7 -2
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine' import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine'
import { requireCompanyId } from '@/lib/company/context'
import type { DeadlineStatus } from '@/types' import type { DeadlineStatus } from '@/types'
/** /**
@@ -19,6 +20,8 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params const { id } = await params
const body = await request.json() const body = await request.json()
@@ -41,7 +44,7 @@ export async function PATCH(
return NextResponse.json({ error: 'Invalid status' }, { status: 400 }) 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) { if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 }) return NextResponse.json({ error: result.error }, { status: 400 })
@@ -66,13 +69,15 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params const { id } = await params
const { data: deadline, error } = await supabase const { data: deadline, error } = await supabase
.from('deadlines') .from('deadlines')
.select('status, is_completed, due_date') .select('status, is_completed, due_date')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (error || !deadline) { if (error || !deadline) {
+7 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { CreateDeadlineSchema } from '@/lib/api/schemas' import { CreateDeadlineSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* GET /api/deadlines * GET /api/deadlines
@@ -23,6 +24,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Parse query params // Parse query params
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const status = searchParams.get('status') || 'all' const status = searchParams.get('status') || 'all'
@@ -34,7 +37,7 @@ export async function GET(request: Request) {
let query = supabase let query = supabase
.from('deadlines') .from('deadlines')
.select('*, customer:customers(id, name)') .select('*, customer:customers(id, name)')
.eq('user_id', user.id) .eq('company_id', companyId)
// Apply filters // Apply filters
if (status === 'pending') { if (status === 'pending') {
@@ -79,6 +82,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateDeadlineSchema) const validation = await validateBody(request, CreateDeadlineSchema)
if (!validation.success) return validation.response if (!validation.success) return validation.response
const body = validation.data const body = validation.data
@@ -88,6 +93,7 @@ export async function POST(request: Request) {
.from('deadlines') .from('deadlines')
.insert({ .insert({
user_id: user.id, user_id: user.id,
company_id: companyId,
title: body.title, title: body.title,
due_date: body.due_date, due_date: body.due_date,
due_time: body.due_time || null, due_time: body.due_time || null,
+4 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { linkToJournalEntry } from '@/lib/core/documents/document-service' import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized() ensureInitialized()
@@ -25,6 +26,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params const { id } = await params
try { try {
@@ -39,7 +42,7 @@ export async function POST(
const document = await linkToJournalEntry( const document = await linkToJournalEntry(
supabase, supabase,
user.id, companyId,
id, id,
body.journal_entry_id, body.journal_entry_id,
body.journal_entry_line_id body.journal_entry_line_id
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized() ensureInitialized()
@@ -20,6 +21,8 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params const { id } = await params
// Fetch document record // Fetch document record
@@ -27,7 +30,7 @@ export async function GET(
.from('document_attachments') .from('document_attachments')
.select('*') .select('*')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (docError || !doc) { if (docError || !doc) {
+4 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { verifyIntegrity } from '@/lib/core/documents/document-service' import { verifyIntegrity } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized() ensureInitialized()
@@ -21,10 +22,12 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params const { id } = await params
try { try {
const result = await verifyIntegrity(supabase, user.id, id) const result = await verifyIntegrity(supabase, companyId, id)
return NextResponse.json({ data: result }) return NextResponse.json({ data: result })
} catch (error) { } catch (error) {
+8 -3
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { createNewVersion } from '@/lib/core/documents/document-service' import { createNewVersion } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized() ensureInitialized()
@@ -24,6 +25,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params const { id } = await params
try { try {
@@ -68,14 +71,16 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params 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 const { data: doc, error: docError } = await supabase
.from('document_attachments') .from('document_attachments')
.select('id, original_id') .select('id, original_id')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (docError || !doc) { if (docError || !doc) {
@@ -89,7 +94,7 @@ export async function GET(
const { data: versions, error: versionsError } = await supabase const { data: versions, error: versionsError } = await supabase
.from('document_attachments') .from('document_attachments')
.select('*') .select('*')
.eq('user_id', user.id) .eq('company_id', companyId)
.or(`id.eq.${rootId},original_id.eq.${rootId}`) .or(`id.eq.${rootId},original_id.eq.${rootId}`)
.order('version', { ascending: true }) .order('version', { ascending: true })
+4 -1
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* GET /api/documents/counts?journal_entry_ids=id1,id2,... * 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 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const idsParam = searchParams.get('journal_entry_ids') const idsParam = searchParams.get('journal_entry_ids')
@@ -35,7 +38,7 @@ export async function GET(request: Request) {
const { data, error } = await supabase const { data, error } = await supabase
.from('document_attachments') .from('document_attachments')
.select('journal_entry_id') .select('journal_entry_id')
.eq('user_id', user.id) .eq('company_id', companyId)
.eq('is_current_version', true) .eq('is_current_version', true)
.in('journal_entry_id', ids) .in('journal_entry_id', ids)
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { runDocumentMatchingSweep } from '@/lib/documents/batch-match' import { runDocumentMatchingSweep } from '@/lib/documents/batch-match'
import { requireCompanyId } from '@/lib/company/context'
export async function POST(request: Request) { export async function POST(request: Request) {
const supabase = await createClient() const supabase = await createClient()
@@ -10,6 +11,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Optional: pass specific inbox item IDs to match // Optional: pass specific inbox item IDs to match
let inboxItemIds: string[] | undefined let inboxItemIds: string[] | undefined
try { try {
@@ -22,7 +25,7 @@ export async function POST(request: Request) {
} }
try { try {
const result = await runDocumentMatchingSweep(supabase, user.id, inboxItemIds) const result = await runDocumentMatchingSweep(supabase, companyId, inboxItemIds)
return NextResponse.json({ data: result }) return NextResponse.json({ data: result })
} catch (error) { } catch (error) {
console.error('[match-sweep] Failed:', error) console.error('[match-sweep] Failed:', error)
+7 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { uploadDocument } from '@/lib/core/documents/document-service' import { uploadDocument } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized() ensureInitialized()
@@ -24,6 +25,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
try { try {
const formData = await request.formData() const formData = await request.formData()
const file = formData.get('file') as File | null const file = formData.get('file') as File | null
@@ -38,7 +41,7 @@ export async function POST(request: Request) {
const buffer = await file.arrayBuffer() const buffer = await file.arrayBuffer()
const document = await uploadDocument(supabase, user.id, { const document = await uploadDocument(supabase, user.id, companyId, {
name: file.name, name: file.name,
buffer, buffer,
type: file.type, type: file.type,
@@ -77,6 +80,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const journalEntryId = searchParams.get('journal_entry_id') const journalEntryId = searchParams.get('journal_entry_id')
const currentOnly = searchParams.get('current_only') !== 'false' const currentOnly = searchParams.get('current_only') !== 'false'
@@ -86,7 +91,7 @@ export async function GET(request: Request) {
let query = supabase let query = supabase
.from('document_attachments') .from('document_attachments')
.select('*', { count: 'exact' }) .select('*', { count: 'exact' })
.eq('user_id', user.id) .eq('company_id', companyId)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.range(offset, offset + limit - 1) .range(offset, offset + limit - 1)
+5
View File
@@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase), 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 // Mock API key auth
const mockValidateApiKey = vi.fn() const mockValidateApiKey = vi.fn()
const mockExtractBearerToken = vi.fn() const mockExtractBearerToken = vi.fn()
+4 -1
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { validateQuery } from '@/lib/api/validate' import { validateQuery } from '@/lib/api/validate'
import { EventsQuerySchema } from '@/lib/api/schemas' import { EventsQuerySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import type { SupabaseClient } from '@supabase/supabase-js' import type { SupabaseClient } from '@supabase/supabase-js'
/** /**
@@ -40,6 +41,8 @@ export async function GET(request: Request) {
userId = user.id userId = user.id
} }
const companyId = await requireCompanyId(supabase, userId)
// Validate query params // Validate query params
const result = validateQuery(request, EventsQuerySchema) const result = validateQuery(request, EventsQuerySchema)
if (!result.success) return result.response if (!result.success) return result.response
@@ -49,7 +52,7 @@ export async function GET(request: Request) {
let query = supabase let query = supabase
.from('event_log') .from('event_log')
.select('sequence, event_type, entity_id, data, created_at') .select('sequence, event_type, entity_id, data, created_at')
.eq('user_id', userId) .eq('company_id', companyId)
.order('sequence', { ascending: true }) .order('sequence', { ascending: true })
.limit(limit) .limit(limit)
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET( export async function GET(
request: Request, request: Request,
@@ -13,6 +14,8 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const extensionId = `${sector}/${slug}` const extensionId = `${sector}/${slug}`
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
@@ -21,7 +24,7 @@ export async function GET(
let query = supabase let query = supabase
.from('extension_data') .from('extension_data')
.select('*') .select('*')
.eq('user_id', user.id) .eq('company_id', companyId)
.eq('extension_id', extensionId) .eq('extension_id', extensionId)
const prefix = searchParams.get('prefix') const prefix = searchParams.get('prefix')
@@ -53,6 +56,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json() const body = await request.json()
const { key, value } = body const { key, value } = body
@@ -67,6 +72,7 @@ export async function POST(
.upsert( .upsert(
{ {
user_id: user.id, user_id: user.id,
company_id: companyId,
extension_id: extensionId, extension_id: extensionId,
key, key,
value, value,
@@ -95,6 +101,8 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const key = searchParams.get('key') const key = searchParams.get('key')
@@ -107,7 +115,7 @@ export async function DELETE(
const { error } = await supabase const { error } = await supabase
.from('extension_data') .from('extension_data')
.delete() .delete()
.eq('user_id', user.id) .eq('company_id', companyId)
.eq('extension_id', extensionId) .eq('extension_id', extensionId)
.eq('key', key) .eq('key', key)
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET( export async function GET(
_request: Request, _request: Request,
@@ -13,12 +14,14 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const extensionId = `${sector}/${slug}` const extensionId = `${sector}/${slug}`
const { data } = await supabase const { data } = await supabase
.from('extension_data') .from('extension_data')
.select('value') .select('value')
.eq('user_id', user.id) .eq('company_id', companyId)
.eq('extension_id', extensionId) .eq('extension_id', extensionId)
.eq('key', 'settings') .eq('key', 'settings')
.single() .single()
@@ -38,6 +41,8 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json() const body = await request.json()
const extensionId = `${sector}/${slug}` const extensionId = `${sector}/${slug}`
@@ -45,7 +50,7 @@ export async function PATCH(
const { data: existing } = await supabase const { data: existing } = await supabase
.from('extension_data') .from('extension_data')
.select('value') .select('value')
.eq('user_id', user.id) .eq('company_id', companyId)
.eq('extension_id', extensionId) .eq('extension_id', extensionId)
.eq('key', 'settings') .eq('key', 'settings')
.single() .single()
@@ -57,6 +62,7 @@ export async function PATCH(
.upsert( .upsert(
{ {
user_id: user.id, user_id: user.id,
company_id: companyId,
extension_id: extensionId, extension_id: extensionId,
key: 'settings', key: 'settings',
value: mergedSettings, value: mergedSettings,
@@ -81,7 +81,7 @@ export async function GET(request: Request) {
// Look up pending connection by oauth_state (CSRF-safe) // Look up pending connection by oauth_state (CSRF-safe)
const { data: pendingConnection, error: findError } = await supabase const { data: pendingConnection, error: findError } = await supabase
.from('bank_connections') .from('bank_connections')
.select('id, user_id') .select('id, user_id, company_id')
.eq('oauth_state', state) .eq('oauth_state', state)
.eq('status', 'pending') .eq('status', 'pending')
.single() .single()
@@ -98,6 +98,7 @@ export async function GET(request: Request) {
} }
const userId = pendingConnection.user_id const userId = pendingConnection.user_id
const companyId = pendingConnection.company_id
console.log('[enable-banking] Exchanging code for session', { console.log('[enable-banking] Exchanging code for session', {
connectionId: pendingConnection.id, connectionId: pendingConnection.id,
@@ -166,7 +167,7 @@ export async function GET(request: Request) {
const { data: userSettings } = await supabase const { data: userSettings } = await supabase
.from('company_settings') .from('company_settings')
.select('onboarding_complete') .select('onboarding_complete')
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
const redirectTarget = userSettings?.onboarding_complete const redirectTarget = userSettings?.onboarding_complete
@@ -143,7 +143,7 @@ export async function GET(request: Request) {
const { data: sieOverlap } = await supabase const { data: sieOverlap } = await supabase
.from('sie_imports') .from('sie_imports')
.select('id') .select('id')
.eq('user_id', connection.user_id) .eq('company_id', connection.company_id)
.eq('status', 'completed') .eq('status', 'completed')
.gte('fiscal_year_end', fromDate) .gte('fiscal_year_end', fromDate)
.limit(1) .limit(1)
@@ -156,6 +156,7 @@ export async function GET(request: Request) {
const syncResults = await Promise.all( const syncResults = await Promise.all(
accounts.map(account => syncAccountTransactions( accounts.map(account => syncAccountTransactions(
supabase, supabase,
connection.company_id,
connection.user_id, connection.user_id,
connection.id, connection.id,
account, account,
@@ -284,7 +285,7 @@ async function sendConsentExpiryNotification(
const { data: companySettings } = await supabase const { data: companySettings } = await supabase
.from('company_settings') .from('company_settings')
.select('company_name') .select('company_name')
.eq('user_id', userId) .eq('company_id', connection.company_id)
.single() .single()
const emailData = { const emailData = {
@@ -15,6 +15,11 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(), 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', () => ({ vi.mock('@/lib/extensions/context-factory', () => ({
createExtensionContext: vi.fn().mockReturnValue({ createExtensionContext: vi.fn().mockReturnValue({
userId: 'user-1', userId: 'user-1',
+5 -2
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { extensionRegistry } from '@/lib/extensions/registry' import { extensionRegistry } from '@/lib/extensions/registry'
import { createExtensionContext } from '@/lib/extensions/context-factory' import { createExtensionContext } from '@/lib/extensions/context-factory'
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent' import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
import { requireCompanyId } from '@/lib/company/context'
import type { ApiRouteDefinition } from '@/lib/extensions/types' import type { ApiRouteDefinition } from '@/lib/extensions/types'
ensureInitialized() ensureInitialized()
@@ -117,9 +118,11 @@ async function handleRequest(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// AI consent check // AI consent check
if (isAiExtension(extensionId)) { if (isAiExtension(extensionId)) {
const consented = await hasAiConsent(supabase, user.id, extensionId) const consented = await hasAiConsent(supabase, companyId, extensionId)
if (!consented) { if (!consented) {
return NextResponse.json( return NextResponse.json(
{ error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' }, { error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' },
@@ -145,7 +148,7 @@ async function handleRequest(
} }
// Build context and dispatch // Build context and dispatch
const ctx = createExtensionContext(supabase, user.id, extensionId) const ctx = createExtensionContext(supabase, user.id, companyId, extensionId)
return matchedRoute.handler(handlerRequest, ctx) return matchedRoute.handler(handlerRequest, ctx)
} }
@@ -142,7 +142,7 @@ describe('Invoice Inbox Webhook Route', () => {
created_at: '2024-06-15T10:00:00Z', created_at: '2024-06-15T10:00:00Z',
}) })
mockExtractAttachments.mockReturnValue([]) mockExtractAttachments.mockReturnValue([])
mockResolveUserFromEmail.mockResolvedValue('user-1') mockResolveUserFromEmail.mockResolvedValue({ userId: 'user-1', companyId: 'company-1' })
// Insert inbox item with error status // Insert inbox item with error status
enqueueMany([ enqueueMany([
@@ -79,13 +79,15 @@ export async function POST(request: Request) {
const supabase = createServiceClient() const supabase = createServiceClient()
// Resolve user from recipient email // 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}`) console.warn(`[document-inbox] No user found for email: ${payload.to}`)
return NextResponse.json({ error: 'User not found' }, { status: 404 }) 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) // Build raw email payload for BFL 7:2 archiving (no binary attachment content)
const rawEmailPayload = buildRawEmailPayload(body, payload) const rawEmailPayload = buildRawEmailPayload(body, payload)
@@ -96,6 +98,7 @@ export async function POST(request: Request) {
await supabase await supabase
.from('invoice_inbox_items') .from('invoice_inbox_items')
.insert({ .insert({
company_id: companyId,
user_id: userId, user_id: userId,
status: 'error', status: 'error',
source: 'email', source: 'email',
@@ -131,6 +134,7 @@ export async function POST(request: Request) {
const { data: document, error: docError } = await supabase const { data: document, error: docError } = await supabase
.from('document_attachments') .from('document_attachments')
.insert({ .insert({
company_id: companyId,
user_id: userId, user_id: userId,
storage_path: storagePath, storage_path: storagePath,
file_name: attachment.filename, file_name: attachment.filename,
@@ -159,6 +163,7 @@ export async function POST(request: Request) {
const { data: inboxItem, error: itemError } = await supabase const { data: inboxItem, error: itemError } = await supabase
.from('invoice_inbox_items') .from('invoice_inbox_items')
.insert({ .insert({
company_id: companyId,
user_id: userId, user_id: userId,
status: 'processing', status: 'processing',
source: 'email', source: 'email',
@@ -197,7 +202,7 @@ export async function POST(request: Request) {
const { data: suppliers } = await supabase const { data: suppliers } = await supabase
.from('suppliers') .from('suppliers')
.select('*') .select('*')
.eq('user_id', userId) .eq('company_id', companyId)
if (suppliers && suppliers.length > 0) { if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers) const match = matchSupplier(extraction, suppliers)
@@ -222,7 +227,7 @@ export async function POST(request: Request) {
// Use pre-extracted receipt data from unified call // Use pre-extracted receipt data from unified call
const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath) 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, documentId: document.id,
source: 'email', source: 'email',
emailFrom: payload.from, emailFrom: payload.from,
+6 -1
View File
@@ -4,6 +4,7 @@ import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest' import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
import { generateExternalId } from '@/lib/import/bank-file/parser' 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 { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
import type { Transaction } from '@/types' import type { Transaction } from '@/types'
@@ -33,6 +34,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const body: ExecuteRequest = await request.json() const body: ExecuteRequest = await request.json()
const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true } = body 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') .from('bank_file_imports')
.upsert({ .upsert({
user_id: user.id, user_id: user.id,
company_id: companyId,
filename, filename,
file_hash, file_hash,
file_format: format, file_format: format,
@@ -76,7 +80,7 @@ export async function POST(request: Request) {
})) }))
// Run ingestion pipeline // 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 // Update import record with results
await supabase await supabase
@@ -106,6 +110,7 @@ export async function POST(request: Request) {
payload: { payload: {
transactions: importedTransactions as Transaction[], transactions: importedTransactions as Transaction[],
userId: user.id, userId: user.id,
companyId,
}, },
}) })
} }
+5 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser' import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser'
import { decodeFileContent } from '@/lib/import/bank-file/encoding' import { decodeFileContent } from '@/lib/import/bank-file/encoding'
import { requireCompanyId } from '@/lib/company/context'
import type { BankFileFormatId } from '@/lib/import/bank-file/types' 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 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const formData = await request.formData() const formData = await request.formData()
const file = formData.get('file') as File | null const file = formData.get('file') as File | null
const formatOverride = formData.get('format') as BankFileFormatId | null const formatOverride = formData.get('format') as BankFileFormatId | null
@@ -41,7 +44,7 @@ export async function POST(request: Request) {
const { data: existingImport } = await supabase const { data: existingImport } = await supabase
.from('bank_file_imports') .from('bank_file_imports')
.select('id, status, imported_count, created_at') .select('id, status, imported_count, created_at')
.eq('user_id', user.id) .eq('company_id', companyId)
.eq('file_hash', fileHash) .eq('file_hash', fileHash)
.single() .single()
@@ -67,7 +70,7 @@ export async function POST(request: Request) {
const { count } = await supabase const { count } = await supabase
.from('transactions') .from('transactions')
.select('*', { count: 'exact', head: true }) .select('*', { count: 'exact', head: true })
.eq('user_id', user.id) .eq('company_id', companyId)
.gte('date', parseResult.date_from || '1970-01-01') .gte('date', parseResult.date_from || '1970-01-01')
.lte('date', parseResult.date_to || '2099-12-31') .lte('date', parseResult.date_to || '2099-12-31')
+8 -3
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* GET /api/import/sie/[id] * GET /api/import/sie/[id]
@@ -20,11 +21,13 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase const { data, error } = await supabase
.from('sie_imports') .from('sie_imports')
.select('*') .select('*')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (error) { if (error) {
@@ -62,12 +65,14 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Check current status before deleting // Check current status before deleting
const { data: importRecord } = await supabase const { data: importRecord } = await supabase
.from('sie_imports') .from('sie_imports')
.select('status') .select('status')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (!importRecord) { if (!importRecord) {
@@ -84,7 +89,7 @@ export async function DELETE(
.from('sie_imports') .from('sie_imports')
.delete() .delete()
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: error.message }, { status: 500 })
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import type { SIEAccount } from '@/lib/import/types' import type { SIEAccount } from '@/lib/import/types'
/** /**
@@ -63,6 +64,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
try { try {
const body = await request.json() const body = await request.json()
const accounts: SIEAccount[] = body.accounts const accounts: SIEAccount[] = body.accounts
@@ -80,6 +83,7 @@ export async function POST(request: Request) {
return { return {
user_id: user.id, user_id: user.id,
company_id: companyId,
account_number: account.number, account_number: account.number,
account_name: account.name, account_name: account.name,
account_class: accountClass, account_class: accountClass,
+11 -4
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all' import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser' import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
import { suggestMappings } from '@/lib/import/account-mapper' import { suggestMappings } from '@/lib/import/account-mapper'
import { executeSIEImport } from '@/lib/import/sie-import' import { executeSIEImport } from '@/lib/import/sie-import'
@@ -26,6 +27,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
try { try {
// Get form data with file and options // Get form data with file and options
const formData = await request.formData() const formData = await request.formData()
@@ -63,7 +66,7 @@ export async function POST(request: Request) {
const { data: storedMappings } = await supabase const { data: storedMappings } = await supabase
.from('sie_account_mappings') .from('sie_account_mappings')
.select('*') .select('*')
.eq('user_id', user.id) .eq('company_id', companyId)
mappings = suggestMappings( mappings = suggestMappings(
parsed.accounts, parsed.accounts,
@@ -90,14 +93,15 @@ export async function POST(request: Request) {
...new Set(mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)), ...new Set(mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)),
] ]
const existingAccounts = await fetchAllRows(({ from, to }) => const allCompanyAccounts = await fetchAllRows(({ from, to }) =>
supabase supabase
.from('chart_of_accounts') .from('chart_of_accounts')
.select('account_number') .select('account_number')
.eq('user_id', user.id) .eq('company_id', companyId)
.in('account_number', mappedAccountNumbers)
.range(from, to) .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) // Build a lookup from SIE mappings for account names (used for bas_range accounts)
const mappingNameLookup = new Map<string, string>() const mappingNameLookup = new Map<string, string>()
@@ -116,6 +120,7 @@ export async function POST(request: Request) {
// Account exists in BAS reference — use full metadata // Account exists in BAS reference — use full metadata
return { return {
user_id: user.id, user_id: user.id,
company_id: companyId,
account_number: ref.account_number, account_number: ref.account_number,
account_name: ref.account_name, account_name: ref.account_name,
account_class: ref.account_class, account_class: ref.account_class,
@@ -146,6 +151,7 @@ export async function POST(request: Request) {
return { return {
user_id: user.id, user_id: user.id,
company_id: companyId,
account_number: num, account_number: num,
account_name: accountName, account_name: accountName,
account_class: accountClass, account_class: accountClass,
@@ -176,6 +182,7 @@ export async function POST(request: Request) {
// Execute the import // Execute the import
const result = await executeSIEImport( const result = await executeSIEImport(
supabase, supabase,
companyId,
user.id, user.id,
parsed, parsed,
mappings, mappings,
+13 -3
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { saveMappings } from '@/lib/import/sie-import' import { saveMappings } from '@/lib/import/sie-import'
import { requireCompanyId } from '@/lib/company/context'
import type { AccountMapping } from '@/lib/import/types' import type { AccountMapping } from '@/lib/import/types'
/** /**
@@ -18,10 +19,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase const { data, error } = await supabase
.from('sie_account_mappings') .from('sie_account_mappings')
.select('*') .select('*')
.eq('user_id', user.id) .eq('company_id', companyId)
.order('source_account') .order('source_account')
if (error) { if (error) {
@@ -46,6 +49,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json() const body = await request.json()
const mappings: AccountMapping[] = body.mappings const mappings: AccountMapping[] = body.mappings
@@ -79,6 +84,8 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json() const body = await request.json()
const { sourceAccount, targetAccount } = body const { sourceAccount, targetAccount } = body
@@ -93,6 +100,7 @@ export async function PUT(request: Request) {
.from('sie_account_mappings') .from('sie_account_mappings')
.upsert({ .upsert({
user_id: user.id, user_id: user.id,
company_id: companyId,
source_account: sourceAccount, source_account: sourceAccount,
target_account: targetAccount, target_account: targetAccount,
confidence: 1.0, confidence: 1.0,
@@ -125,6 +133,8 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const sourceAccount = searchParams.get('sourceAccount') const sourceAccount = searchParams.get('sourceAccount')
@@ -133,7 +143,7 @@ export async function DELETE(request: Request) {
const { error } = await supabase const { error } = await supabase
.from('sie_account_mappings') .from('sie_account_mappings')
.delete() .delete()
.eq('user_id', user.id) .eq('company_id', companyId)
.eq('source_account', sourceAccount) .eq('source_account', sourceAccount)
if (error) { if (error) {
@@ -144,7 +154,7 @@ export async function DELETE(request: Request) {
const { error } = await supabase const { error } = await supabase
.from('sie_account_mappings') .from('sie_account_mappings')
.delete() .delete()
.eq('user_id', user.id) .eq('company_id', companyId)
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ error: error.message }, { status: 500 })
+4 -1
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { import {
parseSIEFile, parseSIEFile,
validateSIEFile, validateSIEFile,
@@ -27,6 +28,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
try { try {
// Get form data with file // Get form data with file
const formData = await request.formData() const formData = await request.formData()
@@ -91,7 +94,7 @@ export async function POST(request: Request) {
const { data: storedMappings } = await supabase const { data: storedMappings } = await supabase
.from('sie_account_mappings') .from('sie_account_mappings')
.select('*') .select('*')
.eq('user_id', user.id) .eq('company_id', companyId)
// Match against the full BAS reference (1,276 accounts) instead of only // Match against the full BAS reference (1,276 accounts) instead of only
// the user's active chart (~40 accounts). Accounts that match will be // the user's active chart (~40 accounts). Accounts that match will be
+4 -1
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* GET /api/import/sie * GET /api/import/sie
@@ -16,6 +17,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Parse query params // Parse query params
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get('limit') || '20', 10) const limit = parseInt(searchParams.get('limit') || '20', 10)
@@ -25,7 +28,7 @@ export async function GET(request: Request) {
let query = supabase let query = supabase
.from('sie_imports') .from('sie_imports')
.select('*', { count: 'exact' }) .select('*', { count: 'exact' })
.eq('user_id', user.id) .eq('company_id', companyId)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.range(offset, offset + limit - 1) .range(offset, offset + limit - 1)
+6 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events' import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice } from '@/types' import type { Invoice } from '@/types'
ensureInitialized() ensureInitialized()
@@ -25,12 +26,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Fetch proforma with items // Fetch proforma with items
const { data: proforma, error: proformaError } = await supabase const { data: proforma, error: proformaError } = await supabase
.from('invoices') .from('invoices')
.select('*, items:invoice_items(*)') .select('*, items:invoice_items(*)')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (proformaError || !proforma) { if (proformaError || !proforma) {
@@ -61,6 +64,7 @@ export async function POST(
.from('invoices') .from('invoices')
.insert({ .insert({
user_id: user.id, user_id: user.id,
company_id: companyId,
customer_id: proforma.customer_id, customer_id: proforma.customer_id,
invoice_number: invoiceNumber, invoice_number: invoiceNumber,
invoice_date: new Date().toISOString().split('T')[0], invoice_date: new Date().toISOString().split('T')[0],
@@ -129,7 +133,7 @@ export async function POST(
if (completeInvoice) { if (completeInvoice) {
await eventBus.emit({ await eventBus.emit({
type: 'invoice.created', type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId: user.id }, payload: { invoice: completeInvoice as Invoice, companyId, userId: user.id },
}) })
} }
@@ -17,6 +17,10 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(), ensureInitialized: vi.fn(),
})) }))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCreateInvoicePaymentJournalEntry = vi.fn() const mockCreateInvoicePaymentJournalEntry = vi.fn()
const mockCreateInvoiceCashEntry = vi.fn() const mockCreateInvoiceCashEntry = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ 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(body.journal_entry_id).toBe('je-1')
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith( expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
'company-1',
'user-1', 'user-1',
expect.objectContaining({ id: 'inv-1' }), expect.objectContaining({ id: 'inv-1' }),
expect.any(String), expect.any(String),
@@ -172,6 +177,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(body.journal_entry_id).toBe('je-2') expect(body.journal_entry_id).toBe('je-2')
expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith( expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
'company-1',
'user-1', 'user-1',
expect.objectContaining({ id: 'inv-1' }), expect.objectContaining({ id: 'inv-1' }),
expect.any(String), expect.any(String),
@@ -235,6 +241,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
// Should call createJournalEntry directly with custom lines // Should call createJournalEntry directly with custom lines
expect(mockCreateJournalEntry).toHaveBeenCalledWith( expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
'company-1',
'user-1', 'user-1',
expect.objectContaining({ expect.objectContaining({
entry_date: '2025-03-17', entry_date: '2025-03-17',
+10 -5
View File
@@ -7,6 +7,7 @@ import {
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { MarkInvoicePaidSchema } from '@/lib/api/schemas' import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types' import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
ensureInitialized() ensureInitialized()
@@ -35,12 +36,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice // Fetch invoice
const { data: invoice, error: invoiceError } = await supabase const { data: invoice, error: invoiceError } = await supabase
.from('invoices') .from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)') .select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (invoiceError || !invoice) { if (invoiceError || !invoice) {
@@ -83,7 +86,7 @@ export async function POST(
const { data: settings } = await supabase const { data: settings } = await supabase
.from('company_settings') .from('company_settings')
.select('accounting_method, entity_type') .select('accounting_method, entity_type')
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
const accountingMethod = settings?.accounting_method || 'accrual' const accountingMethod = settings?.accounting_method || 'accrual'
@@ -107,7 +110,7 @@ export async function POST(
} }
// User-provided lines from PaymentBookingDialog // User-provided lines from PaymentBookingDialog
const fiscalPeriodId = await findFiscalPeriod(supabase, user.id, paymentDate) const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
if (!fiscalPeriodId) { if (!fiscalPeriodId) {
return NextResponse.json( return NextResponse.json(
{ error: 'Ingen öppen räkenskapsperiod för betalningsdatumet' }, { error: 'Ingen öppen räkenskapsperiod för betalningsdatumet' },
@@ -125,12 +128,13 @@ export async function POST(
source_id: invoice.id, source_id: invoice.id,
lines: customLines, lines: customLines,
} }
const journalEntry = await createJournalEntry(supabase, user.id, input) const journalEntry = await createJournalEntry(supabase, companyId, user.id, input)
journalEntryId = journalEntry?.id ?? null journalEntryId = journalEntry?.id ?? null
} else if (accountingMethod === 'accrual') { } else if (accountingMethod === 'accrual') {
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510) // Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
const journalEntry = await createInvoicePaymentJournalEntry( const journalEntry = await createInvoicePaymentJournalEntry(
supabase, supabase,
companyId,
user.id, user.id,
invoice as Invoice, invoice as Invoice,
paymentDate, paymentDate,
@@ -142,6 +146,7 @@ export async function POST(
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx) // Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
const journalEntry = await createInvoiceCashEntry( const journalEntry = await createInvoiceCashEntry(
supabase, supabase,
companyId,
user.id, user.id,
invoice as Invoice, invoice as Invoice,
paymentDate, paymentDate,
@@ -168,7 +173,7 @@ export async function POST(
paid_amount: invoice.total, paid_amount: invoice.total,
}) })
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
if (updateError) { if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 }) return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
+7 -3
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import type { EntityType, Invoice } from '@/types' import type { EntityType, Invoice } from '@/types'
ensureInitialized() ensureInitialized()
@@ -26,12 +27,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice // Fetch invoice
const { data: invoice, error: invoiceError } = await supabase const { data: invoice, error: invoiceError } = await supabase
.from('invoices') .from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)') .select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (invoiceError || !invoice) { if (invoiceError || !invoice) {
@@ -50,7 +53,7 @@ export async function POST(
.from('invoices') .from('invoices')
.update({ status: 'sent' }) .update({ status: 'sent' })
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
if (updateError) { if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 }) return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
@@ -60,7 +63,7 @@ export async function POST(
const { data: settings } = await supabase const { data: settings } = await supabase
.from('company_settings') .from('company_settings')
.select('accounting_method, entity_type') .select('accounting_method, entity_type')
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
const accountingMethod = settings?.accounting_method || 'accrual' const accountingMethod = settings?.accounting_method || 'accrual'
@@ -72,6 +75,7 @@ export async function POST(
try { try {
const journalEntry = await createInvoiceJournalEntry( const journalEntry = await createInvoiceJournalEntry(
supabase, supabase,
companyId,
user.id, user.id,
invoice as Invoice, invoice as Invoice,
(settings?.entity_type as EntityType) || 'enskild_firma', (settings?.entity_type as EntityType) || 'enskild_firma',
+5 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer' import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template' import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
export async function GET( export async function GET(
@@ -17,6 +18,8 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice with customer and items // Fetch invoice with customer and items
const { data: invoice, error: invoiceError } = await supabase const { data: invoice, error: invoiceError } = await supabase
.from('invoices') .from('invoices')
@@ -26,7 +29,7 @@ export async function GET(
items:invoice_items(*) items:invoice_items(*)
`) `)
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (invoiceError || !invoice) { if (invoiceError || !invoice) {
@@ -37,7 +40,7 @@ export async function GET(
const { data: company, error: companyError } = await supabase const { data: company, error: companyError } = await supabase
.from('company_settings') .from('company_settings')
.select('*') .select('*')
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (companyError || !company) { if (companyError || !company) {
+5 -2
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* DELETE /api/invoices/[id] * DELETE /api/invoices/[id]
@@ -21,12 +22,14 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice to verify ownership and status // Fetch invoice to verify ownership and status
const { data: invoice, error: fetchError } = await supabase const { data: invoice, error: fetchError } = await supabase
.from('invoices') .from('invoices')
.select('id, status, user_id') .select('id, status, user_id')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (fetchError || !invoice) { if (fetchError || !invoice) {
@@ -54,7 +57,7 @@ export async function DELETE(
.from('invoices') .from('invoices')
.delete() .delete()
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
if (deleteError) { if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 }) return NextResponse.json({ error: deleteError.message }, { status: 500 })
@@ -19,6 +19,11 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(), ensureInitialized: vi.fn(),
})) }))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockRenderToBuffer = vi.fn() const mockRenderToBuffer = vi.fn()
vi.mock('@react-pdf/renderer', () => ({ vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args), renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
@@ -184,6 +189,7 @@ describe('POST /api/invoices/[id]/send', () => {
) )
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith( expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
'company-1',
'user-1', 'user-1',
expect.objectContaining({ id: 'inv-1' }), expect.objectContaining({ id: 'inv-1' }),
'enskild_firma' 'enskild_firma'
+9 -5
View File
@@ -12,6 +12,7 @@ import {
} from '@/lib/email/invoice-templates' } from '@/lib/email/invoice-templates'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { uploadDocument } from '@/lib/core/documents/document-service' import { uploadDocument } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
ensureInitialized() ensureInitialized()
@@ -29,6 +30,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Check if email is configured // Check if email is configured
const emailService = getEmailService() const emailService = getEmailService()
if (!emailService.isConfigured()) { if (!emailService.isConfigured()) {
@@ -47,7 +50,7 @@ export async function POST(
items:invoice_items(*) items:invoice_items(*)
`) `)
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (invoiceError || !invoice) { if (invoiceError || !invoice) {
@@ -67,7 +70,7 @@ export async function POST(
const { data: company, error: companyError } = await supabase const { data: company, error: companyError } = await supabase
.from('company_settings') .from('company_settings')
.select('*') .select('*')
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (companyError || !company) { if (companyError || !company) {
@@ -161,7 +164,7 @@ export async function POST(
.from('invoices') .from('invoices')
.update({ status: 'sent' }) .update({ status: 'sent' })
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
if (updateError) { if (updateError) {
console.error('Failed to update invoice status:', updateError) console.error('Failed to update invoice status:', updateError)
@@ -175,6 +178,7 @@ export async function POST(
try { try {
const journalEntry = await createInvoiceJournalEntry( const journalEntry = await createInvoiceJournalEntry(
supabase, supabase,
companyId,
user.id, user.id,
invoice as Invoice, invoice as Invoice,
(company as CompanySettings).entity_type (company as CompanySettings).entity_type
@@ -196,7 +200,7 @@ export async function POST(
if (isRealInvoice) { if (isRealInvoice) {
try { try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, user.id, { await uploadDocument(supabase, user.id, companyId, {
name: filename, name: filename,
buffer: pdfArrayBuffer, buffer: pdfArrayBuffer,
type: 'application/pdf', type: 'application/pdf',
@@ -212,7 +216,7 @@ export async function POST(
await eventBus.emit({ await eventBus.emit({
type: 'invoice.sent', type: 'invoice.sent',
payload: { invoice: invoice as Invoice, userId: user.id }, payload: { invoice: invoice as Invoice, companyId, userId: user.id },
}) })
return NextResponse.json({ return NextResponse.json({
+5
View File
@@ -17,6 +17,11 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(), 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 mockGetVatRules = vi.fn()
const mockCalculateVat = vi.fn() const mockCalculateVat = vi.fn()
const mockGetAvailableVatRates = vi.fn() const mockGetAvailableVatRates = vi.fn()
+5 -2
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer' import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template' import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { getVatRules } from '@/lib/invoices/vat-rules' import { getVatRules } from '@/lib/invoices/vat-rules'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' 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 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json() const body = await request.json()
const { customer_id, invoice_date, due_date, currency, items, your_reference, our_reference, notes, document_type } = body 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') .from('customers')
.select('*') .select('*')
.eq('id', customer_id) .eq('id', customer_id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (customerError || !customer) { if (customerError || !customer) {
@@ -43,7 +46,7 @@ export async function POST(request: Request) {
const { data: company, error: companyError } = await supabase const { data: company, error: companyError } = await supabase
.from('company_settings') .from('company_settings')
.select('*') .select('*')
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (companyError || !company) { if (companyError || !company) {
+16 -7
View File
@@ -9,6 +9,7 @@ import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { import {
createCreditNoteJournalEntry, createCreditNoteJournalEntry,
} from '@/lib/bookkeeping/invoice-entries' } from '@/lib/bookkeeping/invoice-entries'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized() ensureInitialized()
@@ -21,6 +22,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const status = searchParams.get('status') const status = searchParams.get('status')
const limit = parseInt(searchParams.get('limit') || '50') const limit = parseInt(searchParams.get('limit') || '50')
@@ -29,7 +32,7 @@ export async function GET(request: Request) {
let query = supabase let query = supabase
.from('invoices') .from('invoices')
.select('*, customer:customers(*)', { count: 'exact' }) .select('*, customer:customers(*)', { count: 'exact' })
.eq('user_id', user.id) .eq('company_id', companyId)
.order('invoice_date', { ascending: false }) .order('invoice_date', { ascending: false })
.range(offset, offset + limit - 1) .range(offset, offset + limit - 1)
@@ -55,6 +58,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
let rawBody: unknown let rawBody: unknown
try { try {
rawBody = await request.json() rawBody = await request.json()
@@ -78,7 +83,7 @@ export async function POST(request: Request) {
{ status: 400 }, { status: 400 },
) )
} }
return createCreditNote(supabase, user.id, parsed.data) return createCreditNote(supabase, companyId, user.id, parsed.data)
} }
const parsed = CreateInvoiceSchema.safeParse(rawBody) const parsed = CreateInvoiceSchema.safeParse(rawBody)
@@ -100,7 +105,7 @@ export async function POST(request: Request) {
.from('customers') .from('customers')
.select('*') .select('*')
.eq('id', invoiceInput.customer_id) .eq('id', invoiceInput.customer_id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (customerError || !customer) { if (customerError || !customer) {
@@ -178,6 +183,7 @@ export async function POST(request: Request) {
.from('invoices') .from('invoices')
.insert({ .insert({
user_id: user.id, user_id: user.id,
company_id: companyId,
customer_id: invoiceInput.customer_id, customer_id: invoiceInput.customer_id,
invoice_number: invoiceNumber, invoice_number: invoiceNumber,
invoice_date: invoiceInput.invoice_date, invoice_date: invoiceInput.invoice_date,
@@ -246,7 +252,7 @@ export async function POST(request: Request) {
if (completeInvoice && documentType === 'invoice') { if (completeInvoice && documentType === 'invoice') {
await eventBus.emit({ await eventBus.emit({
type: 'invoice.created', 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 // Create a credit note for an existing invoice
async function createCreditNote( async function createCreditNote(
supabase: Awaited<ReturnType<typeof createClient>>, supabase: Awaited<ReturnType<typeof createClient>>,
companyId: string,
userId: string, userId: string,
input: { credited_invoice_id: string; reason?: string } input: { credited_invoice_id: string; reason?: string }
) { ) {
@@ -264,7 +271,7 @@ async function createCreditNote(
.from('invoices') .from('invoices')
.select('*, items:invoice_items(*)') .select('*, items:invoice_items(*)')
.eq('id', input.credited_invoice_id) .eq('id', input.credited_invoice_id)
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
if (originalError || !originalInvoice) { if (originalError || !originalInvoice) {
@@ -300,6 +307,7 @@ async function createCreditNote(
.from('invoices') .from('invoices')
.insert({ .insert({
user_id: userId, user_id: userId,
company_id: companyId,
customer_id: originalInvoice.customer_id, customer_id: originalInvoice.customer_id,
invoice_number: creditNoteNumber, invoice_number: creditNoteNumber,
invoice_date: new Date().toISOString().split('T')[0], invoice_date: new Date().toISOString().split('T')[0],
@@ -373,7 +381,7 @@ async function createCreditNote(
const { data: creditNoteSettings } = await supabase const { data: creditNoteSettings } = await supabase
.from('company_settings') .from('company_settings')
.select('entity_type, accounting_method') .select('entity_type, accounting_method')
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma' const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
@@ -385,6 +393,7 @@ async function createCreditNote(
try { try {
const journalEntry = await createCreditNoteJournalEntry( const journalEntry = await createCreditNoteJournalEntry(
supabase, supabase,
companyId,
userId, userId,
completeCreditNote as Invoice, completeCreditNote as Invoice,
entityType, entityType,
@@ -402,7 +411,7 @@ async function createCreditNote(
await eventBus.emit({ await eventBus.emit({
type: 'credit_note.created', type: 'credit_note.created',
payload: { creditNote: completeCreditNote as CreditNote, userId }, payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
}) })
} }
+7 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions' import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import { requireCompanyId } from '@/lib/company/context'
import type { KPIPreferences } from '@/types' import type { KPIPreferences } from '@/types'
const EXTENSION_ID = 'core/kpi' const EXTENSION_ID = 'core/kpi'
@@ -11,10 +12,12 @@ export async function GET() {
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const { data } = await supabase const { data } = await supabase
.from('extension_data') .from('extension_data')
.select('value') .select('value')
.eq('user_id', user.id) .eq('company_id', companyId)
.eq('extension_id', EXTENSION_ID) .eq('extension_id', EXTENSION_ID)
.eq('key', KEY) .eq('key', KEY)
.single() .single()
@@ -28,6 +31,8 @@ export async function PUT(request: Request) {
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
let body: unknown let body: unknown
try { try {
body = await request.json() body = await request.json()
@@ -64,6 +69,7 @@ export async function PUT(request: Request) {
.upsert( .upsert(
{ {
user_id: user.id, user_id: user.id,
company_id: companyId,
extension_id: EXTENSION_ID, extension_id: EXTENSION_ID,
key: KEY, key: KEY,
value: merged, value: merged,
+6 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createAuthCode } from '@/lib/auth/oauth-codes' import { createAuthCode } from '@/lib/auth/oauth-codes'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* OAuth 2.0 Authorization Endpoint. * OAuth 2.0 Authorization Endpoint.
@@ -88,11 +89,13 @@ export async function GET(request: Request) {
return buildLoginRedirect(request) return buildLoginRedirect(request)
} }
const companyId = await requireCompanyId(supabase, user.id)
// Get company name for the consent page // Get company name for the consent page
const { data: settings } = await supabase const { data: settings } = await supabase
.from('company_settings') .from('company_settings')
.select('company_name') .select('company_name')
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
const companyName = settings?.company_name || user.email const companyName = settings?.company_name || user.email
@@ -180,6 +183,8 @@ export async function POST(request: Request) {
return buildLoginRedirect(request) return buildLoginRedirect(request)
} }
await requireCompanyId(supabase, user.id)
// Parse form body // Parse form body
const formData = await request.formData() const formData = await request.formData()
const consent = formData.get('consent') const consent = formData.get('consent')
+5
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { decryptAuthCode, verifyPkce, hashAuthCode } from '@/lib/auth/oauth-codes' import { decryptAuthCode, verifyPkce, hashAuthCode } from '@/lib/auth/oauth-codes'
import { generateApiKey, createServiceClientNoCookies, ALL_SCOPES } from '@/lib/auth/api-keys' import { generateApiKey, createServiceClientNoCookies, ALL_SCOPES } from '@/lib/auth/api-keys'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* OAuth 2.0 Token Endpoint. * 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()) .lt('created_at', new Date(Date.now() - 10 * 60 * 1000).toISOString())
.then(() => {}) .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) // Create the API key now (after PKCE verification — prevents orphaned keys)
const { key, hash, prefix } = generateApiKey() const { key, hash, prefix } = generateApiKey()
@@ -107,6 +111,7 @@ export async function POST(request: Request) {
.from('api_keys') .from('api_keys')
.insert({ .insert({
user_id: payload.userId, user_id: payload.userId,
company_id: companyId,
key_hash: hash, key_hash: hash,
key_prefix: prefix, key_prefix: prefix,
name: 'MCP-klient (OAuth)', name: 'MCP-klient (OAuth)',
@@ -15,6 +15,11 @@ vi.mock('@/lib/supabase/server', () => ({
})) }))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) 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) // Mock the counterparty templates (non-critical side effect)
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined), upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
+55 -39
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events' import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
@@ -48,13 +49,14 @@ ensureInitialized()
async function ensureFiscalPeriod( async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>, supabase: Awaited<ReturnType<typeof createClient>>,
userId: string, userId: string,
companyId: string,
date: string, date: string,
fiscalYearStartMonth: number = 1 fiscalYearStartMonth: number = 1
): Promise<boolean> { ): Promise<boolean> {
const { data: existing } = await supabase const { data: existing } = await supabase
.from('fiscal_periods') .from('fiscal_periods')
.select('id') .select('id')
.eq('user_id', userId) .eq('company_id', companyId)
.lte('period_start', date) .lte('period_start', date)
.gte('period_end', date) .gte('period_end', date)
.eq('is_closed', false) .eq('is_closed', false)
@@ -91,6 +93,7 @@ async function ensureFiscalPeriod(
.from('fiscal_periods') .from('fiscal_periods')
.upsert({ .upsert({
user_id: userId, user_id: userId,
company_id: companyId,
name: periodName, name: periodName,
period_start: periodStart, period_start: periodStart,
period_end: periodEnd, period_end: periodEnd,
@@ -108,6 +111,7 @@ async function ensureFiscalPeriod(
async function commitCategorizeTransaction( async function commitCategorizeTransaction(
supabase: Awaited<ReturnType<typeof createClient>>, supabase: Awaited<ReturnType<typeof createClient>>,
userId: string, userId: string,
companyId: string,
params: Record<string, unknown> params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> { ): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const txId = params.transaction_id as string const txId = params.transaction_id as string
@@ -119,7 +123,7 @@ async function commitCategorizeTransaction(
.from('transactions') .from('transactions')
.select('*') .select('*')
.eq('id', txId) .eq('id', txId)
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
if (fetchError || !transaction) { if (fetchError || !transaction) {
@@ -136,7 +140,7 @@ async function commitCategorizeTransaction(
const { data: settings } = await supabase const { data: settings } = await supabase
.from('company_settings') .from('company_settings')
.select('entity_type, fiscal_year_start_month') .select('entity_type, fiscal_year_start_month')
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
@@ -156,13 +160,13 @@ async function commitCategorizeTransaction(
} }
// Ensure fiscal period exists // Ensure fiscal period exists
await ensureFiscalPeriod(supabase, userId, transaction.date, fiscalYearStartMonth) await ensureFiscalPeriod(supabase, userId, companyId, transaction.date, fiscalYearStartMonth)
// Create journal entry // Create journal entry
let journalEntryId: string | null = null let journalEntryId: string | null = null
try { try {
const journalEntry = await createTransactionJournalEntry( const journalEntry = await createTransactionJournalEntry(
supabase, userId, transaction as Transaction, mappingResult supabase, companyId, userId, transaction as Transaction, mappingResult
) )
if (journalEntry) { if (journalEntry) {
journalEntryId = journalEntry.id journalEntryId = journalEntry.id
@@ -202,6 +206,7 @@ async function commitCategorizeTransaction(
account: mappingResult.debit_account, account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '', taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId, userId,
companyId,
}, },
}) })
@@ -211,12 +216,14 @@ async function commitCategorizeTransaction(
async function commitCreateCustomer( async function commitCreateCustomer(
supabase: Awaited<ReturnType<typeof createClient>>, supabase: Awaited<ReturnType<typeof createClient>>,
userId: string, userId: string,
companyId: string,
params: Record<string, unknown> params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> { ): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const { data, error } = await supabase const { data, error } = await supabase
.from('customers') .from('customers')
.insert({ .insert({
user_id: userId, user_id: userId,
company_id: companyId,
name: params.name as string, name: params.name as string,
customer_type: params.customer_type as string, customer_type: params.customer_type as string,
email: (params.email as string) || null, email: (params.email as string) || null,
@@ -247,7 +254,7 @@ async function commitCreateCustomer(
vat_number_validated_at: new Date().toISOString(), vat_number_validated_at: new Date().toISOString(),
}) })
.eq('id', data.id) .eq('id', data.id)
.eq('user_id', userId) .eq('company_id', companyId)
} }
} catch (err) { } catch (err) {
log.warn('Auto-VIES validation failed:', err) log.warn('Auto-VIES validation failed:', err)
@@ -256,7 +263,7 @@ async function commitCreateCustomer(
await eventBus.emit({ await eventBus.emit({
type: 'customer.created', type: 'customer.created',
payload: { customer: data as Customer, userId }, payload: { customer: data as Customer, userId, companyId },
}) })
return { data: { customer_id: data.id } } return { data: { customer_id: data.id } }
@@ -265,6 +272,7 @@ async function commitCreateCustomer(
async function commitCreateInvoice( async function commitCreateInvoice(
supabase: Awaited<ReturnType<typeof createClient>>, supabase: Awaited<ReturnType<typeof createClient>>,
userId: string, userId: string,
companyId: string,
params: Record<string, unknown> params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> { ): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const customerId = params.customer_id as string const customerId = params.customer_id as string
@@ -281,7 +289,7 @@ async function commitCreateInvoice(
.from('customers') .from('customers')
.select('*') .select('*')
.eq('id', customerId) .eq('id', customerId)
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
if (customerError || !customer) { if (customerError || !customer) {
@@ -340,6 +348,7 @@ async function commitCreateInvoice(
.from('invoices') .from('invoices')
.insert({ .insert({
user_id: userId, user_id: userId,
company_id: companyId,
customer_id: customerId, customer_id: customerId,
invoice_number: invoiceNumber, invoice_number: invoiceNumber,
invoice_date: (params.invoice_date as string) || new Date().toISOString().split('T')[0], invoice_date: (params.invoice_date as string) || new Date().toISOString().split('T')[0],
@@ -406,7 +415,7 @@ async function commitCreateInvoice(
if (completeInvoice) { if (completeInvoice) {
await eventBus.emit({ await eventBus.emit({
type: 'invoice.created', 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( async function commitMarkInvoicePaid(
supabase: Awaited<ReturnType<typeof createClient>>, supabase: Awaited<ReturnType<typeof createClient>>,
userId: string, userId: string,
companyId: string,
params: Record<string, unknown> params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> { ): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const invoiceId = params.invoice_id as string const invoiceId = params.invoice_id as string
@@ -425,7 +435,7 @@ async function commitMarkInvoicePaid(
.from('invoices') .from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)') .select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId) .eq('id', invoiceId)
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 } if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
@@ -436,7 +446,7 @@ async function commitMarkInvoicePaid(
const { data: settings } = await supabase const { data: settings } = await supabase
.from('company_settings') .from('company_settings')
.select('accounting_method, entity_type') .select('accounting_method, entity_type')
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
const accountingMethod = settings?.accounting_method || 'accrual' const accountingMethod = settings?.accounting_method || 'accrual'
@@ -447,12 +457,12 @@ async function commitMarkInvoicePaid(
if (isRealInvoice) { if (isRealInvoice) {
if (accountingMethod === 'accrual') { if (accountingMethod === 'accrual') {
const je = await createInvoicePaymentJournalEntry( 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 journalEntryId = je?.id ?? null
} else { } else {
const je = await createInvoiceCashEntry( 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 journalEntryId = je?.id ?? null
} }
@@ -463,7 +473,7 @@ async function commitMarkInvoicePaid(
.from('invoices') .from('invoices')
.update({ status: 'paid', paid_at: now, paid_amount: invoice.total }) .update({ status: 'paid', paid_at: now, paid_amount: invoice.total })
.eq('id', invoiceId) .eq('id', invoiceId)
.eq('user_id', userId) .eq('company_id', companyId)
if (updateError) return { error: 'Failed to update invoice status', status: 500 } if (updateError) return { error: 'Failed to update invoice status', status: 500 }
@@ -473,6 +483,7 @@ async function commitMarkInvoicePaid(
async function commitSendInvoice( async function commitSendInvoice(
supabase: Awaited<ReturnType<typeof createClient>>, supabase: Awaited<ReturnType<typeof createClient>>,
userId: string, userId: string,
companyId: string,
params: Record<string, unknown>, params: Record<string, unknown>,
userEmail?: string userEmail?: string
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> { ): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
@@ -487,7 +498,7 @@ async function commitSendInvoice(
.from('invoices') .from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)') .select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId) .eq('id', invoiceId)
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 } if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
@@ -501,7 +512,7 @@ async function commitSendInvoice(
const { data: company, error: companyError } = await supabase const { data: company, error: companyError } = await supabase
.from('company_settings') .from('company_settings')
.select('*') .select('*')
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
if (companyError || !company) return { error: 'Company settings missing', status: 500 } 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 } 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' const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let createdJournalEntryId: string | undefined let createdJournalEntryId: string | undefined
if (isRealInvoice && (company.accounting_method === 'accrual' || !company.accounting_method)) { if (isRealInvoice && (company.accounting_method === 'accrual' || !company.accounting_method)) {
try { try {
const je = await createInvoiceJournalEntry( 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) { if (je) {
createdJournalEntryId = je.id createdJournalEntryId = je.id
@@ -573,7 +584,7 @@ async function commitSendInvoice(
if (isRealInvoice) { if (isRealInvoice) {
try { try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, userId, { await uploadDocument(supabase, userId, companyId, {
name: filename, name: filename,
buffer: pdfArrayBuffer, buffer: pdfArrayBuffer,
type: 'application/pdf', type: 'application/pdf',
@@ -584,7 +595,7 @@ async function commitSendInvoice(
} catch { /* non-blocking */ } } 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}` } } return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } }
} }
@@ -592,6 +603,7 @@ async function commitSendInvoice(
async function commitMarkInvoiceSent( async function commitMarkInvoiceSent(
supabase: Awaited<ReturnType<typeof createClient>>, supabase: Awaited<ReturnType<typeof createClient>>,
userId: string, userId: string,
companyId: string,
params: Record<string, unknown> params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> { ): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const invoiceId = params.invoice_id as string const invoiceId = params.invoice_id as string
@@ -600,7 +612,7 @@ async function commitMarkInvoiceSent(
.from('invoices') .from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)') .select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId) .eq('id', invoiceId)
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 } if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
@@ -610,14 +622,14 @@ async function commitMarkInvoiceSent(
.from('invoices') .from('invoices')
.update({ status: 'sent' }) .update({ status: 'sent' })
.eq('id', invoiceId) .eq('id', invoiceId)
.eq('user_id', userId) .eq('company_id', companyId)
if (updateError) return { error: 'Failed to update invoice status', status: 500 } if (updateError) return { error: 'Failed to update invoice status', status: 500 }
const { data: settings } = await supabase const { data: settings } = await supabase
.from('company_settings') .from('company_settings')
.select('accounting_method, entity_type') .select('accounting_method, entity_type')
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' 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)) { if (isRealInvoice && (settings?.accounting_method === 'accrual' || !settings?.accounting_method)) {
try { try {
const je = await createInvoiceJournalEntry( const je = await createInvoiceJournalEntry(
supabase, userId, invoice as Invoice, supabase, companyId, userId, invoice as Invoice,
(settings?.entity_type as EntityType) || 'enskild_firma', (settings?.entity_type as EntityType) || 'enskild_firma',
invoice.customer?.name invoice.customer?.name
) )
@@ -643,6 +655,7 @@ async function commitMarkInvoiceSent(
async function commitMatchTransactionInvoice( async function commitMatchTransactionInvoice(
supabase: Awaited<ReturnType<typeof createClient>>, supabase: Awaited<ReturnType<typeof createClient>>,
userId: string, userId: string,
companyId: string,
params: Record<string, unknown> params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> { ): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const transactionId = params.transaction_id as string const transactionId = params.transaction_id as string
@@ -652,7 +665,7 @@ async function commitMatchTransactionInvoice(
.from('transactions') .from('transactions')
.select('*') .select('*')
.eq('id', transactionId) .eq('id', transactionId)
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
if (txError || !transaction) return { error: 'Transaction not found', status: 404 } if (txError || !transaction) return { error: 'Transaction not found', status: 404 }
@@ -663,7 +676,7 @@ async function commitMatchTransactionInvoice(
.from('invoices') .from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)') .select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId) .eq('id', invoiceId)
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
if (invError || !invoice) return { error: 'Invoice not found', status: 404 } if (invError || !invoice) return { error: 'Invoice not found', status: 404 }
@@ -673,7 +686,7 @@ async function commitMatchTransactionInvoice(
// Storno conflicting journal entry // Storno conflicting journal entry
if (transaction.journal_entry_id) { 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) await supabase.from('transactions').update({ journal_entry_id: null }).eq('id', transactionId)
} }
@@ -688,7 +701,7 @@ async function commitMatchTransactionInvoice(
const { data: settings } = await supabase const { data: settings } = await supabase
.from('company_settings') .from('company_settings')
.select('accounting_method, entity_type') .select('accounting_method, entity_type')
.eq('user_id', userId) .eq('company_id', companyId)
.single() .single()
const accountingMethod = settings?.accounting_method || 'accrual' const accountingMethod = settings?.accounting_method || 'accrual'
@@ -698,12 +711,12 @@ async function commitMatchTransactionInvoice(
try { try {
if (accountingMethod === 'cash' && isFullyPaid) { if (accountingMethod === 'cash' && isFullyPaid) {
const je = await createInvoiceCashEntry( 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 journalEntryId = je?.id ?? null
} else { } else {
const je = await createInvoicePaymentJournalEntry( 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 journalEntryId = je?.id ?? null
} }
@@ -733,6 +746,7 @@ async function commitMatchTransactionInvoice(
await supabase.from('invoice_payments').insert({ await supabase.from('invoice_payments').insert({
user_id: userId, user_id: userId,
company_id: companyId,
invoice_id: invoiceId, invoice_id: invoiceId,
payment_date: transaction.date, payment_date: transaction.date,
amount: paidAmount, amount: paidAmount,
@@ -757,7 +771,7 @@ async function commitMatchTransactionInvoice(
try { try {
await eventBus.emit({ await eventBus.emit({
type: 'invoice.match_confirmed', 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 */ } } catch { /* non-critical */ }
@@ -778,12 +792,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
// Fetch the pending operation // Fetch the pending operation
const { data: op, error: fetchError } = await supabase const { data: op, error: fetchError } = await supabase
.from('pending_operations') .from('pending_operations')
.select('*') .select('*')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (fetchError || !op) { if (fetchError || !op) {
@@ -804,25 +820,25 @@ export async function POST(
switch (pendingOp.operation_type) { switch (pendingOp.operation_type) {
case 'categorize_transaction': case 'categorize_transaction':
result = await commitCategorizeTransaction(supabase, user.id, pendingOp.params) result = await commitCategorizeTransaction(supabase, user.id, companyId, pendingOp.params)
break break
case 'create_customer': case 'create_customer':
result = await commitCreateCustomer(supabase, user.id, pendingOp.params) result = await commitCreateCustomer(supabase, user.id, companyId, pendingOp.params)
break break
case 'create_invoice': case 'create_invoice':
result = await commitCreateInvoice(supabase, user.id, pendingOp.params) result = await commitCreateInvoice(supabase, user.id, companyId, pendingOp.params)
break break
case 'mark_invoice_paid': case 'mark_invoice_paid':
result = await commitMarkInvoicePaid(supabase, user.id, pendingOp.params) result = await commitMarkInvoicePaid(supabase, user.id, companyId, pendingOp.params)
break break
case 'send_invoice': 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 break
case 'mark_invoice_sent': case 'mark_invoice_sent':
result = await commitMarkInvoiceSent(supabase, user.id, pendingOp.params) result = await commitMarkInvoiceSent(supabase, user.id, companyId, pendingOp.params)
break break
case 'match_transaction_invoice': case 'match_transaction_invoice':
result = await commitMatchTransactionInvoice(supabase, user.id, pendingOp.params) result = await commitMatchTransactionInvoice(supabase, user.id, companyId, pendingOp.params)
break break
default: default:
return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 }) return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 })
@@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase), 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' import { POST } from '../../reject/route'
describe('POST /api/pending-operations/:id/reject', () => { describe('POST /api/pending-operations/:id/reject', () => {
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* POST /api/pending-operations/:id/reject * POST /api/pending-operations/:id/reject
@@ -18,11 +19,13 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { data: op, error: fetchError } = await supabase const { data: op, error: fetchError } = await supabase
.from('pending_operations') .from('pending_operations')
.select('id, status') .select('id, status')
.eq('id', id) .eq('id', id)
.eq('user_id', user.id) .eq('company_id', companyId)
.single() .single()
if (fetchError || !op) { if (fetchError || !op) {
@@ -10,6 +10,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase), 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' import { GET } from '../route'
describe('GET /api/pending-operations', () => { describe('GET /api/pending-operations', () => {
+4 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { validateQuery } from '@/lib/api/validate' import { validateQuery } from '@/lib/api/validate'
import { PendingOperationsQuerySchema } from '@/lib/api/schemas' import { PendingOperationsQuerySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
/** /**
* GET /api/pending-operations * GET /api/pending-operations
@@ -16,6 +17,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const result = validateQuery(request, PendingOperationsQuerySchema) const result = validateQuery(request, PendingOperationsQuerySchema)
if (!result.success) return result.response if (!result.success) return result.response
const { status, limit, offset } = result.data const { status, limit, offset } = result.data
@@ -23,7 +26,7 @@ export async function GET(request: Request) {
const { data, error, count } = await supabase const { data, error, count } = await supabase
.from('pending_operations') .from('pending_operations')
.select('*', { count: 'exact' }) .select('*', { count: 'exact' })
.eq('user_id', user.id) .eq('company_id', companyId)
.eq('status', status) .eq('status', status)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.range(offset, offset + limit - 1) .range(offset, offset + limit - 1)
+4 -1
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { manualLink } from '@/lib/reconciliation/bank-reconciliation' import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { BankLinkSchema } from '@/lib/api/schemas' import { BankLinkSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized() ensureInitialized()
@@ -15,11 +16,13 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, BankLinkSchema) const validation = await validateBody(request, BankLinkSchema)
if (!validation.success) return validation.response if (!validation.success) return validation.response
const { transaction_id, journal_entry_id } = validation.data 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) { if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 }) return NextResponse.json({ error: result.error }, { status: 400 })
+4 -1
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation' import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { RunReconciliationSchema } from '@/lib/api/schemas' import { RunReconciliationSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized() ensureInitialized()
@@ -15,11 +16,13 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, RunReconciliationSchema) const validation = await validateBody(request, RunReconciliationSchema)
if (!validation.success) return validation.response if (!validation.success) return validation.response
const { date_from, date_to, dry_run } = validation.data 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, dateFrom: date_from,
dateTo: date_to, dateTo: date_to,
dryRun: dry_run ?? false, dryRun: dry_run ?? false,
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation' import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) { export async function GET(request: Request) {
const supabase = await createClient() const supabase = await createClient()
@@ -10,11 +11,13 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || 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 }) return NextResponse.json({ data: status })
} }
+4 -1
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation' import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { BankUnlinkSchema } from '@/lib/api/schemas' import { BankUnlinkSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function POST(request: Request) { export async function POST(request: Request) {
const supabase = await createClient() const supabase = await createClient()
@@ -12,11 +13,13 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, BankUnlinkSchema) const validation = await validateBody(request, BankUnlinkSchema)
if (!validation.success) return validation.response if (!validation.success) return validation.response
const { transaction_id } = validation.data 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) { if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 }) return NextResponse.json({ error: result.error }, { status: 400 })
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation' import { fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) { export async function GET(request: Request) {
const supabase = await createClient() const supabase = await createClient()
@@ -10,11 +11,13 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || 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 }) return NextResponse.json({ data: lines })
} }
+5 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { generateARLedger } from '@/lib/reports/ar-ledger' import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateARReconciliation } from '@/lib/reports/ar-reconciliation' import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) { export async function GET(request: Request) {
const supabase = await createClient() const supabase = await createClient()
@@ -12,15 +13,17 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const asOfDate = searchParams.get('as_of_date') || undefined const asOfDate = searchParams.get('as_of_date') || undefined
const periodId = searchParams.get('period_id') || 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 let reconciliation = null
if (periodId) { if (periodId) {
reconciliation = await generateARReconciliation(supabase, user.id, periodId) reconciliation = await generateARReconciliation(supabase, companyId, periodId)
} }
return NextResponse.json({ return NextResponse.json({
@@ -9,6 +9,11 @@ vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn(), 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 { createClient } from '@/lib/supabase/server'
import { getAuditLog } from '@/lib/core/audit/audit-service' import { getAuditLog } from '@/lib/core/audit/audit-service'
import { GET } from '../route' import { GET } from '../route'

Some files were not shown because too many files have changed in this diff Show More