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:
@@ -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/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 1–4 (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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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 }) },
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user