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
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
# The empty defaults in lib/extensions/_generated/ are committed so core compiles
# out of the box without running the generator.
supabase/.branches/
+96 -6
View File
@@ -1,5 +1,6 @@
import { createServerClient } from '@supabase/ssr'
import { type NextRequest, NextResponse } from 'next/server'
import { hashInviteToken } from '@/lib/auth/invite-tokens'
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url)
@@ -22,7 +23,13 @@ export async function GET(request: NextRequest) {
},
setAll(cookiesToSet) {
pendingCookies.length = 0
cookiesToSet.forEach((cookie) => pendingCookies.push(cookie))
cookiesToSet.forEach((cookie) => {
// Mirror the cookie into request.cookies so subsequent getAll()
// calls within this request lifecycle return the updated values
// (matches the pattern used in middleware.ts).
request.cookies.set(cookie.name, cookie.value)
pendingCookies.push(cookie)
})
},
},
}
@@ -59,14 +66,95 @@ export async function GET(request: NextRequest) {
return response
}
// Check if user has completed onboarding
const { data: settings } = await supabase
.from('company_settings')
.select('onboarding_complete')
// Check for pending invite token (set by invite page before redirecting to register)
const inviteToken = request.cookies.get('gnubok-invite-token')?.value
if (inviteToken) {
try {
const tokenHash = hashInviteToken(inviteToken)
// Use the service role client to bypass RLS for invite acceptance
const serviceClient = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ cookies: { getAll: () => [], setAll: () => {} } }
)
// Look up company invitation
const { data: invite } = await serviceClient
.from('company_invitations')
.select('id, company_id, email, role, status, expires_at')
.eq('token_hash', tokenHash)
.single()
if (
invite &&
invite.status === 'pending' &&
new Date(invite.expires_at) > new Date() &&
user.email?.toLowerCase() === invite.email.toLowerCase()
) {
// Add user to company
await serviceClient.from('company_members').insert({
company_id: invite.company_id,
user_id: user.id,
role: invite.role,
source: 'direct',
})
// Set active company
await serviceClient.from('user_preferences').upsert({
user_id: user.id,
active_company_id: invite.company_id,
}, { onConflict: 'user_id' })
// Mark invite as accepted
await serviceClient
.from('company_invitations')
.update({ status: 'accepted' })
.eq('id', invite.id)
// Invited user goes straight to dashboard — no onboarding needed
redirectPath = '/'
// Clear invite cookie and set company cookie on response
const response = NextResponse.redirect(new URL(redirectPath, origin))
for (const { name, value, options } of pendingCookies) {
response.cookies.set({ name, value, ...options })
}
response.cookies.set('gnubok-company-id', invite.company_id, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
})
response.cookies.delete('gnubok-invite-token')
return response
}
} catch (err) {
console.error('[auth/callback] invite acceptance failed:', err)
// Fall through to normal onboarding check
}
}
// Check if user has completed onboarding (for any company they belong to)
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', user.id)
.limit(1)
.single()
if (!settings?.onboarding_complete) {
if (membership?.company_id) {
const { data: settings } = await supabase
.from('company_settings')
.select('onboarding_complete')
.eq('company_id', membership.company_id)
.single()
if (!settings?.onboarding_complete) {
redirectPath = '/onboarding'
}
} else {
redirectPath = '/onboarding'
}
}
@@ -76,6 +164,8 @@ export async function GET(request: NextRequest) {
for (const { name, value, options } of pendingCookies) {
response.cookies.set({ name, value, ...options })
}
// Keep the invite cookie alive so the onboarding page fallback can
// retry acceptance (only clear it when successfully processed above).
return response
}
+77 -2
View File
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
@@ -12,14 +13,45 @@ import Image from 'next/image'
import { getErrorMessage } from '@/lib/errors/get-error-message'
export default function RegisterPage() {
return (
<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 [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isRegistered, setIsRegistered] = useState(false)
const [inviteEmail, setInviteEmail] = useState<string | null>(null)
const { toast } = useToast()
const supabase = createClient()
// When arriving from an invite link, fetch the invite info to pre-fill
// and lock the email field so the user registers with the correct address.
useEffect(() => {
const inviteToken = searchParams.get('invite')
if (!inviteToken) return
fetch(`/api/team/accept?token=${encodeURIComponent(inviteToken)}`)
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (data?.data?.email) {
setInviteEmail(data.data.email)
setEmail(data.data.email)
}
})
.catch(() => {})
}, [searchParams])
function isStrongPassword(pw: string): boolean {
return pw.length >= 8
&& /[a-z]/.test(pw)
@@ -102,6 +134,43 @@ export default function RegisterPage() {
provider: data.user?.app_metadata?.provider,
})
// If auto-confirmed (local dev), process invite immediately and redirect
if (data.session) {
const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
const inviteToken = cookieMatch?.[1]
if (inviteToken) {
try {
const res = await fetch('/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: inviteToken }),
})
if (res.ok) {
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
console.log('[register] invite accepted after auto-confirm — redirecting')
window.location.href = '/'
return
}
// Log the error response so we can diagnose invite failures
const errBody = await res.json().catch(() => ({}))
console.error('[register] invite acceptance returned non-ok', {
status: res.status,
error: errBody.error,
})
} catch (err) {
console.error('[register] invite acceptance failed:', err)
}
}
// Auto-confirmed but no invite or invite failed — go to onboarding
// (invite cookie is preserved so the onboarding fallback can retry)
window.location.href = '/'
return
}
setEmail(emailValue)
setIsRegistered(true)
} catch (error) {
@@ -188,9 +257,15 @@ export default function RegisterPage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
disabled={isLoading || !!inviteEmail}
readOnly={!!inviteEmail}
className="h-11"
/>
{inviteEmail && (
<p className="text-xs text-muted-foreground">
Inbjudan skickades till denna adress.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Lösenord</Label>
+1 -1
View File
@@ -60,7 +60,7 @@ export default function DeadlinesPage() {
}, [fetchData])
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 {
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 { cn } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector'
import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
@@ -566,6 +567,7 @@ function PSD2ConnectWizard() {
const { toast } = useToast()
const supabase = createClient()
const { dialogProps, confirm } = useDestructiveConfirm()
const { company } = useCompany()
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
@@ -582,10 +584,12 @@ function PSD2ConnectWizard() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
if (!company) return
const { data: connections } = await supabase
.from('bank_connections')
.select('*')
.eq('user_id', user.id)
.eq('company_id', company.id)
.order('created_at', { ascending: false })
setBankConnections(connections || [])
@@ -750,6 +754,7 @@ function PSD2ConnectWizard() {
type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'migration'
export default function ImportPage() {
const { company } = useCompany()
const [mode, setMode] = useState<ImportMode>(null)
const [userId, setUserId] = useState('')
const [isSandbox, setIsSandbox] = useState(false)
@@ -760,10 +765,11 @@ export default function ImportPage() {
supabase.auth.getUser().then(({ data: { user } }) => {
if (!user) return
setUserId(user.id)
if (!company) return
supabase
.from('company_settings')
.select('is_sandbox')
.eq('user_id', user.id)
.eq('company_id', company.id)
.single()
.then(({ data }) => {
if (data?.is_sandbox) setIsSandbox(true)
+175 -34
View File
@@ -1,11 +1,14 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav'
import { RecaptIdentify } from '@/components/RecaptIdentify'
import { SentryIdentify } from '@/components/SentryIdentify'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import type { EntityType } from '@/types'
import { CompanyProvider } from '@/contexts/CompanyContext'
import { getActiveCompanyId } from '@/lib/company/context'
import type { EntityType, CompanyRole, Team } from '@/types'
export default async function DashboardLayout({
children,
@@ -20,21 +23,138 @@ export default async function DashboardLayout({
redirect('/login')
}
const cookieStore = await cookies()
const companyId = cookieStore.get('gnubok-company-id')?.value
?? await getActiveCompanyId(supabase, user.id)
// Fetch team membership + team info
const { data: teamMembership } = await supabase
.from('team_members')
.select('team_id, role')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
let team: Team | null = null
if (teamMembership?.team_id) {
const { data: teamRow } = await supabase
.from('teams')
.select('*')
.eq('id', teamMembership.team_id)
.single()
team = teamRow
}
const isTeamMember = !!teamMembership
// Consultant with team but no companies — show dashboard with empty state
if (!companyId) {
if (isTeamMember) {
const companyContextValue = {
company: null,
role: null,
companies: [],
isTeamMember: true,
team,
}
return (
<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([
supabase
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, is_sandbox')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single(),
supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('company_id', companyId)
.is('is_business', null),
supabase
.from('pending_operations')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('status', 'pending'),
])
@@ -42,41 +162,62 @@ export default async function DashboardLayout({
redirect('/onboarding')
}
// Use company_name from settings as the display name (companies.name may be stale)
const displayName = settings.company_name || companyRow.name
const companyWithName = { ...companyRow, name: displayName }
const companyContextValue = {
company: companyWithName,
role: memberRow.role as CompanyRole,
companies: (allMemberships || []).map((m) => {
const c = m.companies as unknown as import('@/types').Company
// Override active company's name with settings name
if (c.id === companyId) {
return { company: { ...c, name: displayName }, role: m.role as CompanyRole }
}
return { company: c, role: m.role as CompanyRole }
}),
isTeamMember,
team,
}
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
const isSandbox = settings.is_sandbox === true
return (
<div className="min-h-screen bg-background">
{/* Skip to content link for keyboard/screen reader users */}
<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>
{isSandbox && <SandboxBanner />}
<DashboardNav
companyName={settings.company_name || 'Min verksamhet'}
entityType={entityType}
uncategorizedTransactionCount={uncategorizedCount ?? 0}
pendingOperationsCount={pendingOpsCount ?? 0}
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}
<CompanyProvider value={companyContextValue}>
<div className="min-h-screen bg-background">
{/* Skip to content link for keyboard/screen reader users */}
<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>
{isSandbox && <SandboxBanner />}
<DashboardNav
companyName={settings.company_name || 'Min verksamhet'}
entityType={entityType}
uncategorizedTransactionCount={uncategorizedCount ?? 0}
pendingOperationsCount={pendingOpsCount ?? 0}
isSandbox={isSandbox}
extensionNavItems={getExtensionNavItems()}
/>
)}
</div>
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
<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 { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import DashboardContent from '@/components/dashboard/DashboardContent'
import ConsultantEmptyState from '@/components/dashboard/ConsultantEmptyState'
import { getActiveCompanyId } from '@/lib/company/context'
import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
export const dynamic = 'force-dynamic'
@@ -14,6 +17,44 @@ export default async function DashboardPage() {
redirect('/login')
}
const cookieStore = await cookies()
const rawCompanyId = cookieStore.get('gnubok-company-id')?.value
?? await getActiveCompanyId(supabase, user.id)
// Validate the cookie/preference points to a company the user can access
let companyId = rawCompanyId
if (companyId) {
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) companyId = null
}
if (!companyId) {
// Consultants (team members) see an empty state; solo users go to onboarding
const { data: teamMembership } = await supabase
.from('team_members')
.select('team_id')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
if (teamMembership) {
const { data: profile } = await supabase
.from('profiles')
.select('full_name')
.eq('id', user.id)
.single()
const firstName = profile?.full_name?.split(' ')[0] || null
return <ConsultantEmptyState firstName={firstName} />
}
redirect('/onboarding')
}
// Fetch current year date boundaries
const startOfYearStr = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]
const startOfMonthStr = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0]
@@ -54,28 +95,29 @@ export default async function DashboardPage() {
{ count: staleUncategorizedCount },
] = await Promise.all([
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
supabase.from('company_settings').select('*').eq('user_id', user.id).single(),
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
supabase.from('company_settings').select('*').eq('company_id', companyId).single(),
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
supabase.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status)')
.select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status, company_id)')
.eq('journal_entry.status', 'posted')
.eq('journal_entry.company_id', companyId)
.gte('journal_entry.entry_date', startOfYearStr),
supabase.from('transactions').select('amount, amount_sek, is_business').eq('user_id', user.id).gte('date', startOfYearStr),
supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status').eq('user_id', user.id).in('status', ['sent', 'overdue']),
supabase.from('bank_connections').select('id, accounts_data, status, consent_expires, bank_name').eq('user_id', user.id).eq('status', 'active'),
supabase.from('deadlines').select('*, customer:customers(id, name)').eq('user_id', user.id).eq('is_completed', false)
supabase.from('transactions').select('amount, amount_sek, is_business').eq('company_id', companyId).gte('date', startOfYearStr),
supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status').eq('company_id', companyId).in('status', ['sent', 'overdue']),
supabase.from('bank_connections').select('id, accounts_data, status, consent_expires, bank_name').eq('company_id', companyId).eq('status', 'active'),
supabase.from('deadlines').select('*, customer:customers(id, name)').eq('company_id', companyId).eq('is_completed', false)
.or(`due_date.lt.${today},due_date.lte.${nextWeek}`).order('due_date', { ascending: true }),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'extracted'),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'confirmed').is('matched_transaction_id', null),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id).lt('amount', 0).is('receipt_id', null),
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'posted').in('source_type', needsDocSourceTypes),
supabase.from('document_attachments').select('journal_entry_id').eq('user_id', user.id).eq('is_current_version', true).not('journal_entry_id', 'is', null),
supabase.from('receipts').select('created_at').eq('user_id', user.id).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30),
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'completed'),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id).is('journal_entry_id', null).not('is_business', 'eq', false).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'extracted'),
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'confirmed').is('matched_transaction_id', null),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).lt('amount', 0).is('receipt_id', null),
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'posted').in('source_type', needsDocSourceTypes),
supabase.from('document_attachments').select('journal_entry_id').eq('company_id', companyId).eq('is_current_version', true).not('journal_entry_id', 'is', null),
supabase.from('receipts').select('created_at').eq('company_id', companyId).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30),
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).not('is_business', 'eq', false).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]),
])
const firstName = profile?.full_name?.split(' ')[0] || null
+25 -8
View File
@@ -32,6 +32,7 @@ import {
AlertTriangle,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { useCompany } from '@/contexts/CompanyContext'
import type { CompanySettings } from '@/types'
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
@@ -41,6 +42,8 @@ import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { SecuritySettings } from '@/components/settings/SecuritySettings'
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
import { TeamPanel } from '@/components/settings/TeamPanel'
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
const BankingPanel = getSettingsPanel('enable-banking')
@@ -50,6 +53,7 @@ export default function SettingsPage() {
const searchParams = useSearchParams()
const { toast } = useToast()
const supabase = createClient()
const { company, isTeamMember } = useCompany()
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
@@ -67,15 +71,18 @@ export default function SettingsPage() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const initialTab = searchParams.get('tab') || 'company'
const hasCompany = !!company
const defaultTab = hasCompany ? 'company' : (isTeamMember ? 'team' : 'account')
const initialTab = searchParams.get('tab') || defaultTab
const [activeTab, setActiveTab] = useState(initialTab)
const settingsTabs = [
{ value: 'company', label: 'Företag', show: true },
{ value: 'banking', label: 'Bank (PSD2)', show: !settings?.is_sandbox && hasBankingExtension },
{ value: 'templates', label: 'Mallar', show: true },
{ value: 'company', label: 'Företag', show: hasCompany },
{ value: 'team', label: 'Lag', show: isTeamMember },
{ value: 'banking', label: 'Bank (PSD2)', show: hasCompany && !settings?.is_sandbox && hasBankingExtension },
{ value: 'templates', label: 'Mallar', show: hasCompany },
{ value: 'account', label: 'Konto', show: true },
{ value: 'api', label: 'API', show: hasMcpExtension },
{ value: 'api', label: 'API', show: hasCompany && hasMcpExtension },
].filter(t => t.show)
useEffect(() => {
@@ -91,9 +98,10 @@ export default function SettingsPage() {
return
}
const settingsRes = await supabase.from('company_settings').select('*').eq('user_id', user.id).single()
setSettings(settingsRes.data)
if (company?.id) {
const settingsRes = await supabase.from('company_settings').select('*').eq('company_id', company.id).single()
setSettings(settingsRes.data)
}
setIsLoading(false)
}
@@ -625,6 +633,15 @@ export default function SettingsPage() {
</Button>
</div>
</form>
<Separator className="my-8" />
<CompanyMembersSection />
</TabsContent>
{/* Team management */}
<TabsContent value="team">
<TeamPanel />
</TabsContent>
{/* 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 { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import { useCompany } from '@/contexts/CompanyContext'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType, LinePatternEntry } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
@@ -42,6 +43,7 @@ interface QuickReviewState {
}
export default function TransactionsPage() {
const { company } = useCompany()
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
const [isLoading, setIsLoading] = useState(true)
const [mode, setMode] = useState<ViewMode>('inbox')
@@ -484,6 +486,7 @@ export default function TransactionsPage() {
const { data: transaction, error } = await supabase
.from('transactions')
.insert({
company_id: company!.id,
user_id: user.id,
date: data.date,
description: data.description,
+408 -53
View File
@@ -6,17 +6,24 @@ import Image from 'next/image'
import * as Sentry from '@sentry/nextjs'
import { createClient } from '@/lib/supabase/client'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { Loader2, ArrowRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
import Step0RoleChoice from '@/components/onboarding/Step0RoleChoice'
import Step1EntityType from '@/components/onboarding/Step1EntityType'
import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting'
type OnboardingMode = 'choice' | 'self' | 'consultant'
const STEP_INFO = [
{ title: 'Välkommen', subtitle: 'Välj din företagsform för att komma igång.', label: 'Företagsform' },
{ title: 'Ditt företag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' },
@@ -24,6 +31,13 @@ const STEP_INFO = [
{ title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.', label: 'Moms' },
]
const STEP_INFO_CONSULTANT = [
{ title: 'Kundföretag', subtitle: 'Välj din kunds företagsform.', label: 'Företagsform' },
{ title: 'Kundföretag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' },
{ title: 'F-skatt & räkenskapsår', subtitle: 'Din kunds skatteregistrering och räkenskapsår.', label: 'Skatt' },
{ title: 'Moms & bokföring', subtitle: 'Din kunds momsregistrering och bokföringsmetod.', label: 'Moms' },
]
function translatePeriodError(msg: string): string {
if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.'
if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.'
@@ -72,8 +86,12 @@ function OnboardingPageContent() {
const [isSaving, setIsSaving] = useState(false)
const [currentStep, setCurrentStep] = useState(1)
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
const [companyId, setCompanyId] = useState<string | null>(null)
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
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
@@ -102,26 +120,95 @@ function OnboardingPageContent() {
return
}
const { data, error } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.single()
// Check for unprocessed invite token (fallback if auth callback didn't process it)
const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
const inviteToken = cookieMatch?.[1]
if (error && error.code !== 'PGRST116') {
logError('failed to load settings', { message: error.message, code: error.code })
if (inviteToken) {
try {
const res = await fetch('/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: inviteToken }),
})
if (res.ok) {
// Clear the cookie
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
console.log(LOG, 'invite accepted via fallback — redirecting to dashboard')
router.push('/')
return
}
// Log the failure to help diagnose
const errBody = await res.json().catch(() => ({}))
console.error(LOG, 'fallback invite acceptance returned non-ok', {
status: res.status,
error: errBody.error,
})
} catch (err) {
console.error(LOG, 'fallback invite acceptance failed:', err)
}
// Clear cookie regardless to avoid retry loops
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
}
if (data) {
const step = data.onboarding_step || 1
const clampedStep = step > totalSteps ? totalSteps : step
if (step > totalSteps) {
logError('onboarding_step exceeds totalSteps — clamped', { step, totalSteps })
// Check if user is already in a team (consultant) — skip onboarding
const { data: teamMember } = await supabase
.from('team_members')
.select('id')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
if (teamMember) {
console.log(LOG, 'user already in a team — redirecting to dashboard')
window.location.href = '/'
return
}
// Check if user already has a company via company_members
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', user.id)
.order('created_at', { ascending: true })
.limit(1)
.single()
if (membership?.company_id) {
const { data, error } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', membership.company_id)
.single()
if (error && error.code !== 'PGRST116') {
logError('failed to load settings', { message: error.message, code: error.code })
}
// If this company is already onboarded (invited user joining existing company),
// skip onboarding entirely and go to dashboard
if (data?.onboarding_complete) {
console.log(LOG, 'company already onboarded — redirecting to dashboard')
router.push('/')
return
}
setCompanyId(membership.company_id)
setMode('self') // Resuming — skip role choice
if (data) {
const step = data.onboarding_step || 1
const clampedStep = step > totalSteps ? totalSteps : step
if (step > totalSteps) {
logError('onboarding_step exceeds totalSteps — clamped', { step, totalSteps })
}
// Important milestone: where we resume
console.log(LOG, 'resuming at step', clampedStep, { entity_type: data.entity_type })
setSettings(data)
setCurrentStep(clampedStep)
}
// Important milestone: where we resume
console.log(LOG, 'resuming at step', clampedStep, { entity_type: data.entity_type })
setSettings(data)
setCurrentStep(clampedStep)
}
setIsLoading(false)
@@ -153,16 +240,21 @@ function OnboardingPageContent() {
onboarding_step: targetStep,
}
if (!companyId) {
logError('save aborted: no companyId', { step: targetStep })
return false
}
// Remove read-only and transient fields before updating
const {
id: _id, user_id: _uid, created_at: _ca, updated_at: _ua,
id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua,
is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye,
...settingsToSave
} = updatedSettings as Record<string, unknown>
const { error } = await supabase
.from('company_settings')
.upsert({ ...settingsToSave, user_id: user.id }, { onConflict: 'user_id' })
.upsert({ ...settingsToSave, company_id: companyId }, { onConflict: 'company_id' })
if (error) {
logError('save failed', { message: error.message, step: targetStep, code: error.code, details: error.details })
@@ -199,36 +291,121 @@ function OnboardingPageContent() {
setTicLookup(null)
}
// Step 1: Create company + membership + user_preferences if no companyId yet
let activeCompanyId = companyId
if (currentStep === 1 && !activeCompanyId) {
try {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError) {
logError('auth.getUser() failed before company creation', { message: authError.message })
}
if (!user) {
logError('company creation skipped: no user')
router.push('/login')
return
}
// Atomically create company + owner membership + set active
const { data: newCompanyId, error: rpcError } = await supabase.rpc('create_company_with_owner', {
p_name: 'Mitt företag',
p_entity_type: stepData.entity_type,
})
if (rpcError || !newCompanyId) {
logError('company creation failed', { message: rpcError?.message, code: rpcError?.code })
toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' })
return
}
activeCompanyId = newCompanyId
setCompanyId(activeCompanyId)
console.log(LOG, 'created company', activeCompanyId)
} catch (err) {
logError('company creation threw', { error: String(err) })
Sentry.captureException(err)
toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' })
return
}
}
if (!activeCompanyId) {
logError('handleNext aborted: no companyId', { step: currentStep })
return
}
const nextStep = currentStep + 1
const success = await saveSettings(stepData, nextStep)
// For step 1, companyId state may not be updated yet (React batching).
// Save settings directly with activeCompanyId to avoid the race condition.
const needsDirectSave = currentStep === 1 && !companyId
const success = needsDirectSave
? await (async () => {
setIsSaving(true)
try {
const updatedSettings = { ...settings, ...stepData, onboarding_step: nextStep }
const {
id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua,
is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye,
...settingsToSave
} = updatedSettings as Record<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) {
logError('handleNext aborted: saveSettings failed', { step: currentStep })
return
}
// After step 2 (company details): sync company name to companies table
if (currentStep === 2 && stepData.company_name && activeCompanyId) {
const { error: nameError } = await supabase
.from('companies')
.update({ name: stepData.company_name })
.eq('id', activeCompanyId)
if (nameError) {
logError('failed to sync company name to companies table', {
message: nameError.message,
code: nameError.code,
})
}
}
// After step 1 (entity type selection): seed chart of accounts
if (currentStep === 1 && stepData.entity_type) {
try {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError) {
logError('auth.getUser() failed before seeding chart of accounts', { message: authError.message })
}
if (user) {
const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', {
p_user_id: user.id,
p_entity_type: stepData.entity_type,
const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', {
p_company_id: activeCompanyId,
p_entity_type: stepData.entity_type,
})
if (rpcError) {
logError('chart of accounts seeding failed', {
entity_type: stepData.entity_type,
message: rpcError.message,
code: rpcError.code,
details: rpcError.details,
})
if (rpcError) {
logError('chart of accounts seeding failed', {
entity_type: stepData.entity_type,
message: rpcError.message,
code: rpcError.code,
details: rpcError.details,
})
}
} else {
logError('chart of accounts seeding skipped: no user')
}
} catch (err) {
logError('chart of accounts seeding threw', { error: String(err) })
@@ -237,15 +414,8 @@ function OnboardingPageContent() {
}
// After step 3 (tax registration): create initial fiscal period
if (currentStep === 3) {
if (currentStep === 3 && companyId) {
try {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError) {
logError('auth.getUser() failed before fiscal period creation', { message: authError.message })
}
if (!user) {
logError('fiscal period creation skipped: no user')
} else {
const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined
const firstYearStart = stepData.first_year_start as string | undefined
const firstYearEnd = stepData.first_year_end as string | undefined
@@ -314,7 +484,7 @@ function OnboardingPageContent() {
const { data: existingPeriods, error: fetchPeriodsError } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', user.id)
.eq('company_id', companyId)
if (fetchPeriodsError) {
logError('failed to fetch existing fiscal periods', {
@@ -348,12 +518,12 @@ function OnboardingPageContent() {
}
const { error: upsertError } = await supabase.from('fiscal_periods').upsert({
user_id: user.id,
company_id: companyId,
name: periodName,
period_start: startStr,
period_end: endStr,
}, {
onConflict: 'user_id,period_start,period_end',
onConflict: 'company_id,period_start,period_end',
})
if (upsertError) {
@@ -361,7 +531,6 @@ function OnboardingPageContent() {
message: upsertError.message, startStr, endStr, code: upsertError.code, details: upsertError.details,
})
}
}
} catch (err) {
logError('fiscal period creation threw', { error: String(err) })
Sentry.captureException(err)
@@ -394,6 +563,8 @@ function OnboardingPageContent() {
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
} else if (isConsultant) {
setConsultantLanding(true)
}
}
@@ -408,6 +579,46 @@ function OnboardingPageContent() {
setCurrentStep(nextStep)
}
const handleConsultantCreateTeam = async () => {
if (!teamName.trim()) {
toast({ title: 'Ange ett namn', description: 'Ditt team behöver ett namn.', variant: 'destructive' })
return
}
setIsSaving(true)
try {
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
router.push('/login')
return
}
const { data: newTeamId, error: teamError } = await supabase.rpc('create_team_with_owner', {
p_name: teamName.trim(),
})
if (teamError || !newTeamId) {
logError('consultant team creation failed', { message: teamError?.message })
toast({ title: 'Fel', description: 'Kunde inte skapa team. Försök igen.', variant: 'destructive' })
return
}
console.log(LOG, 'created team', newTeamId)
toast({
title: 'Välkommen!',
description: 'Lägg till ditt första kundföretag för att komma igång.',
})
// Hard navigation to exit the (onboarding) route group and trigger middleware
window.location.href = '/'
} catch (err) {
logError('consultant team creation threw', { error: String(err) })
Sentry.captureException(err)
toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' })
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<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 = () => (
<>
@@ -475,10 +690,150 @@ function OnboardingPageContent() {
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 14 (self or consultant adding company) ──
return (
<div className="min-h-screen flex flex-col bg-background">
{/* ── Branded Header ── */}
@@ -511,7 +866,7 @@ function OnboardingPageContent() {
</div>
{/* Step indicator — inline with logo row */}
<div className="flex items-center gap-1.5">
{STEP_INFO.map((_, i) => {
{stepInfoArr.map((_, i) => {
const num = i + 1
return (
<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 { z } from 'zod'
@@ -31,9 +31,11 @@ export async function POST(request: Request) {
}
try {
// Delete the auth user via service role — all data cascades via ON DELETE CASCADE
const serviceClient = await createServiceClient()
const { error } = await serviceClient.auth.admin.deleteUser(user.id)
// RPC disables protective triggers, deletes from auth.users (CASCADE
// cleans up all public tables), then re-enables triggers — all in one tx.
const { error } = await supabase.rpc('delete_user_account', {
target_user_id: user.id,
})
if (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 { hasAiConsent, grantAiConsent, revokeAiConsent } from '@/lib/extensions/ai-consent'
import { GET, POST, DELETE } from '../route'
@@ -91,7 +96,7 @@ describe('POST /api/ai-consent', () => {
expect(status).toBe(200)
expect(body.data.consented).toBe(true)
expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'receipt-ocr')
expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'receipt-ocr')
})
it('returns 400 for non-AI extension', async () => {
@@ -131,6 +136,6 @@ describe('DELETE /api/ai-consent', () => {
expect(status).toBe(200)
expect(body.data.consented).toBe(false)
expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'ai-chat')
expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'ai-chat')
})
})
+10 -3
View File
@@ -7,6 +7,7 @@ import {
revokeAiConsent,
isAiExtension,
} from '@/lib/extensions/ai-consent'
import { requireCompanyId } from '@/lib/company/context'
export async function GET() {
const supabase = await createClient()
@@ -16,9 +17,11 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const statuses: Record<string, boolean> = {}
for (const ext of AI_EXTENSIONS) {
statuses[ext] = await hasAiConsent(supabase, user.id, ext)
statuses[ext] = await hasAiConsent(supabase, companyId, ext)
}
return NextResponse.json({ data: statuses })
@@ -32,6 +35,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { extension_id } = body
@@ -42,7 +47,7 @@ export async function POST(request: Request) {
)
}
await grantAiConsent(supabase, user.id, extension_id)
await grantAiConsent(supabase, user.id, companyId, extension_id)
return NextResponse.json({ data: { consented: true } })
}
@@ -54,6 +59,8 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { extension_id } = body
@@ -64,6 +71,6 @@ export async function DELETE(request: Request) {
)
}
await revokeAiConsent(supabase, user.id, extension_id)
await revokeAiConsent(supabase, user.id, companyId, extension_id)
return NextResponse.json({ data: { consented: false } })
}
@@ -9,6 +9,11 @@ vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { createClient } from '@/lib/supabase/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { GET } from '../route'
+3
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import type { AuditAction } from '@/types'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -11,6 +12,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const filters = {
+4 -1
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -9,6 +10,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const from = searchParams.get('from')
const to = searchParams.get('to')
@@ -27,7 +30,7 @@ export async function GET(request: Request) {
let entriesQuery = supabase
.from('journal_entries')
.select('id, entry_date')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('status', 'posted')
if (dateFrom) {
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { UpdateAccountSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function DELETE(
request: Request,
@@ -15,11 +16,13 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch the account to check if it's a system account
const { data: account, error: fetchError } = await supabase
.from('chart_of_accounts')
.select('id, is_system_account')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('account_number', number)
.single()
@@ -51,7 +54,7 @@ export async function DELETE(
.from('chart_of_accounts')
.delete()
.eq('id', account.id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
@@ -72,6 +75,8 @@ export async function PUT(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, UpdateAccountSchema)
if (!validation.success) return validation.response
const body = validation.data
@@ -79,7 +84,7 @@ export async function PUT(
const { data, error } = await supabase
.from('chart_of_accounts')
.update(body)
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('account_number', number)
.select()
.single()
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { requireCompanyId } from '@/lib/company/context'
/**
* POST /api/bookkeeping/accounts/activate
@@ -17,6 +18,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const accountNumbers: string[] = body.account_numbers
@@ -28,7 +31,7 @@ export async function POST(request: Request) {
const { data: existing } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('user_id', user.id)
.eq('company_id', companyId)
.in('account_number', accountNumbers)
const existingNumbers = new Set((existing || []).map((a) => a.account_number))
@@ -42,6 +45,7 @@ export async function POST(request: Request) {
return {
user_id: user.id,
company_id: companyId,
account_number: ref.account_number,
account_name: ref.account_name,
account_class: ref.account_class,
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/bookkeeping/accounts/reference
@@ -17,13 +18,15 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch user's chart of accounts (paginated to avoid 1000-row limit)
try {
const userAccounts = await fetchAllRows<{ account_number: string; is_active: boolean; is_system_account: boolean }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, is_active, is_system_account')
.eq('user_id', user.id)
.eq('company_id', companyId)
.range(from, to)
)
+7 -1
View File
@@ -3,6 +3,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { CreateAccountSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -12,6 +13,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const accountClass = searchParams.get('class')
const activeOnly = searchParams.get('active') !== 'false'
@@ -21,7 +24,7 @@ export async function GET(request: Request) {
let query = supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('sort_order')
if (activeOnly) {
@@ -53,10 +56,13 @@ export async function POST(request: Request) {
if (!validation.success) return validation.response
const body = validation.data
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('chart_of_accounts')
.insert({
user_id: user.id,
company_id: companyId,
account_number: body.account_number,
account_name: body.account_name,
account_class: parseInt(body.account_number[0]),
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
import { requireCompanyId } from '@/lib/company/context'
export async function POST(
request: Request,
@@ -14,8 +15,10 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const period = await closePeriod(supabase, user.id, id)
const period = await closePeriod(supabase, companyId, user.id, id)
return NextResponse.json({ data: period })
} catch (err) {
return NextResponse.json(
@@ -4,6 +4,7 @@ import {
previewCurrencyRevaluation,
executeCurrencyRevaluation,
} from '@/lib/bookkeeping/currency-revaluation'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET: Preview currency revaluation for a fiscal period
@@ -20,20 +21,22 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
// Fetch period to get closing date
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
}
const preview = await previewCurrencyRevaluation(supabase, user.id, period.period_end)
const preview = await previewCurrencyRevaluation(supabase, companyId, period.period_end)
return NextResponse.json({ data: preview })
} catch (err) {
return NextResponse.json(
@@ -58,13 +61,15 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
// Fetch period to get closing date
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
@@ -75,7 +80,7 @@ export async function POST(
return NextResponse.json({ error: 'Period is already closed' }, { status: 400 })
}
const result = await executeCurrencyRevaluation(supabase, user.id, period.period_end, id)
const result = await executeCurrencyRevaluation(supabase, companyId, period.period_end, id, user.id)
if (!result) {
return NextResponse.json({ data: null, message: 'No foreign currency items to revalue' })
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { lockPeriod } from '@/lib/core/bookkeeping/period-service'
import { requireCompanyId } from '@/lib/company/context'
export async function POST(
request: Request,
@@ -14,8 +15,10 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const period = await lockPeriod(supabase, user.id, id)
const period = await lockPeriod(supabase, companyId, user.id, id)
return NextResponse.json({ data: period })
} catch (err) {
return NextResponse.json(
@@ -5,6 +5,7 @@ import {
previewYearEndClosing,
executeYearEndClosing,
} from '@/lib/core/bookkeeping/year-end-service'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET: Validate readiness and preview year-end closing
@@ -21,10 +22,12 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const [validation, preview] = await Promise.all([
validateYearEndReadiness(supabase, user.id, id),
previewYearEndClosing(supabase, user.id, id),
validateYearEndReadiness(supabase, companyId, user.id, id),
previewYearEndClosing(supabase, companyId, user.id, id),
])
return NextResponse.json({ data: { validation, preview } })
@@ -51,8 +54,10 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const result = await executeYearEndClosing(supabase, user.id, id)
const result = await executeYearEndClosing(supabase, companyId, user.id, id)
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
+8 -2
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import { validateBody } from '@/lib/api/validate'
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function GET() {
const supabase = await createClient()
@@ -12,10 +13,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('fiscal_periods')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('period_start', { ascending: false })
if (error) {
@@ -33,6 +36,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateFiscalPeriodSchema)
if (!validation.success) return validation.response
const body = validation.data
@@ -47,7 +52,7 @@ export async function POST(request: Request) {
const { data: overlapping } = await supabase
.from('fiscal_periods')
.select('id, name')
.eq('user_id', user.id)
.eq('company_id', companyId)
.lte('period_start', body.period_end)
.gte('period_end', body.period_start)
.limit(1)
@@ -63,6 +68,7 @@ export async function POST(request: Request) {
.from('fiscal_periods')
.insert({
user_id: user.id,
company_id: companyId,
name: body.name,
period_start: body.period_start,
period_end: body.period_end,
@@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => mockCreateClient(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route'
function buildMockSupabase({
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(
request: Request,
@@ -13,12 +14,14 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch the requested entry with lines
const { data: entry, error } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error || !entry) {
@@ -38,7 +41,7 @@ export async function GET(
const { data: referencing } = await supabase
.from('journal_entries')
.select('id')
.eq('user_id', user.id)
.eq('company_id', companyId)
.or(`reverses_id.eq.${id},reversed_by_id.eq.${id},correction_of_id.eq.${id}`)
if (referencing) {
@@ -57,7 +60,7 @@ export async function GET(
const { data: batchEntries } = await supabase
.from('journal_entries')
.select('id, reverses_id, reversed_by_id, correction_of_id')
.eq('user_id', user.id)
.eq('company_id', companyId)
.in('id', batch)
if (!batchEntries) continue
@@ -81,7 +84,7 @@ export async function GET(
const { data: refs } = await supabase
.from('journal_entries')
.select('id')
.eq('user_id', user.id)
.eq('company_id', companyId)
.or(batchOr)
if (refs) {
@@ -99,7 +102,7 @@ export async function GET(
const { data: chainEntries } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', user.id)
.eq('company_id', companyId)
.in('id', chainIds)
.order('created_at', { ascending: true })
@@ -15,6 +15,10 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCorrectEntry = vi.fn()
vi.mock('@/lib/core/bookkeeping/storno-service', () => ({
correctEntry: (...args: unknown[]) => mockCorrectEntry(...args),
@@ -100,7 +104,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
expect(status).toBe(200)
expect(body.data.reversal).toEqual(reversal)
expect(body.data.corrected).toEqual(corrected)
expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'entry-1', lines)
expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1', lines)
})
it('returns 400 when correctEntry throws for unbalanced lines', async () => {
@@ -4,6 +4,7 @@ import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -19,12 +20,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CorrectJournalEntrySchema)
if (!validation.success) return validation.response
const body = validation.data
try {
const result = await correctEntry(supabase, user.id, id, body.lines)
const result = await correctEntry(supabase, companyId, user.id, id, body.lines)
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
@@ -16,6 +16,10 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockReverseEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
@@ -64,7 +68,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => {
expect(status).toBe(200)
expect(body.data).toEqual(reversalEntry)
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'entry-1')
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1')
})
it('returns 400 when engine throws', async () => {
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -17,8 +18,10 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const reversalEntry = await reverseEntry(supabase, user.id, id)
const reversalEntry = await reverseEntry(supabase, companyId, user.id, id)
return NextResponse.json({ data: reversalEntry })
} catch (err) {
return NextResponse.json(
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(
request: Request,
@@ -13,11 +14,13 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error) {
@@ -16,6 +16,10 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCreateJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
@@ -137,7 +141,7 @@ describe('POST /api/bookkeeping/journal-entries', () => {
expect(status).toBe(200)
expect(body.data).toEqual(entry)
expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', input)
expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', input)
})
it('returns 400 when engine throws', async () => {
+7 -2
View File
@@ -4,6 +4,7 @@ import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -15,6 +16,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const status = searchParams.get('status')
@@ -29,7 +32,7 @@ export async function GET(request: Request) {
let query = supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
.eq('user_id', user.id)
.eq('company_id', companyId)
if (sortDate === 'asc' || sortDate === 'desc') {
query = query
@@ -76,12 +79,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateJournalEntrySchema)
if (!validation.success) return validation.response
const body = validation.data
try {
const entry = await createJournalEntry(supabase, user.id, body)
const entry = await createJournalEntry(supabase, companyId, user.id, body)
return NextResponse.json({ data: entry })
} catch (err) {
return NextResponse.json(
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { validateBody } from '@/lib/api/validate'
import { EvaluateMappingRulesSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction } from '@/types'
export async function POST(request: Request) {
@@ -13,6 +14,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, EvaluateMappingRulesSchema)
if (!validation.success) return validation.response
const body = validation.data
@@ -25,7 +28,7 @@ export async function POST(request: Request) {
.from('transactions')
.select('*')
.eq('id', body.transaction_id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error || !data) {
@@ -38,7 +41,7 @@ export async function POST(request: Request) {
}
try {
const result = await evaluateMappingRules(supabase, user.id, transaction)
const result = await evaluateMappingRules(supabase, companyId, transaction)
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
+7 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { CreateMappingRuleSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function GET() {
const supabase = await createClient()
@@ -11,10 +12,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('mapping_rules')
.select('*')
.or(`user_id.eq.${user.id},user_id.is.null`)
.or(`company_id.eq.${companyId},company_id.is.null`)
.eq('is_active', true)
.order('priority')
@@ -33,6 +36,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, CreateMappingRuleSchema)
if (!result.success) return result.response
const body = result.data
@@ -41,6 +46,7 @@ export async function POST(request: Request) {
.from('mapping_rules')
.insert({
user_id: user.id,
company_id: companyId,
rule_name: body.rule_name,
rule_type: body.rule_type,
priority: body.priority || 10,
+2 -2
View File
@@ -101,7 +101,7 @@ export async function GET(
? supabase
.from('deadlines')
.select('*')
.eq('user_id', feed.user_id)
.eq('company_id', feed.company_id)
.gte('due_date', startStr)
.lte('due_date', endStr)
.order('due_date')
@@ -112,7 +112,7 @@ export async function GET(
? supabase
.from('invoices')
.select('*, customer:customers(*)')
.eq('user_id', feed.user_id)
.eq('company_id', feed.company_id)
.gte('due_date', startStr)
.lte('due_date', endStr)
.order('due_date')
+14 -4
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import type { UpdateCalendarFeedInput } from '@/types'
/**
@@ -15,10 +16,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data: feed, error } = await supabase
.from('calendar_feeds')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error && error.code !== 'PGRST116') {
@@ -57,11 +60,13 @@ export async function POST() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Check if feed already exists
const { data: existingFeed } = await supabase
.from('calendar_feeds')
.select('id')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (existingFeed) {
@@ -76,6 +81,7 @@ export async function POST() {
.from('calendar_feeds')
.insert({
user_id: user.id,
company_id: companyId,
is_active: true,
include_tax_deadlines: true,
include_invoices: true,
@@ -111,12 +117,14 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body: UpdateCalendarFeedInput = await request.json()
const { data: feed, error } = await supabase
.from('calendar_feeds')
.update(body)
.eq('user_id', user.id)
.eq('company_id', companyId)
.select()
.single()
@@ -148,6 +156,8 @@ export async function DELETE() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Generate a new token by updating with a new UUID
const { data: feed, error } = await supabase
.from('calendar_feeds')
@@ -156,7 +166,7 @@ export async function DELETE() {
access_count: 0,
last_accessed_at: null,
})
.eq('user_id', user.id)
.eq('company_id', companyId)
.select()
.single()
+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 { UpdateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { requireCompanyId } from '@/lib/company/context'
import { createLogger } from '@/lib/logger'
const log = createLogger('api/customers/[id]')
@@ -22,11 +23,13 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('customers')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error) {
@@ -41,7 +44,7 @@ export async function GET(
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, status, total, currency')
.eq('customer_id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('invoice_date', { ascending: false })
return NextResponse.json({
@@ -67,6 +70,8 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, UpdateCustomerSchema)
if (!result.success) return result.response
const body = result.data
@@ -91,7 +96,7 @@ export async function PATCH(
.from('customers')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.select()
.single()
@@ -113,7 +118,7 @@ export async function PATCH(
vat_number_validated_at: new Date().toISOString(),
})
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
data.vat_number_validated = true
data.vat_number_validated_at = new Date().toISOString()
@@ -125,7 +130,7 @@ export async function PATCH(
vat_number_validated_at: null,
})
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
data.vat_number_validated = false
data.vat_number_validated_at = null
@@ -139,7 +144,7 @@ export async function PATCH(
vat_number_validated_at: null,
})
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
data.vat_number_validated = false
data.vat_number_validated_at = null
@@ -167,11 +172,13 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { error } = await supabase
.from('customers')
.delete()
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
+9 -3
View File
@@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { requireCompanyId } from '@/lib/company/context'
import { createLogger } from '@/lib/logger'
import type { Customer } from '@/types'
@@ -21,10 +22,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('customers')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('name', { ascending: true })
if (error) {
@@ -43,6 +46,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, CreateCustomerSchema)
if (!result.success) return result.response
const body = result.data
@@ -51,6 +56,7 @@ export async function POST(request: Request) {
.from('customers')
.insert({
user_id: user.id,
company_id: companyId,
name: body.name,
customer_type: body.customer_type,
email: body.email,
@@ -84,7 +90,7 @@ export async function POST(request: Request) {
vat_number_validated_at: new Date().toISOString(),
})
.eq('id', data.id)
.eq('user_id', user.id)
.eq('company_id', companyId)
data.vat_number_validated = true
data.vat_number_validated_at = new Date().toISOString()
@@ -96,7 +102,7 @@ export async function POST(request: Request) {
await eventBus.emit({
type: 'customer.created',
payload: { customer: data as Customer, userId: user.id },
payload: { customer: data as Customer, companyId, userId: user.id },
})
return NextResponse.json({ data })
+5 -2
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* POST /api/deadlines/[id]/complete
@@ -20,12 +21,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// First, get current deadline state
const { data: existing, error: fetchError } = await supabase
.from('deadlines')
.select('is_completed')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchError) {
@@ -44,7 +47,7 @@ export async function POST(
completed_at: newCompletedState ? new Date().toISOString() : null,
})
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.select('*, customer:customers(id, name)')
.single()
+11 -4
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import type { CreateDeadlineInput } from '@/types'
/**
@@ -21,11 +22,13 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('deadlines')
.select('*, customer:customers(id, name)')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error) {
@@ -57,6 +60,8 @@ export async function PUT(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body: Partial<CreateDeadlineInput> = await request.json()
// First, get existing deadline to verify ownership
@@ -64,7 +69,7 @@ export async function PUT(
.from('deadlines')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchError) {
@@ -89,7 +94,7 @@ export async function PUT(
.from('deadlines')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.select('*, customer:customers(id, name)')
.single()
@@ -119,11 +124,13 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { error } = await supabase
.from('deadlines')
.delete()
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
+7 -2
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine'
import { requireCompanyId } from '@/lib/company/context'
import type { DeadlineStatus } from '@/types'
/**
@@ -19,6 +20,8 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const body = await request.json()
@@ -41,7 +44,7 @@ export async function PATCH(
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
}
const result = await updateDeadlineStatus(supabase, id, user.id, newStatus)
const result = await updateDeadlineStatus(supabase, id, companyId, newStatus)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
@@ -66,13 +69,15 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const { data: deadline, error } = await supabase
.from('deadlines')
.select('status, is_completed, due_date')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error || !deadline) {
+7 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { CreateDeadlineSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/deadlines
@@ -23,6 +24,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Parse query params
const { searchParams } = new URL(request.url)
const status = searchParams.get('status') || 'all'
@@ -34,7 +37,7 @@ export async function GET(request: Request) {
let query = supabase
.from('deadlines')
.select('*, customer:customers(id, name)')
.eq('user_id', user.id)
.eq('company_id', companyId)
// Apply filters
if (status === 'pending') {
@@ -79,6 +82,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateDeadlineSchema)
if (!validation.success) return validation.response
const body = validation.data
@@ -88,6 +93,7 @@ export async function POST(request: Request) {
.from('deadlines')
.insert({
user_id: user.id,
company_id: companyId,
title: body.title,
due_date: body.due_date,
due_time: body.due_time || null,
+4 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -25,6 +26,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
try {
@@ -39,7 +42,7 @@ export async function POST(
const document = await linkToJournalEntry(
supabase,
user.id,
companyId,
id,
body.journal_entry_id,
body.journal_entry_line_id
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -20,6 +21,8 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
// Fetch document record
@@ -27,7 +30,7 @@ export async function GET(
.from('document_attachments')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (docError || !doc) {
+4 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { verifyIntegrity } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -21,10 +22,12 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
try {
const result = await verifyIntegrity(supabase, user.id, id)
const result = await verifyIntegrity(supabase, companyId, id)
return NextResponse.json({ data: result })
} catch (error) {
+8 -3
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { createNewVersion } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -24,6 +25,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
try {
@@ -68,14 +71,16 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
// First, check if the document belongs to the user
// First, check if the document belongs to the company
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, original_id')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (docError || !doc) {
@@ -89,7 +94,7 @@ export async function GET(
const { data: versions, error: versionsError } = await supabase
.from('document_attachments')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.or(`id.eq.${rootId},original_id.eq.${rootId}`)
.order('version', { ascending: true })
+4 -1
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/documents/counts?journal_entry_ids=id1,id2,...
@@ -15,6 +16,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const idsParam = searchParams.get('journal_entry_ids')
@@ -35,7 +38,7 @@ export async function GET(request: Request) {
const { data, error } = await supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('is_current_version', true)
.in('journal_entry_id', ids)
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { runDocumentMatchingSweep } from '@/lib/documents/batch-match'
import { requireCompanyId } from '@/lib/company/context'
export async function POST(request: Request) {
const supabase = await createClient()
@@ -10,6 +11,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Optional: pass specific inbox item IDs to match
let inboxItemIds: string[] | undefined
try {
@@ -22,7 +25,7 @@ export async function POST(request: Request) {
}
try {
const result = await runDocumentMatchingSweep(supabase, user.id, inboxItemIds)
const result = await runDocumentMatchingSweep(supabase, companyId, inboxItemIds)
return NextResponse.json({ data: result })
} catch (error) {
console.error('[match-sweep] Failed:', error)
+7 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -24,6 +25,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
@@ -38,7 +41,7 @@ export async function POST(request: Request) {
const buffer = await file.arrayBuffer()
const document = await uploadDocument(supabase, user.id, {
const document = await uploadDocument(supabase, user.id, companyId, {
name: file.name,
buffer,
type: file.type,
@@ -77,6 +80,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const journalEntryId = searchParams.get('journal_entry_id')
const currentOnly = searchParams.get('current_only') !== 'false'
@@ -86,7 +91,7 @@ export async function GET(request: Request) {
let query = supabase
.from('document_attachments')
.select('*', { count: 'exact' })
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
+5
View File
@@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
// Mock API key auth
const mockValidateApiKey = vi.fn()
const mockExtractBearerToken = vi.fn()
+4 -1
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { validateQuery } from '@/lib/api/validate'
import { EventsQuerySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import type { SupabaseClient } from '@supabase/supabase-js'
/**
@@ -40,6 +41,8 @@ export async function GET(request: Request) {
userId = user.id
}
const companyId = await requireCompanyId(supabase, userId)
// Validate query params
const result = validateQuery(request, EventsQuerySchema)
if (!result.success) return result.response
@@ -49,7 +52,7 @@ export async function GET(request: Request) {
let query = supabase
.from('event_log')
.select('sequence, event_type, entity_id, data, created_at')
.eq('user_id', userId)
.eq('company_id', companyId)
.order('sequence', { ascending: true })
.limit(limit)
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(
request: Request,
@@ -13,6 +14,8 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const extensionId = `${sector}/${slug}`
const { searchParams } = new URL(request.url)
@@ -21,7 +24,7 @@ export async function GET(
let query = supabase
.from('extension_data')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', extensionId)
const prefix = searchParams.get('prefix')
@@ -53,6 +56,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { key, value } = body
@@ -67,6 +72,7 @@ export async function POST(
.upsert(
{
user_id: user.id,
company_id: companyId,
extension_id: extensionId,
key,
value,
@@ -95,6 +101,8 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const key = searchParams.get('key')
@@ -107,7 +115,7 @@ export async function DELETE(
const { error } = await supabase
.from('extension_data')
.delete()
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', extensionId)
.eq('key', key)
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(
_request: Request,
@@ -13,12 +14,14 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const extensionId = `${sector}/${slug}`
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', extensionId)
.eq('key', 'settings')
.single()
@@ -38,6 +41,8 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const extensionId = `${sector}/${slug}`
@@ -45,7 +50,7 @@ export async function PATCH(
const { data: existing } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', extensionId)
.eq('key', 'settings')
.single()
@@ -57,6 +62,7 @@ export async function PATCH(
.upsert(
{
user_id: user.id,
company_id: companyId,
extension_id: extensionId,
key: 'settings',
value: mergedSettings,
@@ -81,7 +81,7 @@ export async function GET(request: Request) {
// Look up pending connection by oauth_state (CSRF-safe)
const { data: pendingConnection, error: findError } = await supabase
.from('bank_connections')
.select('id, user_id')
.select('id, user_id, company_id')
.eq('oauth_state', state)
.eq('status', 'pending')
.single()
@@ -98,6 +98,7 @@ export async function GET(request: Request) {
}
const userId = pendingConnection.user_id
const companyId = pendingConnection.company_id
console.log('[enable-banking] Exchanging code for session', {
connectionId: pendingConnection.id,
@@ -166,7 +167,7 @@ export async function GET(request: Request) {
const { data: userSettings } = await supabase
.from('company_settings')
.select('onboarding_complete')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
const redirectTarget = userSettings?.onboarding_complete
@@ -143,7 +143,7 @@ export async function GET(request: Request) {
const { data: sieOverlap } = await supabase
.from('sie_imports')
.select('id')
.eq('user_id', connection.user_id)
.eq('company_id', connection.company_id)
.eq('status', 'completed')
.gte('fiscal_year_end', fromDate)
.limit(1)
@@ -156,6 +156,7 @@ export async function GET(request: Request) {
const syncResults = await Promise.all(
accounts.map(account => syncAccountTransactions(
supabase,
connection.company_id,
connection.user_id,
connection.id,
account,
@@ -284,7 +285,7 @@ async function sendConsentExpiryNotification(
const { data: companySettings } = await supabase
.from('company_settings')
.select('company_name')
.eq('user_id', userId)
.eq('company_id', connection.company_id)
.single()
const emailData = {
@@ -15,6 +15,11 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/extensions/context-factory', () => ({
createExtensionContext: vi.fn().mockReturnValue({
userId: 'user-1',
+5 -2
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { extensionRegistry } from '@/lib/extensions/registry'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
import { requireCompanyId } from '@/lib/company/context'
import type { ApiRouteDefinition } from '@/lib/extensions/types'
ensureInitialized()
@@ -117,9 +118,11 @@ async function handleRequest(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// AI consent check
if (isAiExtension(extensionId)) {
const consented = await hasAiConsent(supabase, user.id, extensionId)
const consented = await hasAiConsent(supabase, companyId, extensionId)
if (!consented) {
return NextResponse.json(
{ error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' },
@@ -145,7 +148,7 @@ async function handleRequest(
}
// Build context and dispatch
const ctx = createExtensionContext(supabase, user.id, extensionId)
const ctx = createExtensionContext(supabase, user.id, companyId, extensionId)
return matchedRoute.handler(handlerRequest, ctx)
}
@@ -142,7 +142,7 @@ describe('Invoice Inbox Webhook Route', () => {
created_at: '2024-06-15T10:00:00Z',
})
mockExtractAttachments.mockReturnValue([])
mockResolveUserFromEmail.mockResolvedValue('user-1')
mockResolveUserFromEmail.mockResolvedValue({ userId: 'user-1', companyId: 'company-1' })
// Insert inbox item with error status
enqueueMany([
@@ -79,13 +79,15 @@ export async function POST(request: Request) {
const supabase = createServiceClient()
// Resolve user from recipient email
const userId = await resolveUserFromEmail(payload.to, supabase)
const resolved = await resolveUserFromEmail(payload.to, supabase)
if (!userId) {
if (!resolved) {
console.warn(`[document-inbox] No user found for email: ${payload.to}`)
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
const { userId, companyId } = resolved
// Build raw email payload for BFL 7:2 archiving (no binary attachment content)
const rawEmailPayload = buildRawEmailPayload(body, payload)
@@ -96,6 +98,7 @@ export async function POST(request: Request) {
await supabase
.from('invoice_inbox_items')
.insert({
company_id: companyId,
user_id: userId,
status: 'error',
source: 'email',
@@ -131,6 +134,7 @@ export async function POST(request: Request) {
const { data: document, error: docError } = await supabase
.from('document_attachments')
.insert({
company_id: companyId,
user_id: userId,
storage_path: storagePath,
file_name: attachment.filename,
@@ -159,6 +163,7 @@ export async function POST(request: Request) {
const { data: inboxItem, error: itemError } = await supabase
.from('invoice_inbox_items')
.insert({
company_id: companyId,
user_id: userId,
status: 'processing',
source: 'email',
@@ -197,7 +202,7 @@ export async function POST(request: Request) {
const { data: suppliers } = await supabase
.from('suppliers')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers)
@@ -222,7 +227,7 @@ export async function POST(request: Request) {
// Use pre-extracted receipt data from unified call
const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath)
const result = await processReceiptFromDocument(supabase, userId, attachment.content, attachment.content_type, {
const result = await processReceiptFromDocument(supabase, userId, companyId, attachment.content, attachment.content_type, {
documentId: document.id,
source: 'email',
emailFrom: payload.from,
+6 -1
View File
@@ -4,6 +4,7 @@ import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
import { generateExternalId } from '@/lib/import/bank-file/parser'
import { requireCompanyId } from '@/lib/company/context'
import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
import type { Transaction } from '@/types'
@@ -33,6 +34,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body: ExecuteRequest = await request.json()
const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true } = body
@@ -46,6 +49,7 @@ export async function POST(request: Request) {
.from('bank_file_imports')
.upsert({
user_id: user.id,
company_id: companyId,
filename,
file_hash,
file_format: format,
@@ -76,7 +80,7 @@ export async function POST(request: Request) {
}))
// Run ingestion pipeline
const ingestResult = await ingestTransactions(supabase, user.id, rawTransactions)
const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions)
// Update import record with results
await supabase
@@ -106,6 +110,7 @@ export async function POST(request: Request) {
payload: {
transactions: importedTransactions as Transaction[],
userId: user.id,
companyId,
},
})
}
+5 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser'
import { decodeFileContent } from '@/lib/import/bank-file/encoding'
import { requireCompanyId } from '@/lib/company/context'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
/**
@@ -18,6 +19,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const formData = await request.formData()
const file = formData.get('file') as File | null
const formatOverride = formData.get('format') as BankFileFormatId | null
@@ -41,7 +44,7 @@ export async function POST(request: Request) {
const { data: existingImport } = await supabase
.from('bank_file_imports')
.select('id, status, imported_count, created_at')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('file_hash', fileHash)
.single()
@@ -67,7 +70,7 @@ export async function POST(request: Request) {
const { count } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('company_id', companyId)
.gte('date', parseResult.date_from || '1970-01-01')
.lte('date', parseResult.date_to || '2099-12-31')
+8 -3
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/import/sie/[id]
@@ -20,11 +21,13 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('sie_imports')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error) {
@@ -62,12 +65,14 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Check current status before deleting
const { data: importRecord } = await supabase
.from('sie_imports')
.select('status')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (!importRecord) {
@@ -84,7 +89,7 @@ export async function DELETE(
.from('sie_imports')
.delete()
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import type { SIEAccount } from '@/lib/import/types'
/**
@@ -63,6 +64,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const body = await request.json()
const accounts: SIEAccount[] = body.accounts
@@ -80,6 +83,7 @@ export async function POST(request: Request) {
return {
user_id: user.id,
company_id: companyId,
account_number: account.number,
account_name: account.name,
account_class: accountClass,
+11 -4
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
import { suggestMappings } from '@/lib/import/account-mapper'
import { executeSIEImport } from '@/lib/import/sie-import'
@@ -26,6 +27,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
// Get form data with file and options
const formData = await request.formData()
@@ -63,7 +66,7 @@ export async function POST(request: Request) {
const { data: storedMappings } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
mappings = suggestMappings(
parsed.accounts,
@@ -90,14 +93,15 @@ export async function POST(request: Request) {
...new Set(mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)),
]
const existingAccounts = await fetchAllRows(({ from, to }) =>
const allCompanyAccounts = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number')
.eq('user_id', user.id)
.in('account_number', mappedAccountNumbers)
.eq('company_id', companyId)
.range(from, to)
)
const mappedSet = new Set(mappedAccountNumbers)
const existingAccounts = allCompanyAccounts.filter((a) => mappedSet.has(a.account_number))
// Build a lookup from SIE mappings for account names (used for bas_range accounts)
const mappingNameLookup = new Map<string, string>()
@@ -116,6 +120,7 @@ export async function POST(request: Request) {
// Account exists in BAS reference — use full metadata
return {
user_id: user.id,
company_id: companyId,
account_number: ref.account_number,
account_name: ref.account_name,
account_class: ref.account_class,
@@ -146,6 +151,7 @@ export async function POST(request: Request) {
return {
user_id: user.id,
company_id: companyId,
account_number: num,
account_name: accountName,
account_class: accountClass,
@@ -176,6 +182,7 @@ export async function POST(request: Request) {
// Execute the import
const result = await executeSIEImport(
supabase,
companyId,
user.id,
parsed,
mappings,
+13 -3
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { saveMappings } from '@/lib/import/sie-import'
import { requireCompanyId } from '@/lib/company/context'
import type { AccountMapping } from '@/lib/import/types'
/**
@@ -18,10 +19,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('source_account')
if (error) {
@@ -46,6 +49,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const mappings: AccountMapping[] = body.mappings
@@ -79,6 +84,8 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { sourceAccount, targetAccount } = body
@@ -93,6 +100,7 @@ export async function PUT(request: Request) {
.from('sie_account_mappings')
.upsert({
user_id: user.id,
company_id: companyId,
source_account: sourceAccount,
target_account: targetAccount,
confidence: 1.0,
@@ -125,6 +133,8 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const sourceAccount = searchParams.get('sourceAccount')
@@ -133,7 +143,7 @@ export async function DELETE(request: Request) {
const { error } = await supabase
.from('sie_account_mappings')
.delete()
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('source_account', sourceAccount)
if (error) {
@@ -144,7 +154,7 @@ export async function DELETE(request: Request) {
const { error } = await supabase
.from('sie_account_mappings')
.delete()
.eq('user_id', user.id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
+4 -1
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import {
parseSIEFile,
validateSIEFile,
@@ -27,6 +28,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
// Get form data with file
const formData = await request.formData()
@@ -91,7 +94,7 @@ export async function POST(request: Request) {
const { data: storedMappings } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
// Match against the full BAS reference (1,276 accounts) instead of only
// the user's active chart (~40 accounts). Accounts that match will be
+4 -1
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/import/sie
@@ -16,6 +17,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Parse query params
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get('limit') || '20', 10)
@@ -25,7 +28,7 @@ export async function GET(request: Request) {
let query = supabase
.from('sie_imports')
.select('*', { count: 'exact' })
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
+6 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice } from '@/types'
ensureInitialized()
@@ -25,12 +26,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch proforma with items
const { data: proforma, error: proformaError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (proformaError || !proforma) {
@@ -61,6 +64,7 @@ export async function POST(
.from('invoices')
.insert({
user_id: user.id,
company_id: companyId,
customer_id: proforma.customer_id,
invoice_number: invoiceNumber,
invoice_date: new Date().toISOString().split('T')[0],
@@ -129,7 +133,7 @@ export async function POST(
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId: user.id },
payload: { invoice: completeInvoice as Invoice, companyId, userId: user.id },
})
}
@@ -17,6 +17,10 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCreateInvoicePaymentJournalEntry = vi.fn()
const mockCreateInvoiceCashEntry = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
@@ -137,6 +141,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(body.journal_entry_id).toBe('je-1')
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
expect.any(String),
@@ -172,6 +177,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(body.journal_entry_id).toBe('je-2')
expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
expect.any(String),
@@ -235,6 +241,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
// Should call createJournalEntry directly with custom lines
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({
entry_date: '2025-03-17',
+10 -5
View File
@@ -7,6 +7,7 @@ import {
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
ensureInitialized()
@@ -35,12 +36,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
@@ -83,7 +86,7 @@ export async function POST(
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
@@ -107,7 +110,7 @@ export async function POST(
}
// User-provided lines from PaymentBookingDialog
const fiscalPeriodId = await findFiscalPeriod(supabase, user.id, paymentDate)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
if (!fiscalPeriodId) {
return NextResponse.json(
{ error: 'Ingen öppen räkenskapsperiod för betalningsdatumet' },
@@ -125,12 +128,13 @@ export async function POST(
source_id: invoice.id,
lines: customLines,
}
const journalEntry = await createJournalEntry(supabase, user.id, input)
const journalEntry = await createJournalEntry(supabase, companyId, user.id, input)
journalEntryId = journalEntry?.id ?? null
} else if (accountingMethod === 'accrual') {
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
const journalEntry = await createInvoicePaymentJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
paymentDate,
@@ -142,6 +146,7 @@ export async function POST(
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
const journalEntry = await createInvoiceCashEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
paymentDate,
@@ -168,7 +173,7 @@ export async function POST(
paid_amount: invoice.total,
})
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
+7 -3
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import type { EntityType, Invoice } from '@/types'
ensureInitialized()
@@ -26,12 +27,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
@@ -50,7 +53,7 @@ export async function POST(
.from('invoices')
.update({ status: 'sent' })
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
@@ -60,7 +63,7 @@ export async function POST(
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
@@ -72,6 +75,7 @@ export async function POST(
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
(settings?.entity_type as EntityType) || 'enskild_firma',
+5 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
export async function GET(
@@ -17,6 +18,8 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice with customer and items
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
@@ -26,7 +29,7 @@ export async function GET(
items:invoice_items(*)
`)
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
@@ -37,7 +40,7 @@ export async function GET(
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (companyError || !company) {
+5 -2
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* DELETE /api/invoices/[id]
@@ -21,12 +22,14 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice to verify ownership and status
const { data: invoice, error: fetchError } = await supabase
.from('invoices')
.select('id, status, user_id')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchError || !invoice) {
@@ -54,7 +57,7 @@ export async function DELETE(
.from('invoices')
.delete()
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
@@ -19,6 +19,11 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockRenderToBuffer = vi.fn()
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
@@ -184,6 +189,7 @@ describe('POST /api/invoices/[id]/send', () => {
)
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
'enskild_firma'
+9 -5
View File
@@ -12,6 +12,7 @@ import {
} from '@/lib/email/invoice-templates'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
ensureInitialized()
@@ -29,6 +30,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Check if email is configured
const emailService = getEmailService()
if (!emailService.isConfigured()) {
@@ -47,7 +50,7 @@ export async function POST(
items:invoice_items(*)
`)
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
@@ -67,7 +70,7 @@ export async function POST(
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (companyError || !company) {
@@ -161,7 +164,7 @@ export async function POST(
.from('invoices')
.update({ status: 'sent' })
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (updateError) {
console.error('Failed to update invoice status:', updateError)
@@ -175,6 +178,7 @@ export async function POST(
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
(company as CompanySettings).entity_type
@@ -196,7 +200,7 @@ export async function POST(
if (isRealInvoice) {
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, user.id, {
await uploadDocument(supabase, user.id, companyId, {
name: filename,
buffer: pdfArrayBuffer,
type: 'application/pdf',
@@ -212,7 +216,7 @@ export async function POST(
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: invoice as Invoice, userId: user.id },
payload: { invoice: invoice as Invoice, companyId, userId: user.id },
})
return NextResponse.json({
+5
View File
@@ -17,6 +17,11 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockGetVatRules = vi.fn()
const mockCalculateVat = vi.fn()
const mockGetAvailableVatRates = vi.fn()
+5 -2
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { getVatRules } from '@/lib/invoices/vat-rules'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
/**
@@ -20,6 +21,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { customer_id, invoice_date, due_date, currency, items, your_reference, our_reference, notes, document_type } = body
@@ -32,7 +35,7 @@ export async function POST(request: Request) {
.from('customers')
.select('*')
.eq('id', customer_id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (customerError || !customer) {
@@ -43,7 +46,7 @@ export async function POST(request: Request) {
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (companyError || !company) {
+16 -7
View File
@@ -9,6 +9,7 @@ import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import {
createCreditNoteJournalEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -21,6 +22,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const limit = parseInt(searchParams.get('limit') || '50')
@@ -29,7 +32,7 @@ export async function GET(request: Request) {
let query = supabase
.from('invoices')
.select('*, customer:customers(*)', { count: 'exact' })
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('invoice_date', { ascending: false })
.range(offset, offset + limit - 1)
@@ -55,6 +58,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
let rawBody: unknown
try {
rawBody = await request.json()
@@ -78,7 +83,7 @@ export async function POST(request: Request) {
{ status: 400 },
)
}
return createCreditNote(supabase, user.id, parsed.data)
return createCreditNote(supabase, companyId, user.id, parsed.data)
}
const parsed = CreateInvoiceSchema.safeParse(rawBody)
@@ -100,7 +105,7 @@ export async function POST(request: Request) {
.from('customers')
.select('*')
.eq('id', invoiceInput.customer_id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (customerError || !customer) {
@@ -178,6 +183,7 @@ export async function POST(request: Request) {
.from('invoices')
.insert({
user_id: user.id,
company_id: companyId,
customer_id: invoiceInput.customer_id,
invoice_number: invoiceNumber,
invoice_date: invoiceInput.invoice_date,
@@ -246,7 +252,7 @@ export async function POST(request: Request) {
if (completeInvoice && documentType === 'invoice') {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId: user.id },
payload: { invoice: completeInvoice as Invoice, companyId, userId: user.id },
})
}
@@ -256,6 +262,7 @@ export async function POST(request: Request) {
// Create a credit note for an existing invoice
async function createCreditNote(
supabase: Awaited<ReturnType<typeof createClient>>,
companyId: string,
userId: string,
input: { credited_invoice_id: string; reason?: string }
) {
@@ -264,7 +271,7 @@ async function createCreditNote(
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('id', input.credited_invoice_id)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (originalError || !originalInvoice) {
@@ -300,6 +307,7 @@ async function createCreditNote(
.from('invoices')
.insert({
user_id: userId,
company_id: companyId,
customer_id: originalInvoice.customer_id,
invoice_number: creditNoteNumber,
invoice_date: new Date().toISOString().split('T')[0],
@@ -373,7 +381,7 @@ async function createCreditNote(
const { data: creditNoteSettings } = await supabase
.from('company_settings')
.select('entity_type, accounting_method')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
@@ -385,6 +393,7 @@ async function createCreditNote(
try {
const journalEntry = await createCreditNoteJournalEntry(
supabase,
companyId,
userId,
completeCreditNote as Invoice,
entityType,
@@ -402,7 +411,7 @@ async function createCreditNote(
await eventBus.emit({
type: 'credit_note.created',
payload: { creditNote: completeCreditNote as CreditNote, userId },
payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
})
}
+7 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import { requireCompanyId } from '@/lib/company/context'
import type { KPIPreferences } from '@/types'
const EXTENSION_ID = 'core/kpi'
@@ -11,10 +12,12 @@ export async function GET() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', EXTENSION_ID)
.eq('key', KEY)
.single()
@@ -28,6 +31,8 @@ export async function PUT(request: Request) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
let body: unknown
try {
body = await request.json()
@@ -64,6 +69,7 @@ export async function PUT(request: Request) {
.upsert(
{
user_id: user.id,
company_id: companyId,
extension_id: EXTENSION_ID,
key: KEY,
value: merged,
+6 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createAuthCode } from '@/lib/auth/oauth-codes'
import { requireCompanyId } from '@/lib/company/context'
/**
* OAuth 2.0 Authorization Endpoint.
@@ -88,11 +89,13 @@ export async function GET(request: Request) {
return buildLoginRedirect(request)
}
const companyId = await requireCompanyId(supabase, user.id)
// Get company name for the consent page
const { data: settings } = await supabase
.from('company_settings')
.select('company_name')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
const companyName = settings?.company_name || user.email
@@ -180,6 +183,8 @@ export async function POST(request: Request) {
return buildLoginRedirect(request)
}
await requireCompanyId(supabase, user.id)
// Parse form body
const formData = await request.formData()
const consent = formData.get('consent')
+5
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { decryptAuthCode, verifyPkce, hashAuthCode } from '@/lib/auth/oauth-codes'
import { generateApiKey, createServiceClientNoCookies, ALL_SCOPES } from '@/lib/auth/api-keys'
import { requireCompanyId } from '@/lib/company/context'
/**
* OAuth 2.0 Token Endpoint.
@@ -100,6 +101,9 @@ export async function POST(request: Request) {
.lt('created_at', new Date(Date.now() - 10 * 60 * 1000).toISOString())
.then(() => {})
// Resolve company context for the user
const companyId = await requireCompanyId(supabase, payload.userId)
// Create the API key now (after PKCE verification — prevents orphaned keys)
const { key, hash, prefix } = generateApiKey()
@@ -107,6 +111,7 @@ export async function POST(request: Request) {
.from('api_keys')
.insert({
user_id: payload.userId,
company_id: companyId,
key_hash: hash,
key_prefix: prefix,
name: 'MCP-klient (OAuth)',
@@ -15,6 +15,11 @@ vi.mock('@/lib/supabase/server', () => ({
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
// Mock the counterparty templates (non-critical side effect)
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
+55 -39
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
@@ -48,13 +49,14 @@ ensureInitialized()
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
date: string,
fiscalYearStartMonth: number = 1
): Promise<boolean> {
const { data: existing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.eq('company_id', companyId)
.lte('period_start', date)
.gte('period_end', date)
.eq('is_closed', false)
@@ -91,6 +93,7 @@ async function ensureFiscalPeriod(
.from('fiscal_periods')
.upsert({
user_id: userId,
company_id: companyId,
name: periodName,
period_start: periodStart,
period_end: periodEnd,
@@ -108,6 +111,7 @@ async function ensureFiscalPeriod(
async function commitCategorizeTransaction(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const txId = params.transaction_id as string
@@ -119,7 +123,7 @@ async function commitCategorizeTransaction(
.from('transactions')
.select('*')
.eq('id', txId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (fetchError || !transaction) {
@@ -136,7 +140,7 @@ async function commitCategorizeTransaction(
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, fiscal_year_start_month')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
@@ -156,13 +160,13 @@ async function commitCategorizeTransaction(
}
// Ensure fiscal period exists
await ensureFiscalPeriod(supabase, userId, transaction.date, fiscalYearStartMonth)
await ensureFiscalPeriod(supabase, userId, companyId, transaction.date, fiscalYearStartMonth)
// Create journal entry
let journalEntryId: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
supabase, userId, transaction as Transaction, mappingResult
supabase, companyId, userId, transaction as Transaction, mappingResult
)
if (journalEntry) {
journalEntryId = journalEntry.id
@@ -202,6 +206,7 @@ async function commitCategorizeTransaction(
account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId,
companyId,
},
})
@@ -211,12 +216,14 @@ async function commitCategorizeTransaction(
async function commitCreateCustomer(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const { data, error } = await supabase
.from('customers')
.insert({
user_id: userId,
company_id: companyId,
name: params.name as string,
customer_type: params.customer_type as string,
email: (params.email as string) || null,
@@ -247,7 +254,7 @@ async function commitCreateCustomer(
vat_number_validated_at: new Date().toISOString(),
})
.eq('id', data.id)
.eq('user_id', userId)
.eq('company_id', companyId)
}
} catch (err) {
log.warn('Auto-VIES validation failed:', err)
@@ -256,7 +263,7 @@ async function commitCreateCustomer(
await eventBus.emit({
type: 'customer.created',
payload: { customer: data as Customer, userId },
payload: { customer: data as Customer, userId, companyId },
})
return { data: { customer_id: data.id } }
@@ -265,6 +272,7 @@ async function commitCreateCustomer(
async function commitCreateInvoice(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const customerId = params.customer_id as string
@@ -281,7 +289,7 @@ async function commitCreateInvoice(
.from('customers')
.select('*')
.eq('id', customerId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (customerError || !customer) {
@@ -340,6 +348,7 @@ async function commitCreateInvoice(
.from('invoices')
.insert({
user_id: userId,
company_id: companyId,
customer_id: customerId,
invoice_number: invoiceNumber,
invoice_date: (params.invoice_date as string) || new Date().toISOString().split('T')[0],
@@ -406,7 +415,7 @@ async function commitCreateInvoice(
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId },
payload: { invoice: completeInvoice as Invoice, userId, companyId },
})
}
@@ -416,6 +425,7 @@ async function commitCreateInvoice(
async function commitMarkInvoicePaid(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const invoiceId = params.invoice_id as string
@@ -425,7 +435,7 @@ async function commitMarkInvoicePaid(
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
@@ -436,7 +446,7 @@ async function commitMarkInvoicePaid(
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
@@ -447,12 +457,12 @@ async function commitMarkInvoicePaid(
if (isRealInvoice) {
if (accountingMethod === 'accrual') {
const je = await createInvoicePaymentJournalEntry(
supabase, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
supabase, companyId, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
)
journalEntryId = je?.id ?? null
} else {
const je = await createInvoiceCashEntry(
supabase, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
supabase, companyId, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
)
journalEntryId = je?.id ?? null
}
@@ -463,7 +473,7 @@ async function commitMarkInvoicePaid(
.from('invoices')
.update({ status: 'paid', paid_at: now, paid_amount: invoice.total })
.eq('id', invoiceId)
.eq('user_id', userId)
.eq('company_id', companyId)
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
@@ -473,6 +483,7 @@ async function commitMarkInvoicePaid(
async function commitSendInvoice(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>,
userEmail?: string
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
@@ -487,7 +498,7 @@ async function commitSendInvoice(
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
@@ -501,7 +512,7 @@ async function commitSendInvoice(
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (companyError || !company) return { error: 'Company settings missing', status: 500 }
@@ -554,14 +565,14 @@ async function commitSendInvoice(
if (!result.success) return { error: `Failed to send email: ${result.error}`, status: 500 }
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('user_id', userId)
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('company_id', companyId)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let createdJournalEntryId: string | undefined
if (isRealInvoice && (company.accounting_method === 'accrual' || !company.accounting_method)) {
try {
const je = await createInvoiceJournalEntry(
supabase, userId, invoice as Invoice, (company as CompanySettings).entity_type
supabase, companyId, userId, invoice as Invoice, (company as CompanySettings).entity_type
)
if (je) {
createdJournalEntryId = je.id
@@ -573,7 +584,7 @@ async function commitSendInvoice(
if (isRealInvoice) {
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, userId, {
await uploadDocument(supabase, userId, companyId, {
name: filename,
buffer: pdfArrayBuffer,
type: 'application/pdf',
@@ -584,7 +595,7 @@ async function commitSendInvoice(
} catch { /* non-blocking */ }
}
await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId } })
await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId, companyId } })
return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } }
}
@@ -592,6 +603,7 @@ async function commitSendInvoice(
async function commitMarkInvoiceSent(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const invoiceId = params.invoice_id as string
@@ -600,7 +612,7 @@ async function commitMarkInvoiceSent(
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
@@ -610,14 +622,14 @@ async function commitMarkInvoiceSent(
.from('invoices')
.update({ status: 'sent' })
.eq('id', invoiceId)
.eq('user_id', userId)
.eq('company_id', companyId)
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
@@ -626,7 +638,7 @@ async function commitMarkInvoiceSent(
if (isRealInvoice && (settings?.accounting_method === 'accrual' || !settings?.accounting_method)) {
try {
const je = await createInvoiceJournalEntry(
supabase, userId, invoice as Invoice,
supabase, companyId, userId, invoice as Invoice,
(settings?.entity_type as EntityType) || 'enskild_firma',
invoice.customer?.name
)
@@ -643,6 +655,7 @@ async function commitMarkInvoiceSent(
async function commitMatchTransactionInvoice(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const transactionId = params.transaction_id as string
@@ -652,7 +665,7 @@ async function commitMatchTransactionInvoice(
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (txError || !transaction) return { error: 'Transaction not found', status: 404 }
@@ -663,7 +676,7 @@ async function commitMatchTransactionInvoice(
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (invError || !invoice) return { error: 'Invoice not found', status: 404 }
@@ -673,7 +686,7 @@ async function commitMatchTransactionInvoice(
// Storno conflicting journal entry
if (transaction.journal_entry_id) {
await reverseEntry(supabase, userId, transaction.journal_entry_id)
await reverseEntry(supabase, companyId, userId, transaction.journal_entry_id)
await supabase.from('transactions').update({ journal_entry_id: null }).eq('id', transactionId)
}
@@ -688,7 +701,7 @@ async function commitMatchTransactionInvoice(
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
@@ -698,12 +711,12 @@ async function commitMatchTransactionInvoice(
try {
if (accountingMethod === 'cash' && isFullyPaid) {
const je = await createInvoiceCashEntry(
supabase, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
supabase, companyId, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
)
journalEntryId = je?.id ?? null
} else {
const je = await createInvoicePaymentJournalEntry(
supabase, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount
supabase, companyId, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount
)
journalEntryId = je?.id ?? null
}
@@ -733,6 +746,7 @@ async function commitMatchTransactionInvoice(
await supabase.from('invoice_payments').insert({
user_id: userId,
company_id: companyId,
invoice_id: invoiceId,
payment_date: transaction.date,
amount: paidAmount,
@@ -757,7 +771,7 @@ async function commitMatchTransactionInvoice(
try {
await eventBus.emit({
type: 'invoice.match_confirmed',
payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId },
payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId, companyId },
})
} catch { /* non-critical */ }
@@ -778,12 +792,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch the pending operation
const { data: op, error: fetchError } = await supabase
.from('pending_operations')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchError || !op) {
@@ -804,25 +820,25 @@ export async function POST(
switch (pendingOp.operation_type) {
case 'categorize_transaction':
result = await commitCategorizeTransaction(supabase, user.id, pendingOp.params)
result = await commitCategorizeTransaction(supabase, user.id, companyId, pendingOp.params)
break
case 'create_customer':
result = await commitCreateCustomer(supabase, user.id, pendingOp.params)
result = await commitCreateCustomer(supabase, user.id, companyId, pendingOp.params)
break
case 'create_invoice':
result = await commitCreateInvoice(supabase, user.id, pendingOp.params)
result = await commitCreateInvoice(supabase, user.id, companyId, pendingOp.params)
break
case 'mark_invoice_paid':
result = await commitMarkInvoicePaid(supabase, user.id, pendingOp.params)
result = await commitMarkInvoicePaid(supabase, user.id, companyId, pendingOp.params)
break
case 'send_invoice':
result = await commitSendInvoice(supabase, user.id, pendingOp.params, user.email)
result = await commitSendInvoice(supabase, user.id, companyId, pendingOp.params, user.email)
break
case 'mark_invoice_sent':
result = await commitMarkInvoiceSent(supabase, user.id, pendingOp.params)
result = await commitMarkInvoiceSent(supabase, user.id, companyId, pendingOp.params)
break
case 'match_transaction_invoice':
result = await commitMatchTransactionInvoice(supabase, user.id, pendingOp.params)
result = await commitMatchTransactionInvoice(supabase, user.id, companyId, pendingOp.params)
break
default:
return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 })
@@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { POST } from '../../reject/route'
describe('POST /api/pending-operations/:id/reject', () => {
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* POST /api/pending-operations/:id/reject
@@ -18,11 +19,13 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data: op, error: fetchError } = await supabase
.from('pending_operations')
.select('id, status')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchError || !op) {
@@ -10,6 +10,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route'
describe('GET /api/pending-operations', () => {
+4 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateQuery } from '@/lib/api/validate'
import { PendingOperationsQuerySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/pending-operations
@@ -16,6 +17,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const result = validateQuery(request, PendingOperationsQuerySchema)
if (!result.success) return result.response
const { status, limit, offset } = result.data
@@ -23,7 +26,7 @@ export async function GET(request: Request) {
const { data, error, count } = await supabase
.from('pending_operations')
.select('*', { count: 'exact' })
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('status', status)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
+4 -1
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate'
import { BankLinkSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -15,11 +16,13 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, BankLinkSchema)
if (!validation.success) return validation.response
const { transaction_id, journal_entry_id } = validation.data
const result = await manualLink(supabase, user.id, transaction_id, journal_entry_id)
const result = await manualLink(supabase, companyId, transaction_id, journal_entry_id)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
+4 -1
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate'
import { RunReconciliationSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -15,11 +16,13 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, RunReconciliationSchema)
if (!validation.success) return validation.response
const { date_from, date_to, dry_run } = validation.data
const result = await runReconciliation(supabase, user.id, {
const result = await runReconciliation(supabase, companyId, {
dateFrom: date_from,
dateTo: date_to,
dryRun: dry_run ?? false,
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -10,11 +11,13 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const status = await getReconciliationStatus(supabase, user.id, dateFrom, dateTo)
const status = await getReconciliationStatus(supabase, companyId, dateFrom, dateTo)
return NextResponse.json({ data: status })
}
+4 -1
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate'
import { BankUnlinkSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function POST(request: Request) {
const supabase = await createClient()
@@ -12,11 +13,13 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, BankUnlinkSchema)
if (!validation.success) return validation.response
const { transaction_id } = validation.data
const result = await unlinkReconciliation(supabase, user.id, transaction_id)
const result = await unlinkReconciliation(supabase, companyId, transaction_id)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -10,11 +11,13 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const lines = await fetchUnlinkedGLLines(supabase, user.id, dateFrom, dateTo)
const lines = await fetchUnlinkedGLLines(supabase, companyId, dateFrom, dateTo)
return NextResponse.json({ data: lines })
}
+5 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -12,15 +13,17 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const asOfDate = searchParams.get('as_of_date') || undefined
const periodId = searchParams.get('period_id') || undefined
const ledger = await generateARLedger(supabase, user.id, asOfDate)
const ledger = await generateARLedger(supabase, companyId, asOfDate)
let reconciliation = null
if (periodId) {
reconciliation = await generateARReconciliation(supabase, user.id, periodId)
reconciliation = await generateARReconciliation(supabase, companyId, periodId)
}
return NextResponse.json({
@@ -9,6 +9,11 @@ vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { createClient } from '@/lib/supabase/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { GET } from '../route'

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