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:
co-authored by
Claude Opus 4.6
parent
1756af6c5e
commit
0dd1f5ebc1
@@ -55,3 +55,4 @@ supabase/.temp/
|
|||||||
# Run `npm run setup:extensions` to regenerate after changing extensions.config.json
|
# Run `npm run setup:extensions` to regenerate after changing extensions.config.json
|
||||||
# The empty defaults in lib/extensions/_generated/ are committed so core compiles
|
# The empty defaults in lib/extensions/_generated/ are committed so core compiles
|
||||||
# out of the box without running the generator.
|
# out of the box without running the generator.
|
||||||
|
supabase/.branches/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createServerClient } from '@supabase/ssr'
|
import { createServerClient } from '@supabase/ssr'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { hashInviteToken } from '@/lib/auth/invite-tokens'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams, origin } = new URL(request.url)
|
const { searchParams, origin } = new URL(request.url)
|
||||||
@@ -22,7 +23,13 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
setAll(cookiesToSet) {
|
setAll(cookiesToSet) {
|
||||||
pendingCookies.length = 0
|
pendingCookies.length = 0
|
||||||
cookiesToSet.forEach((cookie) => pendingCookies.push(cookie))
|
cookiesToSet.forEach((cookie) => {
|
||||||
|
// Mirror the cookie into request.cookies so subsequent getAll()
|
||||||
|
// calls within this request lifecycle return the updated values
|
||||||
|
// (matches the pattern used in middleware.ts).
|
||||||
|
request.cookies.set(cookie.name, cookie.value)
|
||||||
|
pendingCookies.push(cookie)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -59,14 +66,95 @@ export async function GET(request: NextRequest) {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has completed onboarding
|
// Check for pending invite token (set by invite page before redirecting to register)
|
||||||
const { data: settings } = await supabase
|
const inviteToken = request.cookies.get('gnubok-invite-token')?.value
|
||||||
.from('company_settings')
|
if (inviteToken) {
|
||||||
.select('onboarding_complete')
|
try {
|
||||||
|
const tokenHash = hashInviteToken(inviteToken)
|
||||||
|
|
||||||
|
// Use the service role client to bypass RLS for invite acceptance
|
||||||
|
const serviceClient = createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||||
|
{ cookies: { getAll: () => [], setAll: () => {} } }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Look up company invitation
|
||||||
|
const { data: invite } = await serviceClient
|
||||||
|
.from('company_invitations')
|
||||||
|
.select('id, company_id, email, role, status, expires_at')
|
||||||
|
.eq('token_hash', tokenHash)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (
|
||||||
|
invite &&
|
||||||
|
invite.status === 'pending' &&
|
||||||
|
new Date(invite.expires_at) > new Date() &&
|
||||||
|
user.email?.toLowerCase() === invite.email.toLowerCase()
|
||||||
|
) {
|
||||||
|
// Add user to company
|
||||||
|
await serviceClient.from('company_members').insert({
|
||||||
|
company_id: invite.company_id,
|
||||||
|
user_id: user.id,
|
||||||
|
role: invite.role,
|
||||||
|
source: 'direct',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set active company
|
||||||
|
await serviceClient.from('user_preferences').upsert({
|
||||||
|
user_id: user.id,
|
||||||
|
active_company_id: invite.company_id,
|
||||||
|
}, { onConflict: 'user_id' })
|
||||||
|
|
||||||
|
// Mark invite as accepted
|
||||||
|
await serviceClient
|
||||||
|
.from('company_invitations')
|
||||||
|
.update({ status: 'accepted' })
|
||||||
|
.eq('id', invite.id)
|
||||||
|
|
||||||
|
// Invited user goes straight to dashboard — no onboarding needed
|
||||||
|
redirectPath = '/'
|
||||||
|
|
||||||
|
// Clear invite cookie and set company cookie on response
|
||||||
|
const response = NextResponse.redirect(new URL(redirectPath, origin))
|
||||||
|
for (const { name, value, options } of pendingCookies) {
|
||||||
|
response.cookies.set({ name, value, ...options })
|
||||||
|
}
|
||||||
|
response.cookies.set('gnubok-company-id', invite.company_id, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
})
|
||||||
|
response.cookies.delete('gnubok-invite-token')
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[auth/callback] invite acceptance failed:', err)
|
||||||
|
// Fall through to normal onboarding check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has completed onboarding (for any company they belong to)
|
||||||
|
const { data: membership } = await supabase
|
||||||
|
.from('company_members')
|
||||||
|
.select('company_id')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
|
.limit(1)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (!settings?.onboarding_complete) {
|
if (membership?.company_id) {
|
||||||
|
const { data: settings } = await supabase
|
||||||
|
.from('company_settings')
|
||||||
|
.select('onboarding_complete')
|
||||||
|
.eq('company_id', membership.company_id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!settings?.onboarding_complete) {
|
||||||
|
redirectPath = '/onboarding'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
redirectPath = '/onboarding'
|
redirectPath = '/onboarding'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +164,8 @@ export async function GET(request: NextRequest) {
|
|||||||
for (const { name, value, options } of pendingCookies) {
|
for (const { name, value, options } of pendingCookies) {
|
||||||
response.cookies.set({ name, value, ...options })
|
response.cookies.set({ name, value, ...options })
|
||||||
}
|
}
|
||||||
|
// Keep the invite cookie alive so the onboarding page fallback can
|
||||||
|
// retry acceptance (only clear it when successfully processed above).
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, Suspense } from 'react'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { createClient } from '@/lib/supabase/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -12,14 +13,45 @@ import Image from 'next/image'
|
|||||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<RegisterPageContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegisterPageContent() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isRegistered, setIsRegistered] = useState(false)
|
const [isRegistered, setIsRegistered] = useState(false)
|
||||||
|
const [inviteEmail, setInviteEmail] = useState<string | null>(null)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
|
|
||||||
|
// When arriving from an invite link, fetch the invite info to pre-fill
|
||||||
|
// and lock the email field so the user registers with the correct address.
|
||||||
|
useEffect(() => {
|
||||||
|
const inviteToken = searchParams.get('invite')
|
||||||
|
if (!inviteToken) return
|
||||||
|
|
||||||
|
fetch(`/api/team/accept?token=${encodeURIComponent(inviteToken)}`)
|
||||||
|
.then((res) => res.ok ? res.json() : null)
|
||||||
|
.then((data) => {
|
||||||
|
if (data?.data?.email) {
|
||||||
|
setInviteEmail(data.data.email)
|
||||||
|
setEmail(data.data.email)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
function isStrongPassword(pw: string): boolean {
|
function isStrongPassword(pw: string): boolean {
|
||||||
return pw.length >= 8
|
return pw.length >= 8
|
||||||
&& /[a-z]/.test(pw)
|
&& /[a-z]/.test(pw)
|
||||||
@@ -102,6 +134,43 @@ export default function RegisterPage() {
|
|||||||
provider: data.user?.app_metadata?.provider,
|
provider: data.user?.app_metadata?.provider,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// If auto-confirmed (local dev), process invite immediately and redirect
|
||||||
|
if (data.session) {
|
||||||
|
const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
|
||||||
|
const inviteToken = cookieMatch?.[1]
|
||||||
|
|
||||||
|
if (inviteToken) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/team/accept', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: inviteToken }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
|
||||||
|
console.log('[register] invite accepted after auto-confirm — redirecting')
|
||||||
|
window.location.href = '/'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the error response so we can diagnose invite failures
|
||||||
|
const errBody = await res.json().catch(() => ({}))
|
||||||
|
console.error('[register] invite acceptance returned non-ok', {
|
||||||
|
status: res.status,
|
||||||
|
error: errBody.error,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[register] invite acceptance failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-confirmed but no invite or invite failed — go to onboarding
|
||||||
|
// (invite cookie is preserved so the onboarding fallback can retry)
|
||||||
|
window.location.href = '/'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setEmail(emailValue)
|
setEmail(emailValue)
|
||||||
setIsRegistered(true)
|
setIsRegistered(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -188,9 +257,15 @@ export default function RegisterPage() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading || !!inviteEmail}
|
||||||
|
readOnly={!!inviteEmail}
|
||||||
className="h-11"
|
className="h-11"
|
||||||
/>
|
/>
|
||||||
|
{inviteEmail && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Inbjudan skickades till denna adress.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Lösenord</Label>
|
<Label htmlFor="password">Lösenord</Label>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function DeadlinesPage() {
|
|||||||
}, [fetchData])
|
}, [fetchData])
|
||||||
|
|
||||||
const handleDeadlineCreate = async (
|
const handleDeadlineCreate = async (
|
||||||
data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>
|
data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/deadlines', {
|
const response = await fetch('/api/deadlines', {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useToast } from '@/components/ui/use-toast'
|
|||||||
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight } from 'lucide-react'
|
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
import { useCompany } from '@/contexts/CompanyContext'
|
||||||
import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector'
|
import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector'
|
||||||
import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus'
|
import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus'
|
||||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||||
@@ -566,6 +567,7 @@ function PSD2ConnectWizard() {
|
|||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||||
|
const { company } = useCompany()
|
||||||
|
|
||||||
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
|
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
|
||||||
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
|
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
|
||||||
@@ -582,10 +584,12 @@ function PSD2ConnectWizard() {
|
|||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
|
if (!company) return
|
||||||
|
|
||||||
const { data: connections } = await supabase
|
const { data: connections } = await supabase
|
||||||
.from('bank_connections')
|
.from('bank_connections')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', company.id)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
setBankConnections(connections || [])
|
setBankConnections(connections || [])
|
||||||
@@ -750,6 +754,7 @@ function PSD2ConnectWizard() {
|
|||||||
type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'migration'
|
type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'migration'
|
||||||
|
|
||||||
export default function ImportPage() {
|
export default function ImportPage() {
|
||||||
|
const { company } = useCompany()
|
||||||
const [mode, setMode] = useState<ImportMode>(null)
|
const [mode, setMode] = useState<ImportMode>(null)
|
||||||
const [userId, setUserId] = useState('')
|
const [userId, setUserId] = useState('')
|
||||||
const [isSandbox, setIsSandbox] = useState(false)
|
const [isSandbox, setIsSandbox] = useState(false)
|
||||||
@@ -760,10 +765,11 @@ export default function ImportPage() {
|
|||||||
supabase.auth.getUser().then(({ data: { user } }) => {
|
supabase.auth.getUser().then(({ data: { user } }) => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
setUserId(user.id)
|
setUserId(user.id)
|
||||||
|
if (!company) return
|
||||||
supabase
|
supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('is_sandbox')
|
.select('is_sandbox')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', company.id)
|
||||||
.single()
|
.single()
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
if (data?.is_sandbox) setIsSandbox(true)
|
if (data?.is_sandbox) setIsSandbox(true)
|
||||||
|
|||||||
+175
-34
@@ -1,11 +1,14 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
import DashboardNav from '@/components/dashboard/DashboardNav'
|
import DashboardNav from '@/components/dashboard/DashboardNav'
|
||||||
import { RecaptIdentify } from '@/components/RecaptIdentify'
|
import { RecaptIdentify } from '@/components/RecaptIdentify'
|
||||||
import { SentryIdentify } from '@/components/SentryIdentify'
|
import { SentryIdentify } from '@/components/SentryIdentify'
|
||||||
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
|
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
|
||||||
import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
||||||
import type { EntityType } from '@/types'
|
import { CompanyProvider } from '@/contexts/CompanyContext'
|
||||||
|
import { getActiveCompanyId } from '@/lib/company/context'
|
||||||
|
import type { EntityType, CompanyRole, Team } from '@/types'
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -20,21 +23,138 @@ export default async function DashboardLayout({
|
|||||||
redirect('/login')
|
redirect('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const companyId = cookieStore.get('gnubok-company-id')?.value
|
||||||
|
?? await getActiveCompanyId(supabase, user.id)
|
||||||
|
|
||||||
|
// Fetch team membership + team info
|
||||||
|
const { data: teamMembership } = await supabase
|
||||||
|
.from('team_members')
|
||||||
|
.select('team_id, role')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
let team: Team | null = null
|
||||||
|
if (teamMembership?.team_id) {
|
||||||
|
const { data: teamRow } = await supabase
|
||||||
|
.from('teams')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', teamMembership.team_id)
|
||||||
|
.single()
|
||||||
|
team = teamRow
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTeamMember = !!teamMembership
|
||||||
|
|
||||||
|
// Consultant with team but no companies — show dashboard with empty state
|
||||||
|
if (!companyId) {
|
||||||
|
if (isTeamMember) {
|
||||||
|
const companyContextValue = {
|
||||||
|
company: null,
|
||||||
|
role: null,
|
||||||
|
companies: [],
|
||||||
|
isTeamMember: true,
|
||||||
|
team,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CompanyProvider value={companyContextValue}>
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
|
||||||
|
>
|
||||||
|
Hoppa till innehåll
|
||||||
|
</a>
|
||||||
|
<DashboardNav
|
||||||
|
companyName={team?.name || 'Mitt team'}
|
||||||
|
entityType="enskild_firma"
|
||||||
|
uncategorizedTransactionCount={0}
|
||||||
|
pendingOperationsCount={0}
|
||||||
|
isSandbox={false}
|
||||||
|
extensionNavItems={getExtensionNavItems()}
|
||||||
|
/>
|
||||||
|
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
|
||||||
|
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<SentryIdentify userId={user.id} email={user.email} />
|
||||||
|
</div>
|
||||||
|
</CompanyProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect('/onboarding')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch company + membership for context provider
|
||||||
|
const [
|
||||||
|
{ data: companyRow },
|
||||||
|
{ data: memberRow },
|
||||||
|
{ data: allMemberships },
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from('companies').select('*').eq('id', companyId).single(),
|
||||||
|
supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(),
|
||||||
|
supabase.from('company_members').select('company_id, role, companies:company_id(id, name, org_number, entity_type, created_by, team_id, archived_at, created_at, updated_at)').eq('user_id', user.id),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!companyRow || !memberRow) {
|
||||||
|
// Stale cookie pointing to a deleted/inaccessible company.
|
||||||
|
// If the user is a team member, render the empty-state dashboard
|
||||||
|
// instead of redirecting to onboarding (which would cause a loop).
|
||||||
|
if (isTeamMember) {
|
||||||
|
const companyContextValue = {
|
||||||
|
company: null,
|
||||||
|
role: null,
|
||||||
|
companies: (allMemberships || []).filter(m => m.companies).map((m) => ({
|
||||||
|
company: m.companies as unknown as import('@/types').Company,
|
||||||
|
role: m.role as CompanyRole,
|
||||||
|
})),
|
||||||
|
isTeamMember: true,
|
||||||
|
team,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CompanyProvider value={companyContextValue}>
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<DashboardNav
|
||||||
|
companyName={team?.name || 'Mitt team'}
|
||||||
|
entityType="enskild_firma"
|
||||||
|
uncategorizedTransactionCount={0}
|
||||||
|
pendingOperationsCount={0}
|
||||||
|
isSandbox={false}
|
||||||
|
extensionNavItems={getExtensionNavItems()}
|
||||||
|
/>
|
||||||
|
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
|
||||||
|
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<SentryIdentify userId={user.id} email={user.email} />
|
||||||
|
</div>
|
||||||
|
</CompanyProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
redirect('/onboarding')
|
||||||
|
}
|
||||||
|
|
||||||
const [{ data: settings }, { count: uncategorizedCount }, { count: pendingOpsCount }] = await Promise.all([
|
const [{ data: settings }, { count: uncategorizedCount }, { count: pendingOpsCount }] = await Promise.all([
|
||||||
supabase
|
supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('company_name, onboarding_complete, entity_type, is_sandbox')
|
.select('company_name, onboarding_complete, entity_type, is_sandbox')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single(),
|
.single(),
|
||||||
supabase
|
supabase
|
||||||
.from('transactions')
|
.from('transactions')
|
||||||
.select('*', { count: 'exact', head: true })
|
.select('*', { count: 'exact', head: true })
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.is('is_business', null),
|
.is('is_business', null),
|
||||||
supabase
|
supabase
|
||||||
.from('pending_operations')
|
.from('pending_operations')
|
||||||
.select('*', { count: 'exact', head: true })
|
.select('*', { count: 'exact', head: true })
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.eq('status', 'pending'),
|
.eq('status', 'pending'),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -42,41 +162,62 @@ export default async function DashboardLayout({
|
|||||||
redirect('/onboarding')
|
redirect('/onboarding')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use company_name from settings as the display name (companies.name may be stale)
|
||||||
|
const displayName = settings.company_name || companyRow.name
|
||||||
|
const companyWithName = { ...companyRow, name: displayName }
|
||||||
|
|
||||||
|
const companyContextValue = {
|
||||||
|
company: companyWithName,
|
||||||
|
role: memberRow.role as CompanyRole,
|
||||||
|
companies: (allMemberships || []).map((m) => {
|
||||||
|
const c = m.companies as unknown as import('@/types').Company
|
||||||
|
// Override active company's name with settings name
|
||||||
|
if (c.id === companyId) {
|
||||||
|
return { company: { ...c, name: displayName }, role: m.role as CompanyRole }
|
||||||
|
}
|
||||||
|
return { company: c, role: m.role as CompanyRole }
|
||||||
|
}),
|
||||||
|
isTeamMember,
|
||||||
|
team,
|
||||||
|
}
|
||||||
|
|
||||||
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
|
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
|
||||||
|
|
||||||
const isSandbox = settings.is_sandbox === true
|
const isSandbox = settings.is_sandbox === true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<CompanyProvider value={companyContextValue}>
|
||||||
{/* Skip to content link for keyboard/screen reader users */}
|
<div className="min-h-screen bg-background">
|
||||||
<a
|
{/* Skip to content link for keyboard/screen reader users */}
|
||||||
href="#main-content"
|
<a
|
||||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
|
href="#main-content"
|
||||||
>
|
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
|
||||||
Hoppa till innehåll
|
>
|
||||||
</a>
|
Hoppa till innehåll
|
||||||
{isSandbox && <SandboxBanner />}
|
</a>
|
||||||
<DashboardNav
|
{isSandbox && <SandboxBanner />}
|
||||||
companyName={settings.company_name || 'Min verksamhet'}
|
<DashboardNav
|
||||||
entityType={entityType}
|
companyName={settings.company_name || 'Min verksamhet'}
|
||||||
uncategorizedTransactionCount={uncategorizedCount ?? 0}
|
entityType={entityType}
|
||||||
pendingOperationsCount={pendingOpsCount ?? 0}
|
uncategorizedTransactionCount={uncategorizedCount ?? 0}
|
||||||
isSandbox={isSandbox}
|
pendingOperationsCount={pendingOpsCount ?? 0}
|
||||||
extensionNavItems={getExtensionNavItems()}
|
isSandbox={isSandbox}
|
||||||
/>
|
extensionNavItems={getExtensionNavItems()}
|
||||||
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
|
|
||||||
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<SentryIdentify userId={user.id} email={user.email} />
|
|
||||||
{!isSandbox && (
|
|
||||||
<RecaptIdentify
|
|
||||||
userId={user.id}
|
|
||||||
email={user.email}
|
|
||||||
displayName={settings.company_name || undefined}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
|
||||||
</div>
|
<div key={companyId} className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<SentryIdentify userId={user.id} email={user.email} />
|
||||||
|
{!isSandbox && (
|
||||||
|
<RecaptIdentify
|
||||||
|
userId={user.id}
|
||||||
|
email={user.email}
|
||||||
|
displayName={settings.company_name || undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CompanyProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-18
@@ -1,6 +1,9 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
import DashboardContent from '@/components/dashboard/DashboardContent'
|
import DashboardContent from '@/components/dashboard/DashboardContent'
|
||||||
|
import ConsultantEmptyState from '@/components/dashboard/ConsultantEmptyState'
|
||||||
|
import { getActiveCompanyId } from '@/lib/company/context'
|
||||||
import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -14,6 +17,44 @@ export default async function DashboardPage() {
|
|||||||
redirect('/login')
|
redirect('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const rawCompanyId = cookieStore.get('gnubok-company-id')?.value
|
||||||
|
?? await getActiveCompanyId(supabase, user.id)
|
||||||
|
|
||||||
|
// Validate the cookie/preference points to a company the user can access
|
||||||
|
let companyId = rawCompanyId
|
||||||
|
if (companyId) {
|
||||||
|
const { data: membership } = await supabase
|
||||||
|
.from('company_members')
|
||||||
|
.select('company_id')
|
||||||
|
.eq('company_id', companyId)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.maybeSingle()
|
||||||
|
if (!membership) companyId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
// Consultants (team members) see an empty state; solo users go to onboarding
|
||||||
|
const { data: teamMembership } = await supabase
|
||||||
|
.from('team_members')
|
||||||
|
.select('team_id')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (teamMembership) {
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('full_name')
|
||||||
|
.eq('id', user.id)
|
||||||
|
.single()
|
||||||
|
const firstName = profile?.full_name?.split(' ')[0] || null
|
||||||
|
return <ConsultantEmptyState firstName={firstName} />
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect('/onboarding')
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch current year date boundaries
|
// Fetch current year date boundaries
|
||||||
const startOfYearStr = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]
|
const startOfYearStr = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]
|
||||||
const startOfMonthStr = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0]
|
const startOfMonthStr = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0]
|
||||||
@@ -54,28 +95,29 @@ export default async function DashboardPage() {
|
|||||||
{ count: staleUncategorizedCount },
|
{ count: staleUncategorizedCount },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
|
supabase.from('profiles').select('full_name').eq('id', user.id).single(),
|
||||||
supabase.from('company_settings').select('*').eq('user_id', user.id).single(),
|
supabase.from('company_settings').select('*').eq('company_id', companyId).single(),
|
||||||
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
|
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||||
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
|
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||||
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
|
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||||
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id),
|
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||||
supabase.from('journal_entry_lines')
|
supabase.from('journal_entry_lines')
|
||||||
.select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status)')
|
.select('account_number, debit_amount, credit_amount, journal_entry:journal_entries!inner(entry_date, status, company_id)')
|
||||||
.eq('journal_entry.status', 'posted')
|
.eq('journal_entry.status', 'posted')
|
||||||
|
.eq('journal_entry.company_id', companyId)
|
||||||
.gte('journal_entry.entry_date', startOfYearStr),
|
.gte('journal_entry.entry_date', startOfYearStr),
|
||||||
supabase.from('transactions').select('amount, amount_sek, is_business').eq('user_id', user.id).gte('date', startOfYearStr),
|
supabase.from('transactions').select('amount, amount_sek, is_business').eq('company_id', companyId).gte('date', startOfYearStr),
|
||||||
supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status').eq('user_id', user.id).in('status', ['sent', 'overdue']),
|
supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status').eq('company_id', companyId).in('status', ['sent', 'overdue']),
|
||||||
supabase.from('bank_connections').select('id, accounts_data, status, consent_expires, bank_name').eq('user_id', user.id).eq('status', 'active'),
|
supabase.from('bank_connections').select('id, accounts_data, status, consent_expires, bank_name').eq('company_id', companyId).eq('status', 'active'),
|
||||||
supabase.from('deadlines').select('*, customer:customers(id, name)').eq('user_id', user.id).eq('is_completed', false)
|
supabase.from('deadlines').select('*, customer:customers(id, name)').eq('company_id', companyId).eq('is_completed', false)
|
||||||
.or(`due_date.lt.${today},due_date.lte.${nextWeek}`).order('due_date', { ascending: true }),
|
.or(`due_date.lt.${today},due_date.lte.${nextWeek}`).order('due_date', { ascending: true }),
|
||||||
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'extracted'),
|
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'extracted'),
|
||||||
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'confirmed').is('matched_transaction_id', null),
|
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'confirmed').is('matched_transaction_id', null),
|
||||||
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id).lt('amount', 0).is('receipt_id', null),
|
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).lt('amount', 0).is('receipt_id', null),
|
||||||
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'posted').in('source_type', needsDocSourceTypes),
|
supabase.from('journal_entries').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'posted').in('source_type', needsDocSourceTypes),
|
||||||
supabase.from('document_attachments').select('journal_entry_id').eq('user_id', user.id).eq('is_current_version', true).not('journal_entry_id', 'is', null),
|
supabase.from('document_attachments').select('journal_entry_id').eq('company_id', companyId).eq('is_current_version', true).not('journal_entry_id', 'is', null),
|
||||||
supabase.from('receipts').select('created_at').eq('user_id', user.id).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30),
|
supabase.from('receipts').select('created_at').eq('company_id', companyId).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30),
|
||||||
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'completed'),
|
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
|
||||||
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id).is('journal_entry_id', null).not('is_business', 'eq', false).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]),
|
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).not('is_business', 'eq', false).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]),
|
||||||
])
|
])
|
||||||
|
|
||||||
const firstName = profile?.full_name?.split(' ')[0] || null
|
const firstName = profile?.full_name?.split(' ')[0] || null
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
|
import { useCompany } from '@/contexts/CompanyContext'
|
||||||
import type { CompanySettings } from '@/types'
|
import type { CompanySettings } from '@/types'
|
||||||
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
|
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||||
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
|
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
|
||||||
@@ -41,6 +42,8 @@ import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
|||||||
import { SecuritySettings } from '@/components/settings/SecuritySettings'
|
import { SecuritySettings } from '@/components/settings/SecuritySettings'
|
||||||
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
|
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
|
||||||
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
|
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
|
||||||
|
import { TeamPanel } from '@/components/settings/TeamPanel'
|
||||||
|
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
|
||||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||||
|
|
||||||
const BankingPanel = getSettingsPanel('enable-banking')
|
const BankingPanel = getSettingsPanel('enable-banking')
|
||||||
@@ -50,6 +53,7 @@ export default function SettingsPage() {
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
|
const { company, isTeamMember } = useCompany()
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
@@ -67,15 +71,18 @@ export default function SettingsPage() {
|
|||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
const initialTab = searchParams.get('tab') || 'company'
|
const hasCompany = !!company
|
||||||
|
const defaultTab = hasCompany ? 'company' : (isTeamMember ? 'team' : 'account')
|
||||||
|
const initialTab = searchParams.get('tab') || defaultTab
|
||||||
const [activeTab, setActiveTab] = useState(initialTab)
|
const [activeTab, setActiveTab] = useState(initialTab)
|
||||||
|
|
||||||
const settingsTabs = [
|
const settingsTabs = [
|
||||||
{ value: 'company', label: 'Företag', show: true },
|
{ value: 'company', label: 'Företag', show: hasCompany },
|
||||||
{ value: 'banking', label: 'Bank (PSD2)', show: !settings?.is_sandbox && hasBankingExtension },
|
{ value: 'team', label: 'Lag', show: isTeamMember },
|
||||||
{ value: 'templates', label: 'Mallar', show: true },
|
{ value: 'banking', label: 'Bank (PSD2)', show: hasCompany && !settings?.is_sandbox && hasBankingExtension },
|
||||||
|
{ value: 'templates', label: 'Mallar', show: hasCompany },
|
||||||
{ value: 'account', label: 'Konto', show: true },
|
{ value: 'account', label: 'Konto', show: true },
|
||||||
{ value: 'api', label: 'API', show: hasMcpExtension },
|
{ value: 'api', label: 'API', show: hasCompany && hasMcpExtension },
|
||||||
].filter(t => t.show)
|
].filter(t => t.show)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,9 +98,10 @@ export default function SettingsPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsRes = await supabase.from('company_settings').select('*').eq('user_id', user.id).single()
|
if (company?.id) {
|
||||||
|
const settingsRes = await supabase.from('company_settings').select('*').eq('company_id', company.id).single()
|
||||||
setSettings(settingsRes.data)
|
setSettings(settingsRes.data)
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -625,6 +633,15 @@ export default function SettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<Separator className="my-8" />
|
||||||
|
|
||||||
|
<CompanyMembersSection />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Team management */}
|
||||||
|
<TabsContent value="team">
|
||||||
|
<TeamPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Banking settings — loaded dynamically from extension, hidden for sandbox */}
|
{/* Banking settings — loaded dynamically from extension, hidden for sandbox */}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from
|
|||||||
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||||
import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates'
|
import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates'
|
||||||
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
|
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
|
||||||
|
import { useCompany } from '@/contexts/CompanyContext'
|
||||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||||
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType, LinePatternEntry } from '@/types'
|
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType, LinePatternEntry } from '@/types'
|
||||||
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||||
@@ -42,6 +43,7 @@ interface QuickReviewState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TransactionsPage() {
|
export default function TransactionsPage() {
|
||||||
|
const { company } = useCompany()
|
||||||
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
|
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [mode, setMode] = useState<ViewMode>('inbox')
|
const [mode, setMode] = useState<ViewMode>('inbox')
|
||||||
@@ -484,6 +486,7 @@ export default function TransactionsPage() {
|
|||||||
const { data: transaction, error } = await supabase
|
const { data: transaction, error } = await supabase
|
||||||
.from('transactions')
|
.from('transactions')
|
||||||
.insert({
|
.insert({
|
||||||
|
company_id: company!.id,
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
date: data.date,
|
date: data.date,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
|
|||||||
@@ -6,17 +6,24 @@ import Image from 'next/image'
|
|||||||
import * as Sentry from '@sentry/nextjs'
|
import * as Sentry from '@sentry/nextjs'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { createClient } from '@/lib/supabase/client'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2, ArrowRight } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||||
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
|
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
|
||||||
|
|
||||||
|
import Step0RoleChoice from '@/components/onboarding/Step0RoleChoice'
|
||||||
import Step1EntityType from '@/components/onboarding/Step1EntityType'
|
import Step1EntityType from '@/components/onboarding/Step1EntityType'
|
||||||
import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
|
import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
|
||||||
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
|
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
|
||||||
import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting'
|
import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting'
|
||||||
|
|
||||||
|
type OnboardingMode = 'choice' | 'self' | 'consultant'
|
||||||
|
|
||||||
const STEP_INFO = [
|
const STEP_INFO = [
|
||||||
{ title: 'Välkommen', subtitle: 'Välj din företagsform för att komma igång.', label: 'Företagsform' },
|
{ title: 'Välkommen', subtitle: 'Välj din företagsform för att komma igång.', label: 'Företagsform' },
|
||||||
{ title: 'Ditt företag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' },
|
{ title: 'Ditt företag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' },
|
||||||
@@ -24,6 +31,13 @@ const STEP_INFO = [
|
|||||||
{ title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.', label: 'Moms' },
|
{ title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.', label: 'Moms' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const STEP_INFO_CONSULTANT = [
|
||||||
|
{ title: 'Kundföretag', subtitle: 'Välj din kunds företagsform.', label: 'Företagsform' },
|
||||||
|
{ title: 'Kundföretag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' },
|
||||||
|
{ title: 'F-skatt & räkenskapsår', subtitle: 'Din kunds skatteregistrering och räkenskapsår.', label: 'Skatt' },
|
||||||
|
{ title: 'Moms & bokföring', subtitle: 'Din kunds momsregistrering och bokföringsmetod.', label: 'Moms' },
|
||||||
|
]
|
||||||
|
|
||||||
function translatePeriodError(msg: string): string {
|
function translatePeriodError(msg: string): string {
|
||||||
if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.'
|
if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.'
|
||||||
if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.'
|
if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.'
|
||||||
@@ -72,8 +86,12 @@ function OnboardingPageContent() {
|
|||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [currentStep, setCurrentStep] = useState(1)
|
const [currentStep, setCurrentStep] = useState(1)
|
||||||
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
|
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
|
||||||
|
const [companyId, setCompanyId] = useState<string | null>(null)
|
||||||
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
|
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
|
||||||
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null)
|
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null)
|
||||||
|
const [mode, setMode] = useState<OnboardingMode>('choice')
|
||||||
|
const [consultantLanding, setConsultantLanding] = useState(false)
|
||||||
|
const [teamName, setTeamName] = useState('')
|
||||||
|
|
||||||
const totalSteps = 4
|
const totalSteps = 4
|
||||||
|
|
||||||
@@ -102,26 +120,95 @@ function OnboardingPageContent() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
// Check for unprocessed invite token (fallback if auth callback didn't process it)
|
||||||
.from('company_settings')
|
const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
|
||||||
.select('*')
|
const inviteToken = cookieMatch?.[1]
|
||||||
.eq('user_id', user.id)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error && error.code !== 'PGRST116') {
|
if (inviteToken) {
|
||||||
logError('failed to load settings', { message: error.message, code: error.code })
|
try {
|
||||||
|
const res = await fetch('/api/team/accept', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: inviteToken }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
// Clear the cookie
|
||||||
|
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
|
||||||
|
console.log(LOG, 'invite accepted via fallback — redirecting to dashboard')
|
||||||
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the failure to help diagnose
|
||||||
|
const errBody = await res.json().catch(() => ({}))
|
||||||
|
console.error(LOG, 'fallback invite acceptance returned non-ok', {
|
||||||
|
status: res.status,
|
||||||
|
error: errBody.error,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(LOG, 'fallback invite acceptance failed:', err)
|
||||||
|
}
|
||||||
|
// Clear cookie regardless to avoid retry loops
|
||||||
|
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data) {
|
// Check if user is already in a team (consultant) — skip onboarding
|
||||||
const step = data.onboarding_step || 1
|
const { data: teamMember } = await supabase
|
||||||
const clampedStep = step > totalSteps ? totalSteps : step
|
.from('team_members')
|
||||||
if (step > totalSteps) {
|
.select('id')
|
||||||
logError('onboarding_step exceeds totalSteps — clamped', { step, totalSteps })
|
.eq('user_id', user.id)
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (teamMember) {
|
||||||
|
console.log(LOG, 'user already in a team — redirecting to dashboard')
|
||||||
|
window.location.href = '/'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already has a company via company_members
|
||||||
|
const { data: membership } = await supabase
|
||||||
|
.from('company_members')
|
||||||
|
.select('company_id')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.order('created_at', { ascending: true })
|
||||||
|
.limit(1)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (membership?.company_id) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('company_settings')
|
||||||
|
.select('*')
|
||||||
|
.eq('company_id', membership.company_id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error && error.code !== 'PGRST116') {
|
||||||
|
logError('failed to load settings', { message: error.message, code: error.code })
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this company is already onboarded (invited user joining existing company),
|
||||||
|
// skip onboarding entirely and go to dashboard
|
||||||
|
if (data?.onboarding_complete) {
|
||||||
|
console.log(LOG, 'company already onboarded — redirecting to dashboard')
|
||||||
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCompanyId(membership.company_id)
|
||||||
|
setMode('self') // Resuming — skip role choice
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
const step = data.onboarding_step || 1
|
||||||
|
const clampedStep = step > totalSteps ? totalSteps : step
|
||||||
|
if (step > totalSteps) {
|
||||||
|
logError('onboarding_step exceeds totalSteps — clamped', { step, totalSteps })
|
||||||
|
}
|
||||||
|
// Important milestone: where we resume
|
||||||
|
console.log(LOG, 'resuming at step', clampedStep, { entity_type: data.entity_type })
|
||||||
|
setSettings(data)
|
||||||
|
setCurrentStep(clampedStep)
|
||||||
}
|
}
|
||||||
// Important milestone: where we resume
|
|
||||||
console.log(LOG, 'resuming at step', clampedStep, { entity_type: data.entity_type })
|
|
||||||
setSettings(data)
|
|
||||||
setCurrentStep(clampedStep)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@@ -153,16 +240,21 @@ function OnboardingPageContent() {
|
|||||||
onboarding_step: targetStep,
|
onboarding_step: targetStep,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
logError('save aborted: no companyId', { step: targetStep })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Remove read-only and transient fields before updating
|
// Remove read-only and transient fields before updating
|
||||||
const {
|
const {
|
||||||
id: _id, user_id: _uid, created_at: _ca, updated_at: _ua,
|
id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua,
|
||||||
is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye,
|
is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye,
|
||||||
...settingsToSave
|
...settingsToSave
|
||||||
} = updatedSettings as Record<string, unknown>
|
} = updatedSettings as Record<string, unknown>
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.upsert({ ...settingsToSave, user_id: user.id }, { onConflict: 'user_id' })
|
.upsert({ ...settingsToSave, company_id: companyId }, { onConflict: 'company_id' })
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
logError('save failed', { message: error.message, step: targetStep, code: error.code, details: error.details })
|
logError('save failed', { message: error.message, step: targetStep, code: error.code, details: error.details })
|
||||||
@@ -199,36 +291,121 @@ function OnboardingPageContent() {
|
|||||||
setTicLookup(null)
|
setTicLookup(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 1: Create company + membership + user_preferences if no companyId yet
|
||||||
|
let activeCompanyId = companyId
|
||||||
|
|
||||||
|
if (currentStep === 1 && !activeCompanyId) {
|
||||||
|
try {
|
||||||
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||||
|
if (authError) {
|
||||||
|
logError('auth.getUser() failed before company creation', { message: authError.message })
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
logError('company creation skipped: no user')
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically create company + owner membership + set active
|
||||||
|
const { data: newCompanyId, error: rpcError } = await supabase.rpc('create_company_with_owner', {
|
||||||
|
p_name: 'Mitt företag',
|
||||||
|
p_entity_type: stepData.entity_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rpcError || !newCompanyId) {
|
||||||
|
logError('company creation failed', { message: rpcError?.message, code: rpcError?.code })
|
||||||
|
toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeCompanyId = newCompanyId
|
||||||
|
setCompanyId(activeCompanyId)
|
||||||
|
console.log(LOG, 'created company', activeCompanyId)
|
||||||
|
} catch (err) {
|
||||||
|
logError('company creation threw', { error: String(err) })
|
||||||
|
Sentry.captureException(err)
|
||||||
|
toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeCompanyId) {
|
||||||
|
logError('handleNext aborted: no companyId', { step: currentStep })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const nextStep = currentStep + 1
|
const nextStep = currentStep + 1
|
||||||
const success = await saveSettings(stepData, nextStep)
|
|
||||||
|
// For step 1, companyId state may not be updated yet (React batching).
|
||||||
|
// Save settings directly with activeCompanyId to avoid the race condition.
|
||||||
|
const needsDirectSave = currentStep === 1 && !companyId
|
||||||
|
const success = needsDirectSave
|
||||||
|
? await (async () => {
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
const updatedSettings = { ...settings, ...stepData, onboarding_step: nextStep }
|
||||||
|
const {
|
||||||
|
id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua,
|
||||||
|
is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye,
|
||||||
|
...settingsToSave
|
||||||
|
} = updatedSettings as Record<string, unknown>
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('company_settings')
|
||||||
|
.upsert({ ...settingsToSave, company_id: activeCompanyId }, { onConflict: 'company_id' })
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logError('save failed', { message: error.message, step: nextStep, code: error.code })
|
||||||
|
toast({ title: 'Fel', description: error.message || 'Kunde inte spara. Försök igen.', variant: 'destructive' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettings(updatedSettings)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
logError('saveSettings threw', { message: String(err), step: nextStep })
|
||||||
|
Sentry.captureException(err)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: await saveSettings(stepData, nextStep)
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
logError('handleNext aborted: saveSettings failed', { step: currentStep })
|
logError('handleNext aborted: saveSettings failed', { step: currentStep })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After step 2 (company details): sync company name to companies table
|
||||||
|
if (currentStep === 2 && stepData.company_name && activeCompanyId) {
|
||||||
|
const { error: nameError } = await supabase
|
||||||
|
.from('companies')
|
||||||
|
.update({ name: stepData.company_name })
|
||||||
|
.eq('id', activeCompanyId)
|
||||||
|
|
||||||
|
if (nameError) {
|
||||||
|
logError('failed to sync company name to companies table', {
|
||||||
|
message: nameError.message,
|
||||||
|
code: nameError.code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// After step 1 (entity type selection): seed chart of accounts
|
// After step 1 (entity type selection): seed chart of accounts
|
||||||
if (currentStep === 1 && stepData.entity_type) {
|
if (currentStep === 1 && stepData.entity_type) {
|
||||||
try {
|
try {
|
||||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', {
|
||||||
if (authError) {
|
p_company_id: activeCompanyId,
|
||||||
logError('auth.getUser() failed before seeding chart of accounts', { message: authError.message })
|
p_entity_type: stepData.entity_type,
|
||||||
}
|
})
|
||||||
if (user) {
|
if (rpcError) {
|
||||||
const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', {
|
logError('chart of accounts seeding failed', {
|
||||||
p_user_id: user.id,
|
entity_type: stepData.entity_type,
|
||||||
p_entity_type: stepData.entity_type,
|
message: rpcError.message,
|
||||||
|
code: rpcError.code,
|
||||||
|
details: rpcError.details,
|
||||||
})
|
})
|
||||||
if (rpcError) {
|
|
||||||
logError('chart of accounts seeding failed', {
|
|
||||||
entity_type: stepData.entity_type,
|
|
||||||
message: rpcError.message,
|
|
||||||
code: rpcError.code,
|
|
||||||
details: rpcError.details,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logError('chart of accounts seeding skipped: no user')
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('chart of accounts seeding threw', { error: String(err) })
|
logError('chart of accounts seeding threw', { error: String(err) })
|
||||||
@@ -237,15 +414,8 @@ function OnboardingPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After step 3 (tax registration): create initial fiscal period
|
// After step 3 (tax registration): create initial fiscal period
|
||||||
if (currentStep === 3) {
|
if (currentStep === 3 && companyId) {
|
||||||
try {
|
try {
|
||||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
|
||||||
if (authError) {
|
|
||||||
logError('auth.getUser() failed before fiscal period creation', { message: authError.message })
|
|
||||||
}
|
|
||||||
if (!user) {
|
|
||||||
logError('fiscal period creation skipped: no user')
|
|
||||||
} else {
|
|
||||||
const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined
|
const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined
|
||||||
const firstYearStart = stepData.first_year_start as string | undefined
|
const firstYearStart = stepData.first_year_start as string | undefined
|
||||||
const firstYearEnd = stepData.first_year_end as string | undefined
|
const firstYearEnd = stepData.first_year_end as string | undefined
|
||||||
@@ -314,7 +484,7 @@ function OnboardingPageContent() {
|
|||||||
const { data: existingPeriods, error: fetchPeriodsError } = await supabase
|
const { data: existingPeriods, error: fetchPeriodsError } = await supabase
|
||||||
.from('fiscal_periods')
|
.from('fiscal_periods')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (fetchPeriodsError) {
|
if (fetchPeriodsError) {
|
||||||
logError('failed to fetch existing fiscal periods', {
|
logError('failed to fetch existing fiscal periods', {
|
||||||
@@ -348,12 +518,12 @@ function OnboardingPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { error: upsertError } = await supabase.from('fiscal_periods').upsert({
|
const { error: upsertError } = await supabase.from('fiscal_periods').upsert({
|
||||||
user_id: user.id,
|
company_id: companyId,
|
||||||
name: periodName,
|
name: periodName,
|
||||||
period_start: startStr,
|
period_start: startStr,
|
||||||
period_end: endStr,
|
period_end: endStr,
|
||||||
}, {
|
}, {
|
||||||
onConflict: 'user_id,period_start,period_end',
|
onConflict: 'company_id,period_start,period_end',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (upsertError) {
|
if (upsertError) {
|
||||||
@@ -361,7 +531,6 @@ function OnboardingPageContent() {
|
|||||||
message: upsertError.message, startStr, endStr, code: upsertError.code, details: upsertError.details,
|
message: upsertError.message, startStr, endStr, code: upsertError.code, details: upsertError.details,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('fiscal period creation threw', { error: String(err) })
|
logError('fiscal period creation threw', { error: String(err) })
|
||||||
Sentry.captureException(err)
|
Sentry.captureException(err)
|
||||||
@@ -394,6 +563,8 @@ function OnboardingPageContent() {
|
|||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (currentStep > 1) {
|
if (currentStep > 1) {
|
||||||
setCurrentStep(currentStep - 1)
|
setCurrentStep(currentStep - 1)
|
||||||
|
} else if (isConsultant) {
|
||||||
|
setConsultantLanding(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,6 +579,46 @@ function OnboardingPageContent() {
|
|||||||
setCurrentStep(nextStep)
|
setCurrentStep(nextStep)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleConsultantCreateTeam = async () => {
|
||||||
|
if (!teamName.trim()) {
|
||||||
|
toast({ title: 'Ange ett namn', description: 'Ditt team behöver ett namn.', variant: 'destructive' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) {
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: newTeamId, error: teamError } = await supabase.rpc('create_team_with_owner', {
|
||||||
|
p_name: teamName.trim(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (teamError || !newTeamId) {
|
||||||
|
logError('consultant team creation failed', { message: teamError?.message })
|
||||||
|
toast({ title: 'Fel', description: 'Kunde inte skapa team. Försök igen.', variant: 'destructive' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(LOG, 'created team', newTeamId)
|
||||||
|
toast({
|
||||||
|
title: 'Välkommen!',
|
||||||
|
description: 'Lägg till ditt första kundföretag för att komma igång.',
|
||||||
|
})
|
||||||
|
// Hard navigation to exit the (onboarding) route group and trigger middleware
|
||||||
|
window.location.href = '/'
|
||||||
|
} catch (err) {
|
||||||
|
logError('consultant team creation threw', { error: String(err) })
|
||||||
|
Sentry.captureException(err)
|
||||||
|
toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' })
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
@@ -416,7 +627,11 @@ function OnboardingPageContent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stepInfo = STEP_INFO[currentStep - 1]
|
const isConsultant = mode === 'consultant'
|
||||||
|
const stepInfoArr = isConsultant ? STEP_INFO_CONSULTANT : STEP_INFO
|
||||||
|
const stepInfo = stepInfoArr[currentStep - 1]
|
||||||
|
const showRoleChoice = mode === 'choice'
|
||||||
|
const showConsultantLanding = isConsultant && consultantLanding
|
||||||
|
|
||||||
const renderSteps = () => (
|
const renderSteps = () => (
|
||||||
<>
|
<>
|
||||||
@@ -475,10 +690,150 @@ function OnboardingPageContent() {
|
|||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Role Choice Screen (Step 0) ──
|
||||||
|
if (showRoleChoice) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-background">
|
||||||
|
<header className="relative bg-[#141414] text-white overflow-hidden">
|
||||||
|
<div className="absolute inset-0 pointer-events-none" aria-hidden>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10 max-w-2xl mx-auto w-full px-6 md:px-10 pt-5 pb-6 md:pt-6 md:pb-8">
|
||||||
|
<div className="flex items-center gap-2.5 mb-5 md:mb-6">
|
||||||
|
<Image
|
||||||
|
src="/gnubokiceon-removebg-preview.png"
|
||||||
|
alt="Gnubok"
|
||||||
|
width={30}
|
||||||
|
height={30}
|
||||||
|
className="invert opacity-90"
|
||||||
|
/>
|
||||||
|
<span className="font-display text-base tracking-tight">gnubok</span>
|
||||||
|
</div>
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight leading-[1.1]">
|
||||||
|
Välkommen till gnubok
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/40 mt-1.5 text-sm max-w-sm leading-relaxed">
|
||||||
|
Hur vill du använda gnubok?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1">
|
||||||
|
<div className="max-w-lg mx-auto px-6 md:px-10 py-6 md:py-8">
|
||||||
|
<div className="animate-slide-up">
|
||||||
|
<Step0RoleChoice
|
||||||
|
onChooseSelf={() => setMode('self')}
|
||||||
|
onChooseConsultant={() => {
|
||||||
|
setMode('consultant')
|
||||||
|
setConsultantLanding(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Consultant Landing Screen ──
|
||||||
|
if (showConsultantLanding) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-background">
|
||||||
|
<header className="relative bg-[#141414] text-white overflow-hidden">
|
||||||
|
<div className="absolute inset-0 pointer-events-none" aria-hidden>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10 max-w-2xl mx-auto w-full px-6 md:px-10 pt-5 pb-6 md:pt-6 md:pb-8">
|
||||||
|
<div className="flex items-center gap-2.5 mb-5 md:mb-6">
|
||||||
|
<Image
|
||||||
|
src="/gnubokiceon-removebg-preview.png"
|
||||||
|
alt="Gnubok"
|
||||||
|
width={30}
|
||||||
|
height={30}
|
||||||
|
className="invert opacity-90"
|
||||||
|
/>
|
||||||
|
<span className="font-display text-base tracking-tight">gnubok</span>
|
||||||
|
</div>
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight leading-[1.1]">
|
||||||
|
Namnge ditt team
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/40 mt-1.5 text-sm max-w-sm leading-relaxed">
|
||||||
|
Skapa ett team som samlar dig och dina kollegor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1">
|
||||||
|
<div className="max-w-lg mx-auto px-6 md:px-10 py-6 md:py-8">
|
||||||
|
<div className="animate-slide-up space-y-5">
|
||||||
|
<div className="rounded-xl border bg-card p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="team-name">Teamnamn</Label>
|
||||||
|
<Input
|
||||||
|
id="team-name"
|
||||||
|
placeholder="T.ex. Redovisningsbyrån AB"
|
||||||
|
value={teamName}
|
||||||
|
onChange={(e) => setTeamName(e.target.value)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="h-11"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Du kan ändra namnet senare i inställningar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleConsultantCreateTeam}
|
||||||
|
disabled={isSaving || !teamName.trim()}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Skapar team...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Skapa team
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMode('choice')
|
||||||
|
setConsultantLanding(false)
|
||||||
|
}}
|
||||||
|
className="block mx-auto text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Tillbaka
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Steps 1–4 (self or consultant adding company) ──
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-background">
|
<div className="min-h-screen flex flex-col bg-background">
|
||||||
{/* ── Branded Header ── */}
|
{/* ── Branded Header ── */}
|
||||||
@@ -511,7 +866,7 @@ function OnboardingPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Step indicator — inline with logo row */}
|
{/* Step indicator — inline with logo row */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{STEP_INFO.map((_, i) => {
|
{stepInfoArr.map((_, i) => {
|
||||||
const num = i + 1
|
const num = i + 1
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
@@ -31,9 +31,11 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Delete the auth user via service role — all data cascades via ON DELETE CASCADE
|
// RPC disables protective triggers, deletes from auth.users (CASCADE
|
||||||
const serviceClient = await createServiceClient()
|
// cleans up all public tables), then re-enables triggers — all in one tx.
|
||||||
const { error } = await serviceClient.auth.admin.deleteUser(user.id)
|
const { error } = await supabase.rpc('delete_user_account', {
|
||||||
|
target_user_id: user.id,
|
||||||
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Failed to delete user:', error)
|
console.error('Failed to delete user:', error)
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ vi.mock('@/lib/extensions/ai-consent', () => ({
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { hasAiConsent, grantAiConsent, revokeAiConsent } from '@/lib/extensions/ai-consent'
|
import { hasAiConsent, grantAiConsent, revokeAiConsent } from '@/lib/extensions/ai-consent'
|
||||||
import { GET, POST, DELETE } from '../route'
|
import { GET, POST, DELETE } from '../route'
|
||||||
@@ -91,7 +96,7 @@ describe('POST /api/ai-consent', () => {
|
|||||||
|
|
||||||
expect(status).toBe(200)
|
expect(status).toBe(200)
|
||||||
expect(body.data.consented).toBe(true)
|
expect(body.data.consented).toBe(true)
|
||||||
expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'receipt-ocr')
|
expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'receipt-ocr')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns 400 for non-AI extension', async () => {
|
it('returns 400 for non-AI extension', async () => {
|
||||||
@@ -131,6 +136,6 @@ describe('DELETE /api/ai-consent', () => {
|
|||||||
|
|
||||||
expect(status).toBe(200)
|
expect(status).toBe(200)
|
||||||
expect(body.data.consented).toBe(false)
|
expect(body.data.consented).toBe(false)
|
||||||
expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'ai-chat')
|
expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'ai-chat')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
revokeAiConsent,
|
revokeAiConsent,
|
||||||
isAiExtension,
|
isAiExtension,
|
||||||
} from '@/lib/extensions/ai-consent'
|
} from '@/lib/extensions/ai-consent'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -16,9 +17,11 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const statuses: Record<string, boolean> = {}
|
const statuses: Record<string, boolean> = {}
|
||||||
for (const ext of AI_EXTENSIONS) {
|
for (const ext of AI_EXTENSIONS) {
|
||||||
statuses[ext] = await hasAiConsent(supabase, user.id, ext)
|
statuses[ext] = await hasAiConsent(supabase, companyId, ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ data: statuses })
|
return NextResponse.json({ data: statuses })
|
||||||
@@ -32,6 +35,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { extension_id } = body
|
const { extension_id } = body
|
||||||
|
|
||||||
@@ -42,7 +47,7 @@ export async function POST(request: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
await grantAiConsent(supabase, user.id, extension_id)
|
await grantAiConsent(supabase, user.id, companyId, extension_id)
|
||||||
return NextResponse.json({ data: { consented: true } })
|
return NextResponse.json({ data: { consented: true } })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +59,8 @@ export async function DELETE(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { extension_id } = body
|
const { extension_id } = body
|
||||||
|
|
||||||
@@ -64,6 +71,6 @@ export async function DELETE(request: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
await revokeAiConsent(supabase, user.id, extension_id)
|
await revokeAiConsent(supabase, user.id, companyId, extension_id)
|
||||||
return NextResponse.json({ data: { consented: false } })
|
return NextResponse.json({ data: { consented: false } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ vi.mock('@/lib/core/audit/audit-service', () => ({
|
|||||||
getAuditLog: vi.fn(),
|
getAuditLog: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
||||||
import { GET } from '../route'
|
import { GET } from '../route'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
||||||
import type { AuditAction } from '@/types'
|
import type { AuditAction } from '@/types'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -11,6 +12,8 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
|
|
||||||
const filters = {
|
const filters = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -9,6 +10,8 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const from = searchParams.get('from')
|
const from = searchParams.get('from')
|
||||||
const to = searchParams.get('to')
|
const to = searchParams.get('to')
|
||||||
@@ -27,7 +30,7 @@ export async function GET(request: Request) {
|
|||||||
let entriesQuery = supabase
|
let entriesQuery = supabase
|
||||||
.from('journal_entries')
|
.from('journal_entries')
|
||||||
.select('id, entry_date')
|
.select('id, entry_date')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.eq('status', 'posted')
|
.eq('status', 'posted')
|
||||||
|
|
||||||
if (dateFrom) {
|
if (dateFrom) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { validateBody } from '@/lib/api/validate'
|
import { validateBody } from '@/lib/api/validate'
|
||||||
import { UpdateAccountSchema } from '@/lib/api/schemas'
|
import { UpdateAccountSchema } from '@/lib/api/schemas'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -15,11 +16,13 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Fetch the account to check if it's a system account
|
// Fetch the account to check if it's a system account
|
||||||
const { data: account, error: fetchError } = await supabase
|
const { data: account, error: fetchError } = await supabase
|
||||||
.from('chart_of_accounts')
|
.from('chart_of_accounts')
|
||||||
.select('id, is_system_account')
|
.select('id, is_system_account')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.eq('account_number', number)
|
.eq('account_number', number)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
@@ -51,7 +54,7 @@ export async function DELETE(
|
|||||||
.from('chart_of_accounts')
|
.from('chart_of_accounts')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', account.id)
|
.eq('id', account.id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (deleteError) {
|
if (deleteError) {
|
||||||
return NextResponse.json({ error: deleteError.message }, { status: 500 })
|
return NextResponse.json({ error: deleteError.message }, { status: 500 })
|
||||||
@@ -72,6 +75,8 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const validation = await validateBody(request, UpdateAccountSchema)
|
const validation = await validateBody(request, UpdateAccountSchema)
|
||||||
if (!validation.success) return validation.response
|
if (!validation.success) return validation.response
|
||||||
const body = validation.data
|
const body = validation.data
|
||||||
@@ -79,7 +84,7 @@ export async function PUT(
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('chart_of_accounts')
|
.from('chart_of_accounts')
|
||||||
.update(body)
|
.update(body)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.eq('account_number', number)
|
.eq('account_number', number)
|
||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/bookkeeping/accounts/activate
|
* POST /api/bookkeeping/accounts/activate
|
||||||
@@ -17,6 +18,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const accountNumbers: string[] = body.account_numbers
|
const accountNumbers: string[] = body.account_numbers
|
||||||
|
|
||||||
@@ -28,7 +31,7 @@ export async function POST(request: Request) {
|
|||||||
const { data: existing } = await supabase
|
const { data: existing } = await supabase
|
||||||
.from('chart_of_accounts')
|
.from('chart_of_accounts')
|
||||||
.select('account_number')
|
.select('account_number')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.in('account_number', accountNumbers)
|
.in('account_number', accountNumbers)
|
||||||
|
|
||||||
const existingNumbers = new Set((existing || []).map((a) => a.account_number))
|
const existingNumbers = new Set((existing || []).map((a) => a.account_number))
|
||||||
@@ -42,6 +45,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
account_number: ref.account_number,
|
account_number: ref.account_number,
|
||||||
account_name: ref.account_name,
|
account_name: ref.account_name,
|
||||||
account_class: ref.account_class,
|
account_class: ref.account_class,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
|
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/bookkeeping/accounts/reference
|
* GET /api/bookkeeping/accounts/reference
|
||||||
@@ -17,13 +18,15 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Fetch user's chart of accounts (paginated to avoid 1000-row limit)
|
// Fetch user's chart of accounts (paginated to avoid 1000-row limit)
|
||||||
try {
|
try {
|
||||||
const userAccounts = await fetchAllRows<{ account_number: string; is_active: boolean; is_system_account: boolean }>(({ from, to }) =>
|
const userAccounts = await fetchAllRows<{ account_number: string; is_active: boolean; is_system_account: boolean }>(({ from, to }) =>
|
||||||
supabase
|
supabase
|
||||||
.from('chart_of_accounts')
|
.from('chart_of_accounts')
|
||||||
.select('account_number, is_active, is_system_account')
|
.select('account_number, is_active, is_system_account')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.range(from, to)
|
.range(from, to)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { validateBody } from '@/lib/api/validate'
|
import { validateBody } from '@/lib/api/validate'
|
||||||
import { CreateAccountSchema } from '@/lib/api/schemas'
|
import { CreateAccountSchema } from '@/lib/api/schemas'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -12,6 +13,8 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const accountClass = searchParams.get('class')
|
const accountClass = searchParams.get('class')
|
||||||
const activeOnly = searchParams.get('active') !== 'false'
|
const activeOnly = searchParams.get('active') !== 'false'
|
||||||
@@ -21,7 +24,7 @@ export async function GET(request: Request) {
|
|||||||
let query = supabase
|
let query = supabase
|
||||||
.from('chart_of_accounts')
|
.from('chart_of_accounts')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.order('sort_order')
|
.order('sort_order')
|
||||||
|
|
||||||
if (activeOnly) {
|
if (activeOnly) {
|
||||||
@@ -53,10 +56,13 @@ export async function POST(request: Request) {
|
|||||||
if (!validation.success) return validation.response
|
if (!validation.success) return validation.response
|
||||||
const body = validation.data
|
const body = validation.data
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('chart_of_accounts')
|
.from('chart_of_accounts')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
account_number: body.account_number,
|
account_number: body.account_number,
|
||||||
account_name: body.account_name,
|
account_name: body.account_name,
|
||||||
account_class: parseInt(body.account_number[0]),
|
account_class: parseInt(body.account_number[0]),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
|
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -14,8 +15,10 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const period = await closePeriod(supabase, user.id, id)
|
const period = await closePeriod(supabase, companyId, user.id, id)
|
||||||
return NextResponse.json({ data: period })
|
return NextResponse.json({ data: period })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
previewCurrencyRevaluation,
|
previewCurrencyRevaluation,
|
||||||
executeCurrencyRevaluation,
|
executeCurrencyRevaluation,
|
||||||
} from '@/lib/bookkeeping/currency-revaluation'
|
} from '@/lib/bookkeeping/currency-revaluation'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET: Preview currency revaluation for a fiscal period
|
* GET: Preview currency revaluation for a fiscal period
|
||||||
@@ -20,20 +21,22 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch period to get closing date
|
// Fetch period to get closing date
|
||||||
const { data: period, error: periodError } = await supabase
|
const { data: period, error: periodError } = await supabase
|
||||||
.from('fiscal_periods')
|
.from('fiscal_periods')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (periodError || !period) {
|
if (periodError || !period) {
|
||||||
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = await previewCurrencyRevaluation(supabase, user.id, period.period_end)
|
const preview = await previewCurrencyRevaluation(supabase, companyId, period.period_end)
|
||||||
return NextResponse.json({ data: preview })
|
return NextResponse.json({ data: preview })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -58,13 +61,15 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch period to get closing date
|
// Fetch period to get closing date
|
||||||
const { data: period, error: periodError } = await supabase
|
const { data: period, error: periodError } = await supabase
|
||||||
.from('fiscal_periods')
|
.from('fiscal_periods')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (periodError || !period) {
|
if (periodError || !period) {
|
||||||
@@ -75,7 +80,7 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Period is already closed' }, { status: 400 })
|
return NextResponse.json({ error: 'Period is already closed' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await executeCurrencyRevaluation(supabase, user.id, period.period_end, id)
|
const result = await executeCurrencyRevaluation(supabase, companyId, period.period_end, id, user.id)
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return NextResponse.json({ data: null, message: 'No foreign currency items to revalue' })
|
return NextResponse.json({ data: null, message: 'No foreign currency items to revalue' })
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { lockPeriod } from '@/lib/core/bookkeeping/period-service'
|
import { lockPeriod } from '@/lib/core/bookkeeping/period-service'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -14,8 +15,10 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const period = await lockPeriod(supabase, user.id, id)
|
const period = await lockPeriod(supabase, companyId, user.id, id)
|
||||||
return NextResponse.json({ data: period })
|
return NextResponse.json({ data: period })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
previewYearEndClosing,
|
previewYearEndClosing,
|
||||||
executeYearEndClosing,
|
executeYearEndClosing,
|
||||||
} from '@/lib/core/bookkeeping/year-end-service'
|
} from '@/lib/core/bookkeeping/year-end-service'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET: Validate readiness and preview year-end closing
|
* GET: Validate readiness and preview year-end closing
|
||||||
@@ -21,10 +22,12 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [validation, preview] = await Promise.all([
|
const [validation, preview] = await Promise.all([
|
||||||
validateYearEndReadiness(supabase, user.id, id),
|
validateYearEndReadiness(supabase, companyId, user.id, id),
|
||||||
previewYearEndClosing(supabase, user.id, id),
|
previewYearEndClosing(supabase, companyId, user.id, id),
|
||||||
])
|
])
|
||||||
|
|
||||||
return NextResponse.json({ data: { validation, preview } })
|
return NextResponse.json({ data: { validation, preview } })
|
||||||
@@ -51,8 +54,10 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await executeYearEndClosing(supabase, user.id, id)
|
const result = await executeYearEndClosing(supabase, companyId, user.id, id)
|
||||||
return NextResponse.json({ data: result })
|
return NextResponse.json({ data: result })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
|||||||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||||||
import { validateBody } from '@/lib/api/validate'
|
import { validateBody } from '@/lib/api/validate'
|
||||||
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
|
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -12,10 +13,12 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('fiscal_periods')
|
.from('fiscal_periods')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.order('period_start', { ascending: false })
|
.order('period_start', { ascending: false })
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -33,6 +36,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const validation = await validateBody(request, CreateFiscalPeriodSchema)
|
const validation = await validateBody(request, CreateFiscalPeriodSchema)
|
||||||
if (!validation.success) return validation.response
|
if (!validation.success) return validation.response
|
||||||
const body = validation.data
|
const body = validation.data
|
||||||
@@ -47,7 +52,7 @@ export async function POST(request: Request) {
|
|||||||
const { data: overlapping } = await supabase
|
const { data: overlapping } = await supabase
|
||||||
.from('fiscal_periods')
|
.from('fiscal_periods')
|
||||||
.select('id, name')
|
.select('id, name')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.lte('period_start', body.period_end)
|
.lte('period_start', body.period_end)
|
||||||
.gte('period_end', body.period_start)
|
.gte('period_end', body.period_start)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -63,6 +68,7 @@ export async function POST(request: Request) {
|
|||||||
.from('fiscal_periods')
|
.from('fiscal_periods')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
period_start: body.period_start,
|
period_start: body.period_start,
|
||||||
period_end: body.period_end,
|
period_end: body.period_end,
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({
|
|||||||
createClient: () => mockCreateClient(),
|
createClient: () => mockCreateClient(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
import { GET } from '../route'
|
import { GET } from '../route'
|
||||||
|
|
||||||
function buildMockSupabase({
|
function buildMockSupabase({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -13,12 +14,14 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Fetch the requested entry with lines
|
// Fetch the requested entry with lines
|
||||||
const { data: entry, error } = await supabase
|
const { data: entry, error } = await supabase
|
||||||
.from('journal_entries')
|
.from('journal_entries')
|
||||||
.select('*, lines:journal_entry_lines(*)')
|
.select('*, lines:journal_entry_lines(*)')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error || !entry) {
|
if (error || !entry) {
|
||||||
@@ -38,7 +41,7 @@ export async function GET(
|
|||||||
const { data: referencing } = await supabase
|
const { data: referencing } = await supabase
|
||||||
.from('journal_entries')
|
.from('journal_entries')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.or(`reverses_id.eq.${id},reversed_by_id.eq.${id},correction_of_id.eq.${id}`)
|
.or(`reverses_id.eq.${id},reversed_by_id.eq.${id},correction_of_id.eq.${id}`)
|
||||||
|
|
||||||
if (referencing) {
|
if (referencing) {
|
||||||
@@ -57,7 +60,7 @@ export async function GET(
|
|||||||
const { data: batchEntries } = await supabase
|
const { data: batchEntries } = await supabase
|
||||||
.from('journal_entries')
|
.from('journal_entries')
|
||||||
.select('id, reverses_id, reversed_by_id, correction_of_id')
|
.select('id, reverses_id, reversed_by_id, correction_of_id')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.in('id', batch)
|
.in('id', batch)
|
||||||
|
|
||||||
if (!batchEntries) continue
|
if (!batchEntries) continue
|
||||||
@@ -81,7 +84,7 @@ export async function GET(
|
|||||||
const { data: refs } = await supabase
|
const { data: refs } = await supabase
|
||||||
.from('journal_entries')
|
.from('journal_entries')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.or(batchOr)
|
.or(batchOr)
|
||||||
|
|
||||||
if (refs) {
|
if (refs) {
|
||||||
@@ -99,7 +102,7 @@ export async function GET(
|
|||||||
const { data: chainEntries } = await supabase
|
const { data: chainEntries } = await supabase
|
||||||
.from('journal_entries')
|
.from('journal_entries')
|
||||||
.select('*, lines:journal_entry_lines(*)')
|
.select('*, lines:journal_entry_lines(*)')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.in('id', chainIds)
|
.in('id', chainIds)
|
||||||
.order('created_at', { ascending: true })
|
.order('created_at', { ascending: true })
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ vi.mock('@/lib/init', () => ({
|
|||||||
ensureInitialized: vi.fn(),
|
ensureInitialized: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
const mockCorrectEntry = vi.fn()
|
const mockCorrectEntry = vi.fn()
|
||||||
vi.mock('@/lib/core/bookkeeping/storno-service', () => ({
|
vi.mock('@/lib/core/bookkeeping/storno-service', () => ({
|
||||||
correctEntry: (...args: unknown[]) => mockCorrectEntry(...args),
|
correctEntry: (...args: unknown[]) => mockCorrectEntry(...args),
|
||||||
@@ -100,7 +104,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
|
|||||||
expect(status).toBe(200)
|
expect(status).toBe(200)
|
||||||
expect(body.data.reversal).toEqual(reversal)
|
expect(body.data.reversal).toEqual(reversal)
|
||||||
expect(body.data.corrected).toEqual(corrected)
|
expect(body.data.corrected).toEqual(corrected)
|
||||||
expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'entry-1', lines)
|
expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1', lines)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns 400 when correctEntry throws for unbalanced lines', async () => {
|
it('returns 400 when correctEntry throws for unbalanced lines', async () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
|
|||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
import { validateBody } from '@/lib/api/validate'
|
import { validateBody } from '@/lib/api/validate'
|
||||||
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
|
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
|
|
||||||
@@ -19,12 +20,14 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const validation = await validateBody(request, CorrectJournalEntrySchema)
|
const validation = await validateBody(request, CorrectJournalEntrySchema)
|
||||||
if (!validation.success) return validation.response
|
if (!validation.success) return validation.response
|
||||||
const body = validation.data
|
const body = validation.data
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await correctEntry(supabase, user.id, id, body.lines)
|
const result = await correctEntry(supabase, companyId, user.id, id, body.lines)
|
||||||
return NextResponse.json({ data: result })
|
return NextResponse.json({ data: result })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ vi.mock('@/lib/init', () => ({
|
|||||||
ensureInitialized: vi.fn(),
|
ensureInitialized: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
const mockReverseEntry = vi.fn()
|
const mockReverseEntry = vi.fn()
|
||||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||||
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
|
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
|
||||||
@@ -64,7 +68,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => {
|
|||||||
|
|
||||||
expect(status).toBe(200)
|
expect(status).toBe(200)
|
||||||
expect(body.data).toEqual(reversalEntry)
|
expect(body.data).toEqual(reversalEntry)
|
||||||
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'entry-1')
|
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns 400 when engine throws', async () => {
|
it('returns 400 when engine throws', async () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
|
|
||||||
@@ -17,8 +18,10 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const reversalEntry = await reverseEntry(supabase, user.id, id)
|
const reversalEntry = await reverseEntry(supabase, companyId, user.id, id)
|
||||||
return NextResponse.json({ data: reversalEntry })
|
return NextResponse.json({ data: reversalEntry })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -13,11 +14,13 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('journal_entries')
|
.from('journal_entries')
|
||||||
.select('*, lines:journal_entry_lines(*)')
|
.select('*, lines:journal_entry_lines(*)')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ vi.mock('@/lib/init', () => ({
|
|||||||
ensureInitialized: vi.fn(),
|
ensureInitialized: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
const mockCreateJournalEntry = vi.fn()
|
const mockCreateJournalEntry = vi.fn()
|
||||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||||
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
||||||
@@ -137,7 +141,7 @@ describe('POST /api/bookkeeping/journal-entries', () => {
|
|||||||
|
|
||||||
expect(status).toBe(200)
|
expect(status).toBe(200)
|
||||||
expect(body.data).toEqual(entry)
|
expect(body.data).toEqual(entry)
|
||||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', input)
|
expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', input)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns 400 when engine throws', async () => {
|
it('returns 400 when engine throws', async () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
|||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
import { validateBody } from '@/lib/api/validate'
|
import { validateBody } from '@/lib/api/validate'
|
||||||
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
|
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const periodId = searchParams.get('period_id')
|
const periodId = searchParams.get('period_id')
|
||||||
const status = searchParams.get('status')
|
const status = searchParams.get('status')
|
||||||
@@ -29,7 +32,7 @@ export async function GET(request: Request) {
|
|||||||
let query = supabase
|
let query = supabase
|
||||||
.from('journal_entries')
|
.from('journal_entries')
|
||||||
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
|
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (sortDate === 'asc' || sortDate === 'desc') {
|
if (sortDate === 'asc' || sortDate === 'desc') {
|
||||||
query = query
|
query = query
|
||||||
@@ -76,12 +79,14 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const validation = await validateBody(request, CreateJournalEntrySchema)
|
const validation = await validateBody(request, CreateJournalEntrySchema)
|
||||||
if (!validation.success) return validation.response
|
if (!validation.success) return validation.response
|
||||||
const body = validation.data
|
const body = validation.data
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = await createJournalEntry(supabase, user.id, body)
|
const entry = await createJournalEntry(supabase, companyId, user.id, body)
|
||||||
return NextResponse.json({ data: entry })
|
return NextResponse.json({ data: entry })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
|||||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||||
import { validateBody } from '@/lib/api/validate'
|
import { validateBody } from '@/lib/api/validate'
|
||||||
import { EvaluateMappingRulesSchema } from '@/lib/api/schemas'
|
import { EvaluateMappingRulesSchema } from '@/lib/api/schemas'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { Transaction } from '@/types'
|
import type { Transaction } from '@/types'
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -13,6 +14,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const validation = await validateBody(request, EvaluateMappingRulesSchema)
|
const validation = await validateBody(request, EvaluateMappingRulesSchema)
|
||||||
if (!validation.success) return validation.response
|
if (!validation.success) return validation.response
|
||||||
const body = validation.data
|
const body = validation.data
|
||||||
@@ -25,7 +28,7 @@ export async function POST(request: Request) {
|
|||||||
.from('transactions')
|
.from('transactions')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', body.transaction_id)
|
.eq('id', body.transaction_id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
@@ -38,7 +41,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await evaluateMappingRules(supabase, user.id, transaction)
|
const result = await evaluateMappingRules(supabase, companyId, transaction)
|
||||||
return NextResponse.json({ data: result })
|
return NextResponse.json({ data: result })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { validateBody } from '@/lib/api/validate'
|
import { validateBody } from '@/lib/api/validate'
|
||||||
import { CreateMappingRuleSchema } from '@/lib/api/schemas'
|
import { CreateMappingRuleSchema } from '@/lib/api/schemas'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -11,10 +12,12 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('mapping_rules')
|
.from('mapping_rules')
|
||||||
.select('*')
|
.select('*')
|
||||||
.or(`user_id.eq.${user.id},user_id.is.null`)
|
.or(`company_id.eq.${companyId},company_id.is.null`)
|
||||||
.eq('is_active', true)
|
.eq('is_active', true)
|
||||||
.order('priority')
|
.order('priority')
|
||||||
|
|
||||||
@@ -33,6 +36,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const result = await validateBody(request, CreateMappingRuleSchema)
|
const result = await validateBody(request, CreateMappingRuleSchema)
|
||||||
if (!result.success) return result.response
|
if (!result.success) return result.response
|
||||||
const body = result.data
|
const body = result.data
|
||||||
@@ -41,6 +46,7 @@ export async function POST(request: Request) {
|
|||||||
.from('mapping_rules')
|
.from('mapping_rules')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
rule_name: body.rule_name,
|
rule_name: body.rule_name,
|
||||||
rule_type: body.rule_type,
|
rule_type: body.rule_type,
|
||||||
priority: body.priority || 10,
|
priority: body.priority || 10,
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export async function GET(
|
|||||||
? supabase
|
? supabase
|
||||||
.from('deadlines')
|
.from('deadlines')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', feed.user_id)
|
.eq('company_id', feed.company_id)
|
||||||
.gte('due_date', startStr)
|
.gte('due_date', startStr)
|
||||||
.lte('due_date', endStr)
|
.lte('due_date', endStr)
|
||||||
.order('due_date')
|
.order('due_date')
|
||||||
@@ -112,7 +112,7 @@ export async function GET(
|
|||||||
? supabase
|
? supabase
|
||||||
.from('invoices')
|
.from('invoices')
|
||||||
.select('*, customer:customers(*)')
|
.select('*, customer:customers(*)')
|
||||||
.eq('user_id', feed.user_id)
|
.eq('company_id', feed.company_id)
|
||||||
.gte('due_date', startStr)
|
.gte('due_date', startStr)
|
||||||
.lte('due_date', endStr)
|
.lte('due_date', endStr)
|
||||||
.order('due_date')
|
.order('due_date')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { UpdateCalendarFeedInput } from '@/types'
|
import type { UpdateCalendarFeedInput } from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,10 +16,12 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { data: feed, error } = await supabase
|
const { data: feed, error } = await supabase
|
||||||
.from('calendar_feeds')
|
.from('calendar_feeds')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error && error.code !== 'PGRST116') {
|
if (error && error.code !== 'PGRST116') {
|
||||||
@@ -57,11 +60,13 @@ export async function POST() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Check if feed already exists
|
// Check if feed already exists
|
||||||
const { data: existingFeed } = await supabase
|
const { data: existingFeed } = await supabase
|
||||||
.from('calendar_feeds')
|
.from('calendar_feeds')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (existingFeed) {
|
if (existingFeed) {
|
||||||
@@ -76,6 +81,7 @@ export async function POST() {
|
|||||||
.from('calendar_feeds')
|
.from('calendar_feeds')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
include_tax_deadlines: true,
|
include_tax_deadlines: true,
|
||||||
include_invoices: true,
|
include_invoices: true,
|
||||||
@@ -111,12 +117,14 @@ export async function PUT(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const body: UpdateCalendarFeedInput = await request.json()
|
const body: UpdateCalendarFeedInput = await request.json()
|
||||||
|
|
||||||
const { data: feed, error } = await supabase
|
const { data: feed, error } = await supabase
|
||||||
.from('calendar_feeds')
|
.from('calendar_feeds')
|
||||||
.update(body)
|
.update(body)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
@@ -148,6 +156,8 @@ export async function DELETE() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Generate a new token by updating with a new UUID
|
// Generate a new token by updating with a new UUID
|
||||||
const { data: feed, error } = await supabase
|
const { data: feed, error } = await supabase
|
||||||
.from('calendar_feeds')
|
.from('calendar_feeds')
|
||||||
@@ -156,7 +166,7 @@ export async function DELETE() {
|
|||||||
access_count: 0,
|
access_count: 0,
|
||||||
last_accessed_at: null,
|
last_accessed_at: null,
|
||||||
})
|
})
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
|
|||||||
@@ -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 { validateBody } from '@/lib/api/validate'
|
||||||
import { UpdateCustomerSchema } from '@/lib/api/schemas'
|
import { UpdateCustomerSchema } from '@/lib/api/schemas'
|
||||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import { createLogger } from '@/lib/logger'
|
import { createLogger } from '@/lib/logger'
|
||||||
|
|
||||||
const log = createLogger('api/customers/[id]')
|
const log = createLogger('api/customers/[id]')
|
||||||
@@ -22,11 +23,13 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('customers')
|
.from('customers')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -41,7 +44,7 @@ export async function GET(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.select('id, invoice_number, invoice_date, due_date, status, total, currency')
|
.select('id, invoice_number, invoice_date, due_date, status, total, currency')
|
||||||
.eq('customer_id', id)
|
.eq('customer_id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.order('invoice_date', { ascending: false })
|
.order('invoice_date', { ascending: false })
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -67,6 +70,8 @@ export async function PATCH(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const result = await validateBody(request, UpdateCustomerSchema)
|
const result = await validateBody(request, UpdateCustomerSchema)
|
||||||
if (!result.success) return result.response
|
if (!result.success) return result.response
|
||||||
const body = result.data
|
const body = result.data
|
||||||
@@ -91,7 +96,7 @@ export async function PATCH(
|
|||||||
.from('customers')
|
.from('customers')
|
||||||
.update(updateData)
|
.update(updateData)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
@@ -113,7 +118,7 @@ export async function PATCH(
|
|||||||
vat_number_validated_at: new Date().toISOString(),
|
vat_number_validated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
data.vat_number_validated = true
|
data.vat_number_validated = true
|
||||||
data.vat_number_validated_at = new Date().toISOString()
|
data.vat_number_validated_at = new Date().toISOString()
|
||||||
@@ -125,7 +130,7 @@ export async function PATCH(
|
|||||||
vat_number_validated_at: null,
|
vat_number_validated_at: null,
|
||||||
})
|
})
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
data.vat_number_validated = false
|
data.vat_number_validated = false
|
||||||
data.vat_number_validated_at = null
|
data.vat_number_validated_at = null
|
||||||
@@ -139,7 +144,7 @@ export async function PATCH(
|
|||||||
vat_number_validated_at: null,
|
vat_number_validated_at: null,
|
||||||
})
|
})
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
data.vat_number_validated = false
|
data.vat_number_validated = false
|
||||||
data.vat_number_validated_at = null
|
data.vat_number_validated_at = null
|
||||||
@@ -167,11 +172,13 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('customers')
|
.from('customers')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init'
|
|||||||
import { validateBody } from '@/lib/api/validate'
|
import { validateBody } from '@/lib/api/validate'
|
||||||
import { CreateCustomerSchema } from '@/lib/api/schemas'
|
import { CreateCustomerSchema } from '@/lib/api/schemas'
|
||||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import { createLogger } from '@/lib/logger'
|
import { createLogger } from '@/lib/logger'
|
||||||
import type { Customer } from '@/types'
|
import type { Customer } from '@/types'
|
||||||
|
|
||||||
@@ -21,10 +22,12 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('customers')
|
.from('customers')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.order('name', { ascending: true })
|
.order('name', { ascending: true })
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -43,6 +46,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const result = await validateBody(request, CreateCustomerSchema)
|
const result = await validateBody(request, CreateCustomerSchema)
|
||||||
if (!result.success) return result.response
|
if (!result.success) return result.response
|
||||||
const body = result.data
|
const body = result.data
|
||||||
@@ -51,6 +56,7 @@ export async function POST(request: Request) {
|
|||||||
.from('customers')
|
.from('customers')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
customer_type: body.customer_type,
|
customer_type: body.customer_type,
|
||||||
email: body.email,
|
email: body.email,
|
||||||
@@ -84,7 +90,7 @@ export async function POST(request: Request) {
|
|||||||
vat_number_validated_at: new Date().toISOString(),
|
vat_number_validated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.eq('id', data.id)
|
.eq('id', data.id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
data.vat_number_validated = true
|
data.vat_number_validated = true
|
||||||
data.vat_number_validated_at = new Date().toISOString()
|
data.vat_number_validated_at = new Date().toISOString()
|
||||||
@@ -96,7 +102,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
await eventBus.emit({
|
await eventBus.emit({
|
||||||
type: 'customer.created',
|
type: 'customer.created',
|
||||||
payload: { customer: data as Customer, userId: user.id },
|
payload: { customer: data as Customer, companyId, userId: user.id },
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ data })
|
return NextResponse.json({ data })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/deadlines/[id]/complete
|
* POST /api/deadlines/[id]/complete
|
||||||
@@ -20,12 +21,14 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// First, get current deadline state
|
// First, get current deadline state
|
||||||
const { data: existing, error: fetchError } = await supabase
|
const { data: existing, error: fetchError } = await supabase
|
||||||
.from('deadlines')
|
.from('deadlines')
|
||||||
.select('is_completed')
|
.select('is_completed')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (fetchError) {
|
if (fetchError) {
|
||||||
@@ -44,7 +47,7 @@ export async function POST(
|
|||||||
completed_at: newCompletedState ? new Date().toISOString() : null,
|
completed_at: newCompletedState ? new Date().toISOString() : null,
|
||||||
})
|
})
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.select('*, customer:customers(id, name)')
|
.select('*, customer:customers(id, name)')
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { CreateDeadlineInput } from '@/types'
|
import type { CreateDeadlineInput } from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,11 +22,13 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('deadlines')
|
.from('deadlines')
|
||||||
.select('*, customer:customers(id, name)')
|
.select('*, customer:customers(id, name)')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -57,6 +60,8 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const body: Partial<CreateDeadlineInput> = await request.json()
|
const body: Partial<CreateDeadlineInput> = await request.json()
|
||||||
|
|
||||||
// First, get existing deadline to verify ownership
|
// First, get existing deadline to verify ownership
|
||||||
@@ -64,7 +69,7 @@ export async function PUT(
|
|||||||
.from('deadlines')
|
.from('deadlines')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (fetchError) {
|
if (fetchError) {
|
||||||
@@ -89,7 +94,7 @@ export async function PUT(
|
|||||||
.from('deadlines')
|
.from('deadlines')
|
||||||
.update(updateData)
|
.update(updateData)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.select('*, customer:customers(id, name)')
|
.select('*, customer:customers(id, name)')
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
@@ -119,11 +124,13 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('deadlines')
|
.from('deadlines')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine'
|
import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { DeadlineStatus } from '@/types'
|
import type { DeadlineStatus } from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +20,8 @@ export async function PATCH(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
@@ -41,7 +44,7 @@ export async function PATCH(
|
|||||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateDeadlineStatus(supabase, id, user.id, newStatus)
|
const result = await updateDeadlineStatus(supabase, id, companyId, newStatus)
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||||
@@ -66,13 +69,15 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
const { data: deadline, error } = await supabase
|
const { data: deadline, error } = await supabase
|
||||||
.from('deadlines')
|
.from('deadlines')
|
||||||
.select('status, is_completed, due_date')
|
.select('status, is_completed, due_date')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error || !deadline) {
|
if (error || !deadline) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { validateBody } from '@/lib/api/validate'
|
import { validateBody } from '@/lib/api/validate'
|
||||||
import { CreateDeadlineSchema } from '@/lib/api/schemas'
|
import { CreateDeadlineSchema } from '@/lib/api/schemas'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/deadlines
|
* GET /api/deadlines
|
||||||
@@ -23,6 +24,8 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Parse query params
|
// Parse query params
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const status = searchParams.get('status') || 'all'
|
const status = searchParams.get('status') || 'all'
|
||||||
@@ -34,7 +37,7 @@ export async function GET(request: Request) {
|
|||||||
let query = supabase
|
let query = supabase
|
||||||
.from('deadlines')
|
.from('deadlines')
|
||||||
.select('*, customer:customers(id, name)')
|
.select('*, customer:customers(id, name)')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
if (status === 'pending') {
|
if (status === 'pending') {
|
||||||
@@ -79,6 +82,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const validation = await validateBody(request, CreateDeadlineSchema)
|
const validation = await validateBody(request, CreateDeadlineSchema)
|
||||||
if (!validation.success) return validation.response
|
if (!validation.success) return validation.response
|
||||||
const body = validation.data
|
const body = validation.data
|
||||||
@@ -88,6 +93,7 @@ export async function POST(request: Request) {
|
|||||||
.from('deadlines')
|
.from('deadlines')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
title: body.title,
|
title: body.title,
|
||||||
due_date: body.due_date,
|
due_date: body.due_date,
|
||||||
due_time: body.due_time || null,
|
due_time: body.due_time || null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -39,7 +42,7 @@ export async function POST(
|
|||||||
|
|
||||||
const document = await linkToJournalEntry(
|
const document = await linkToJournalEntry(
|
||||||
supabase,
|
supabase,
|
||||||
user.id,
|
companyId,
|
||||||
id,
|
id,
|
||||||
body.journal_entry_id,
|
body.journal_entry_id,
|
||||||
body.journal_entry_line_id
|
body.journal_entry_line_id
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
// Fetch document record
|
// Fetch document record
|
||||||
@@ -27,7 +30,7 @@ export async function GET(
|
|||||||
.from('document_attachments')
|
.from('document_attachments')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (docError || !doc) {
|
if (docError || !doc) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
import { verifyIntegrity } from '@/lib/core/documents/document-service'
|
import { verifyIntegrity } from '@/lib/core/documents/document-service'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
|
|
||||||
@@ -21,10 +22,12 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await verifyIntegrity(supabase, user.id, id)
|
const result = await verifyIntegrity(supabase, companyId, id)
|
||||||
|
|
||||||
return NextResponse.json({ data: result })
|
return NextResponse.json({ data: result })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
import { createNewVersion } from '@/lib/core/documents/document-service'
|
import { createNewVersion } from '@/lib/core/documents/document-service'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -68,14 +71,16 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
// First, check if the document belongs to the user
|
// First, check if the document belongs to the company
|
||||||
const { data: doc, error: docError } = await supabase
|
const { data: doc, error: docError } = await supabase
|
||||||
.from('document_attachments')
|
.from('document_attachments')
|
||||||
.select('id, original_id')
|
.select('id, original_id')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (docError || !doc) {
|
if (docError || !doc) {
|
||||||
@@ -89,7 +94,7 @@ export async function GET(
|
|||||||
const { data: versions, error: versionsError } = await supabase
|
const { data: versions, error: versionsError } = await supabase
|
||||||
.from('document_attachments')
|
.from('document_attachments')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.or(`id.eq.${rootId},original_id.eq.${rootId}`)
|
.or(`id.eq.${rootId},original_id.eq.${rootId}`)
|
||||||
.order('version', { ascending: true })
|
.order('version', { ascending: true })
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/documents/counts?journal_entry_ids=id1,id2,...
|
* GET /api/documents/counts?journal_entry_ids=id1,id2,...
|
||||||
@@ -15,6 +16,8 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const idsParam = searchParams.get('journal_entry_ids')
|
const idsParam = searchParams.get('journal_entry_ids')
|
||||||
|
|
||||||
@@ -35,7 +38,7 @@ export async function GET(request: Request) {
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('document_attachments')
|
.from('document_attachments')
|
||||||
.select('journal_entry_id')
|
.select('journal_entry_id')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.eq('is_current_version', true)
|
.eq('is_current_version', true)
|
||||||
.in('journal_entry_id', ids)
|
.in('journal_entry_id', ids)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { runDocumentMatchingSweep } from '@/lib/documents/batch-match'
|
import { runDocumentMatchingSweep } from '@/lib/documents/batch-match'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -10,6 +11,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Optional: pass specific inbox item IDs to match
|
// Optional: pass specific inbox item IDs to match
|
||||||
let inboxItemIds: string[] | undefined
|
let inboxItemIds: string[] | undefined
|
||||||
try {
|
try {
|
||||||
@@ -22,7 +25,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runDocumentMatchingSweep(supabase, user.id, inboxItemIds)
|
const result = await runDocumentMatchingSweep(supabase, companyId, inboxItemIds)
|
||||||
return NextResponse.json({ data: result })
|
return NextResponse.json({ data: result })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[match-sweep] Failed:', error)
|
console.error('[match-sweep] Failed:', error)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData()
|
const formData = await request.formData()
|
||||||
const file = formData.get('file') as File | null
|
const file = formData.get('file') as File | null
|
||||||
@@ -38,7 +41,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const buffer = await file.arrayBuffer()
|
const buffer = await file.arrayBuffer()
|
||||||
|
|
||||||
const document = await uploadDocument(supabase, user.id, {
|
const document = await uploadDocument(supabase, user.id, companyId, {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
buffer,
|
buffer,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
@@ -77,6 +80,8 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const journalEntryId = searchParams.get('journal_entry_id')
|
const journalEntryId = searchParams.get('journal_entry_id')
|
||||||
const currentOnly = searchParams.get('current_only') !== 'false'
|
const currentOnly = searchParams.get('current_only') !== 'false'
|
||||||
@@ -86,7 +91,7 @@ export async function GET(request: Request) {
|
|||||||
let query = supabase
|
let query = supabase
|
||||||
.from('document_attachments')
|
.from('document_attachments')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.range(offset, offset + limit - 1)
|
.range(offset, offset + limit - 1)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({
|
|||||||
createClient: () => Promise.resolve(mockSupabase),
|
createClient: () => Promise.resolve(mockSupabase),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
// Mock API key auth
|
// Mock API key auth
|
||||||
const mockValidateApiKey = vi.fn()
|
const mockValidateApiKey = vi.fn()
|
||||||
const mockExtractBearerToken = vi.fn()
|
const mockExtractBearerToken = vi.fn()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
|||||||
import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||||
import { validateQuery } from '@/lib/api/validate'
|
import { validateQuery } from '@/lib/api/validate'
|
||||||
import { EventsQuerySchema } from '@/lib/api/schemas'
|
import { EventsQuerySchema } from '@/lib/api/schemas'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +41,8 @@ export async function GET(request: Request) {
|
|||||||
userId = user.id
|
userId = user.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, userId)
|
||||||
|
|
||||||
// Validate query params
|
// Validate query params
|
||||||
const result = validateQuery(request, EventsQuerySchema)
|
const result = validateQuery(request, EventsQuerySchema)
|
||||||
if (!result.success) return result.response
|
if (!result.success) return result.response
|
||||||
@@ -49,7 +52,7 @@ export async function GET(request: Request) {
|
|||||||
let query = supabase
|
let query = supabase
|
||||||
.from('event_log')
|
.from('event_log')
|
||||||
.select('sequence, event_type, entity_id, data, created_at')
|
.select('sequence, event_type, entity_id, data, created_at')
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.order('sequence', { ascending: true })
|
.order('sequence', { ascending: true })
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -13,6 +14,8 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const extensionId = `${sector}/${slug}`
|
const extensionId = `${sector}/${slug}`
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
@@ -21,7 +24,7 @@ export async function GET(
|
|||||||
let query = supabase
|
let query = supabase
|
||||||
.from('extension_data')
|
.from('extension_data')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.eq('extension_id', extensionId)
|
.eq('extension_id', extensionId)
|
||||||
|
|
||||||
const prefix = searchParams.get('prefix')
|
const prefix = searchParams.get('prefix')
|
||||||
@@ -53,6 +56,8 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { key, value } = body
|
const { key, value } = body
|
||||||
|
|
||||||
@@ -67,6 +72,7 @@ export async function POST(
|
|||||||
.upsert(
|
.upsert(
|
||||||
{
|
{
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
extension_id: extensionId,
|
extension_id: extensionId,
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
@@ -95,6 +101,8 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const key = searchParams.get('key')
|
const key = searchParams.get('key')
|
||||||
|
|
||||||
@@ -107,7 +115,7 @@ export async function DELETE(
|
|||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('extension_data')
|
.from('extension_data')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.eq('extension_id', extensionId)
|
.eq('extension_id', extensionId)
|
||||||
.eq('key', key)
|
.eq('key', key)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: Request,
|
_request: Request,
|
||||||
@@ -13,12 +14,14 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const extensionId = `${sector}/${slug}`
|
const extensionId = `${sector}/${slug}`
|
||||||
|
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('extension_data')
|
.from('extension_data')
|
||||||
.select('value')
|
.select('value')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.eq('extension_id', extensionId)
|
.eq('extension_id', extensionId)
|
||||||
.eq('key', 'settings')
|
.eq('key', 'settings')
|
||||||
.single()
|
.single()
|
||||||
@@ -38,6 +41,8 @@ export async function PATCH(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const extensionId = `${sector}/${slug}`
|
const extensionId = `${sector}/${slug}`
|
||||||
|
|
||||||
@@ -45,7 +50,7 @@ export async function PATCH(
|
|||||||
const { data: existing } = await supabase
|
const { data: existing } = await supabase
|
||||||
.from('extension_data')
|
.from('extension_data')
|
||||||
.select('value')
|
.select('value')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.eq('extension_id', extensionId)
|
.eq('extension_id', extensionId)
|
||||||
.eq('key', 'settings')
|
.eq('key', 'settings')
|
||||||
.single()
|
.single()
|
||||||
@@ -57,6 +62,7 @@ export async function PATCH(
|
|||||||
.upsert(
|
.upsert(
|
||||||
{
|
{
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
extension_id: extensionId,
|
extension_id: extensionId,
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
value: mergedSettings,
|
value: mergedSettings,
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export async function GET(request: Request) {
|
|||||||
// Look up pending connection by oauth_state (CSRF-safe)
|
// Look up pending connection by oauth_state (CSRF-safe)
|
||||||
const { data: pendingConnection, error: findError } = await supabase
|
const { data: pendingConnection, error: findError } = await supabase
|
||||||
.from('bank_connections')
|
.from('bank_connections')
|
||||||
.select('id, user_id')
|
.select('id, user_id, company_id')
|
||||||
.eq('oauth_state', state)
|
.eq('oauth_state', state)
|
||||||
.eq('status', 'pending')
|
.eq('status', 'pending')
|
||||||
.single()
|
.single()
|
||||||
@@ -98,6 +98,7 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = pendingConnection.user_id
|
const userId = pendingConnection.user_id
|
||||||
|
const companyId = pendingConnection.company_id
|
||||||
|
|
||||||
console.log('[enable-banking] Exchanging code for session', {
|
console.log('[enable-banking] Exchanging code for session', {
|
||||||
connectionId: pendingConnection.id,
|
connectionId: pendingConnection.id,
|
||||||
@@ -166,7 +167,7 @@ export async function GET(request: Request) {
|
|||||||
const { data: userSettings } = await supabase
|
const { data: userSettings } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('onboarding_complete')
|
.select('onboarding_complete')
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
const redirectTarget = userSettings?.onboarding_complete
|
const redirectTarget = userSettings?.onboarding_complete
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export async function GET(request: Request) {
|
|||||||
const { data: sieOverlap } = await supabase
|
const { data: sieOverlap } = await supabase
|
||||||
.from('sie_imports')
|
.from('sie_imports')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('user_id', connection.user_id)
|
.eq('company_id', connection.company_id)
|
||||||
.eq('status', 'completed')
|
.eq('status', 'completed')
|
||||||
.gte('fiscal_year_end', fromDate)
|
.gte('fiscal_year_end', fromDate)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -156,6 +156,7 @@ export async function GET(request: Request) {
|
|||||||
const syncResults = await Promise.all(
|
const syncResults = await Promise.all(
|
||||||
accounts.map(account => syncAccountTransactions(
|
accounts.map(account => syncAccountTransactions(
|
||||||
supabase,
|
supabase,
|
||||||
|
connection.company_id,
|
||||||
connection.user_id,
|
connection.user_id,
|
||||||
connection.id,
|
connection.id,
|
||||||
account,
|
account,
|
||||||
@@ -284,7 +285,7 @@ async function sendConsentExpiryNotification(
|
|||||||
const { data: companySettings } = await supabase
|
const { data: companySettings } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('company_name')
|
.select('company_name')
|
||||||
.eq('user_id', userId)
|
.eq('company_id', connection.company_id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
const emailData = {
|
const emailData = {
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ vi.mock('@/lib/init', () => ({
|
|||||||
ensureInitialized: vi.fn(),
|
ensureInitialized: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/lib/extensions/context-factory', () => ({
|
vi.mock('@/lib/extensions/context-factory', () => ({
|
||||||
createExtensionContext: vi.fn().mockReturnValue({
|
createExtensionContext: vi.fn().mockReturnValue({
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
|
|||||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||||
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
|
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { ApiRouteDefinition } from '@/lib/extensions/types'
|
import type { ApiRouteDefinition } from '@/lib/extensions/types'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
@@ -117,9 +118,11 @@ async function handleRequest(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// AI consent check
|
// AI consent check
|
||||||
if (isAiExtension(extensionId)) {
|
if (isAiExtension(extensionId)) {
|
||||||
const consented = await hasAiConsent(supabase, user.id, extensionId)
|
const consented = await hasAiConsent(supabase, companyId, extensionId)
|
||||||
if (!consented) {
|
if (!consented) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' },
|
{ error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' },
|
||||||
@@ -145,7 +148,7 @@ async function handleRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build context and dispatch
|
// Build context and dispatch
|
||||||
const ctx = createExtensionContext(supabase, user.id, extensionId)
|
const ctx = createExtensionContext(supabase, user.id, companyId, extensionId)
|
||||||
return matchedRoute.handler(handlerRequest, ctx)
|
return matchedRoute.handler(handlerRequest, ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ describe('Invoice Inbox Webhook Route', () => {
|
|||||||
created_at: '2024-06-15T10:00:00Z',
|
created_at: '2024-06-15T10:00:00Z',
|
||||||
})
|
})
|
||||||
mockExtractAttachments.mockReturnValue([])
|
mockExtractAttachments.mockReturnValue([])
|
||||||
mockResolveUserFromEmail.mockResolvedValue('user-1')
|
mockResolveUserFromEmail.mockResolvedValue({ userId: 'user-1', companyId: 'company-1' })
|
||||||
|
|
||||||
// Insert inbox item with error status
|
// Insert inbox item with error status
|
||||||
enqueueMany([
|
enqueueMany([
|
||||||
|
|||||||
@@ -79,13 +79,15 @@ export async function POST(request: Request) {
|
|||||||
const supabase = createServiceClient()
|
const supabase = createServiceClient()
|
||||||
|
|
||||||
// Resolve user from recipient email
|
// Resolve user from recipient email
|
||||||
const userId = await resolveUserFromEmail(payload.to, supabase)
|
const resolved = await resolveUserFromEmail(payload.to, supabase)
|
||||||
|
|
||||||
if (!userId) {
|
if (!resolved) {
|
||||||
console.warn(`[document-inbox] No user found for email: ${payload.to}`)
|
console.warn(`[document-inbox] No user found for email: ${payload.to}`)
|
||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { userId, companyId } = resolved
|
||||||
|
|
||||||
// Build raw email payload for BFL 7:2 archiving (no binary attachment content)
|
// Build raw email payload for BFL 7:2 archiving (no binary attachment content)
|
||||||
const rawEmailPayload = buildRawEmailPayload(body, payload)
|
const rawEmailPayload = buildRawEmailPayload(body, payload)
|
||||||
|
|
||||||
@@ -96,6 +98,7 @@ export async function POST(request: Request) {
|
|||||||
await supabase
|
await supabase
|
||||||
.from('invoice_inbox_items')
|
.from('invoice_inbox_items')
|
||||||
.insert({
|
.insert({
|
||||||
|
company_id: companyId,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
source: 'email',
|
source: 'email',
|
||||||
@@ -131,6 +134,7 @@ export async function POST(request: Request) {
|
|||||||
const { data: document, error: docError } = await supabase
|
const { data: document, error: docError } = await supabase
|
||||||
.from('document_attachments')
|
.from('document_attachments')
|
||||||
.insert({
|
.insert({
|
||||||
|
company_id: companyId,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
storage_path: storagePath,
|
storage_path: storagePath,
|
||||||
file_name: attachment.filename,
|
file_name: attachment.filename,
|
||||||
@@ -159,6 +163,7 @@ export async function POST(request: Request) {
|
|||||||
const { data: inboxItem, error: itemError } = await supabase
|
const { data: inboxItem, error: itemError } = await supabase
|
||||||
.from('invoice_inbox_items')
|
.from('invoice_inbox_items')
|
||||||
.insert({
|
.insert({
|
||||||
|
company_id: companyId,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
status: 'processing',
|
status: 'processing',
|
||||||
source: 'email',
|
source: 'email',
|
||||||
@@ -197,7 +202,7 @@ export async function POST(request: Request) {
|
|||||||
const { data: suppliers } = await supabase
|
const { data: suppliers } = await supabase
|
||||||
.from('suppliers')
|
.from('suppliers')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (suppliers && suppliers.length > 0) {
|
if (suppliers && suppliers.length > 0) {
|
||||||
const match = matchSupplier(extraction, suppliers)
|
const match = matchSupplier(extraction, suppliers)
|
||||||
@@ -222,7 +227,7 @@ export async function POST(request: Request) {
|
|||||||
// Use pre-extracted receipt data from unified call
|
// Use pre-extracted receipt data from unified call
|
||||||
const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath)
|
const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath)
|
||||||
|
|
||||||
const result = await processReceiptFromDocument(supabase, userId, attachment.content, attachment.content_type, {
|
const result = await processReceiptFromDocument(supabase, userId, companyId, attachment.content, attachment.content_type, {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
source: 'email',
|
source: 'email',
|
||||||
emailFrom: payload.from,
|
emailFrom: payload.from,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { eventBus } from '@/lib/events'
|
|||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
|
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
|
||||||
import { generateExternalId } from '@/lib/import/bank-file/parser'
|
import { generateExternalId } from '@/lib/import/bank-file/parser'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
|
import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
|
||||||
import type { Transaction } from '@/types'
|
import type { Transaction } from '@/types'
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const body: ExecuteRequest = await request.json()
|
const body: ExecuteRequest = await request.json()
|
||||||
const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true } = body
|
const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true } = body
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ export async function POST(request: Request) {
|
|||||||
.from('bank_file_imports')
|
.from('bank_file_imports')
|
||||||
.upsert({
|
.upsert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
filename,
|
filename,
|
||||||
file_hash,
|
file_hash,
|
||||||
file_format: format,
|
file_format: format,
|
||||||
@@ -76,7 +80,7 @@ export async function POST(request: Request) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Run ingestion pipeline
|
// Run ingestion pipeline
|
||||||
const ingestResult = await ingestTransactions(supabase, user.id, rawTransactions)
|
const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions)
|
||||||
|
|
||||||
// Update import record with results
|
// Update import record with results
|
||||||
await supabase
|
await supabase
|
||||||
@@ -106,6 +110,7 @@ export async function POST(request: Request) {
|
|||||||
payload: {
|
payload: {
|
||||||
transactions: importedTransactions as Transaction[],
|
transactions: importedTransactions as Transaction[],
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
companyId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser'
|
import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser'
|
||||||
import { decodeFileContent } from '@/lib/import/bank-file/encoding'
|
import { decodeFileContent } from '@/lib/import/bank-file/encoding'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
|
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +19,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const formData = await request.formData()
|
const formData = await request.formData()
|
||||||
const file = formData.get('file') as File | null
|
const file = formData.get('file') as File | null
|
||||||
const formatOverride = formData.get('format') as BankFileFormatId | null
|
const formatOverride = formData.get('format') as BankFileFormatId | null
|
||||||
@@ -41,7 +44,7 @@ export async function POST(request: Request) {
|
|||||||
const { data: existingImport } = await supabase
|
const { data: existingImport } = await supabase
|
||||||
.from('bank_file_imports')
|
.from('bank_file_imports')
|
||||||
.select('id, status, imported_count, created_at')
|
.select('id, status, imported_count, created_at')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.eq('file_hash', fileHash)
|
.eq('file_hash', fileHash)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
@@ -67,7 +70,7 @@ export async function POST(request: Request) {
|
|||||||
const { count } = await supabase
|
const { count } = await supabase
|
||||||
.from('transactions')
|
.from('transactions')
|
||||||
.select('*', { count: 'exact', head: true })
|
.select('*', { count: 'exact', head: true })
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.gte('date', parseResult.date_from || '1970-01-01')
|
.gte('date', parseResult.date_from || '1970-01-01')
|
||||||
.lte('date', parseResult.date_to || '2099-12-31')
|
.lte('date', parseResult.date_to || '2099-12-31')
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/import/sie/[id]
|
* GET /api/import/sie/[id]
|
||||||
@@ -20,11 +21,13 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('sie_imports')
|
.from('sie_imports')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -62,12 +65,14 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Check current status before deleting
|
// Check current status before deleting
|
||||||
const { data: importRecord } = await supabase
|
const { data: importRecord } = await supabase
|
||||||
.from('sie_imports')
|
.from('sie_imports')
|
||||||
.select('status')
|
.select('status')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (!importRecord) {
|
if (!importRecord) {
|
||||||
@@ -84,7 +89,7 @@ export async function DELETE(
|
|||||||
.from('sie_imports')
|
.from('sie_imports')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { SIEAccount } from '@/lib/import/types'
|
import type { SIEAccount } from '@/lib/import/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +64,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const accounts: SIEAccount[] = body.accounts
|
const accounts: SIEAccount[] = body.accounts
|
||||||
@@ -80,6 +83,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
account_number: account.number,
|
account_number: account.number,
|
||||||
account_name: account.name,
|
account_name: account.name,
|
||||||
account_class: accountClass,
|
account_class: accountClass,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
|
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
|
||||||
import { suggestMappings } from '@/lib/import/account-mapper'
|
import { suggestMappings } from '@/lib/import/account-mapper'
|
||||||
import { executeSIEImport } from '@/lib/import/sie-import'
|
import { executeSIEImport } from '@/lib/import/sie-import'
|
||||||
@@ -26,6 +27,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get form data with file and options
|
// Get form data with file and options
|
||||||
const formData = await request.formData()
|
const formData = await request.formData()
|
||||||
@@ -63,7 +66,7 @@ export async function POST(request: Request) {
|
|||||||
const { data: storedMappings } = await supabase
|
const { data: storedMappings } = await supabase
|
||||||
.from('sie_account_mappings')
|
.from('sie_account_mappings')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
mappings = suggestMappings(
|
mappings = suggestMappings(
|
||||||
parsed.accounts,
|
parsed.accounts,
|
||||||
@@ -90,14 +93,15 @@ export async function POST(request: Request) {
|
|||||||
...new Set(mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)),
|
...new Set(mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)),
|
||||||
]
|
]
|
||||||
|
|
||||||
const existingAccounts = await fetchAllRows(({ from, to }) =>
|
const allCompanyAccounts = await fetchAllRows(({ from, to }) =>
|
||||||
supabase
|
supabase
|
||||||
.from('chart_of_accounts')
|
.from('chart_of_accounts')
|
||||||
.select('account_number')
|
.select('account_number')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.in('account_number', mappedAccountNumbers)
|
|
||||||
.range(from, to)
|
.range(from, to)
|
||||||
)
|
)
|
||||||
|
const mappedSet = new Set(mappedAccountNumbers)
|
||||||
|
const existingAccounts = allCompanyAccounts.filter((a) => mappedSet.has(a.account_number))
|
||||||
|
|
||||||
// Build a lookup from SIE mappings for account names (used for bas_range accounts)
|
// Build a lookup from SIE mappings for account names (used for bas_range accounts)
|
||||||
const mappingNameLookup = new Map<string, string>()
|
const mappingNameLookup = new Map<string, string>()
|
||||||
@@ -116,6 +120,7 @@ export async function POST(request: Request) {
|
|||||||
// Account exists in BAS reference — use full metadata
|
// Account exists in BAS reference — use full metadata
|
||||||
return {
|
return {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
account_number: ref.account_number,
|
account_number: ref.account_number,
|
||||||
account_name: ref.account_name,
|
account_name: ref.account_name,
|
||||||
account_class: ref.account_class,
|
account_class: ref.account_class,
|
||||||
@@ -146,6 +151,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
account_number: num,
|
account_number: num,
|
||||||
account_name: accountName,
|
account_name: accountName,
|
||||||
account_class: accountClass,
|
account_class: accountClass,
|
||||||
@@ -176,6 +182,7 @@ export async function POST(request: Request) {
|
|||||||
// Execute the import
|
// Execute the import
|
||||||
const result = await executeSIEImport(
|
const result = await executeSIEImport(
|
||||||
supabase,
|
supabase,
|
||||||
|
companyId,
|
||||||
user.id,
|
user.id,
|
||||||
parsed,
|
parsed,
|
||||||
mappings,
|
mappings,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { saveMappings } from '@/lib/import/sie-import'
|
import { saveMappings } from '@/lib/import/sie-import'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { AccountMapping } from '@/lib/import/types'
|
import type { AccountMapping } from '@/lib/import/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,10 +19,12 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('sie_account_mappings')
|
.from('sie_account_mappings')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.order('source_account')
|
.order('source_account')
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -46,6 +49,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const mappings: AccountMapping[] = body.mappings
|
const mappings: AccountMapping[] = body.mappings
|
||||||
|
|
||||||
@@ -79,6 +84,8 @@ export async function PUT(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { sourceAccount, targetAccount } = body
|
const { sourceAccount, targetAccount } = body
|
||||||
|
|
||||||
@@ -93,6 +100,7 @@ export async function PUT(request: Request) {
|
|||||||
.from('sie_account_mappings')
|
.from('sie_account_mappings')
|
||||||
.upsert({
|
.upsert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
source_account: sourceAccount,
|
source_account: sourceAccount,
|
||||||
target_account: targetAccount,
|
target_account: targetAccount,
|
||||||
confidence: 1.0,
|
confidence: 1.0,
|
||||||
@@ -125,6 +133,8 @@ export async function DELETE(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const sourceAccount = searchParams.get('sourceAccount')
|
const sourceAccount = searchParams.get('sourceAccount')
|
||||||
|
|
||||||
@@ -133,7 +143,7 @@ export async function DELETE(request: Request) {
|
|||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('sie_account_mappings')
|
.from('sie_account_mappings')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.eq('source_account', sourceAccount)
|
.eq('source_account', sourceAccount)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -144,7 +154,7 @@ export async function DELETE(request: Request) {
|
|||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('sie_account_mappings')
|
.from('sie_account_mappings')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import {
|
import {
|
||||||
parseSIEFile,
|
parseSIEFile,
|
||||||
validateSIEFile,
|
validateSIEFile,
|
||||||
@@ -27,6 +28,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get form data with file
|
// Get form data with file
|
||||||
const formData = await request.formData()
|
const formData = await request.formData()
|
||||||
@@ -91,7 +94,7 @@ export async function POST(request: Request) {
|
|||||||
const { data: storedMappings } = await supabase
|
const { data: storedMappings } = await supabase
|
||||||
.from('sie_account_mappings')
|
.from('sie_account_mappings')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
// Match against the full BAS reference (1,276 accounts) instead of only
|
// Match against the full BAS reference (1,276 accounts) instead of only
|
||||||
// the user's active chart (~40 accounts). Accounts that match will be
|
// the user's active chart (~40 accounts). Accounts that match will be
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/import/sie
|
* GET /api/import/sie
|
||||||
@@ -16,6 +17,8 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Parse query params
|
// Parse query params
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const limit = parseInt(searchParams.get('limit') || '20', 10)
|
const limit = parseInt(searchParams.get('limit') || '20', 10)
|
||||||
@@ -25,7 +28,7 @@ export async function GET(request: Request) {
|
|||||||
let query = supabase
|
let query = supabase
|
||||||
.from('sie_imports')
|
.from('sie_imports')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.range(offset, offset + limit - 1)
|
.range(offset, offset + limit - 1)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { eventBus } from '@/lib/events'
|
import { eventBus } from '@/lib/events'
|
||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { Invoice } from '@/types'
|
import type { Invoice } from '@/types'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
@@ -25,12 +26,14 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Fetch proforma with items
|
// Fetch proforma with items
|
||||||
const { data: proforma, error: proformaError } = await supabase
|
const { data: proforma, error: proformaError } = await supabase
|
||||||
.from('invoices')
|
.from('invoices')
|
||||||
.select('*, items:invoice_items(*)')
|
.select('*, items:invoice_items(*)')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (proformaError || !proforma) {
|
if (proformaError || !proforma) {
|
||||||
@@ -61,6 +64,7 @@ export async function POST(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
customer_id: proforma.customer_id,
|
customer_id: proforma.customer_id,
|
||||||
invoice_number: invoiceNumber,
|
invoice_number: invoiceNumber,
|
||||||
invoice_date: new Date().toISOString().split('T')[0],
|
invoice_date: new Date().toISOString().split('T')[0],
|
||||||
@@ -129,7 +133,7 @@ export async function POST(
|
|||||||
if (completeInvoice) {
|
if (completeInvoice) {
|
||||||
await eventBus.emit({
|
await eventBus.emit({
|
||||||
type: 'invoice.created',
|
type: 'invoice.created',
|
||||||
payload: { invoice: completeInvoice as Invoice, userId: user.id },
|
payload: { invoice: completeInvoice as Invoice, companyId, userId: user.id },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ vi.mock('@/lib/init', () => ({
|
|||||||
ensureInitialized: vi.fn(),
|
ensureInitialized: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
const mockCreateInvoicePaymentJournalEntry = vi.fn()
|
const mockCreateInvoicePaymentJournalEntry = vi.fn()
|
||||||
const mockCreateInvoiceCashEntry = vi.fn()
|
const mockCreateInvoiceCashEntry = vi.fn()
|
||||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||||
@@ -137,6 +141,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
|||||||
expect(body.journal_entry_id).toBe('je-1')
|
expect(body.journal_entry_id).toBe('je-1')
|
||||||
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
|
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
'company-1',
|
||||||
'user-1',
|
'user-1',
|
||||||
expect.objectContaining({ id: 'inv-1' }),
|
expect.objectContaining({ id: 'inv-1' }),
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
@@ -172,6 +177,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
|||||||
expect(body.journal_entry_id).toBe('je-2')
|
expect(body.journal_entry_id).toBe('je-2')
|
||||||
expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith(
|
expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
'company-1',
|
||||||
'user-1',
|
'user-1',
|
||||||
expect.objectContaining({ id: 'inv-1' }),
|
expect.objectContaining({ id: 'inv-1' }),
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
@@ -235,6 +241,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
|||||||
// Should call createJournalEntry directly with custom lines
|
// Should call createJournalEntry directly with custom lines
|
||||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
'company-1',
|
||||||
'user-1',
|
'user-1',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
entry_date: '2025-03-17',
|
entry_date: '2025-03-17',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||||
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
|
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
|
||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
|
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
@@ -35,12 +36,14 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Fetch invoice
|
// Fetch invoice
|
||||||
const { data: invoice, error: invoiceError } = await supabase
|
const { data: invoice, error: invoiceError } = await supabase
|
||||||
.from('invoices')
|
.from('invoices')
|
||||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (invoiceError || !invoice) {
|
if (invoiceError || !invoice) {
|
||||||
@@ -83,7 +86,7 @@ export async function POST(
|
|||||||
const { data: settings } = await supabase
|
const { data: settings } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('accounting_method, entity_type')
|
.select('accounting_method, entity_type')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||||
@@ -107,7 +110,7 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User-provided lines from PaymentBookingDialog
|
// User-provided lines from PaymentBookingDialog
|
||||||
const fiscalPeriodId = await findFiscalPeriod(supabase, user.id, paymentDate)
|
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
|
||||||
if (!fiscalPeriodId) {
|
if (!fiscalPeriodId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Ingen öppen räkenskapsperiod för betalningsdatumet' },
|
{ error: 'Ingen öppen räkenskapsperiod för betalningsdatumet' },
|
||||||
@@ -125,12 +128,13 @@ export async function POST(
|
|||||||
source_id: invoice.id,
|
source_id: invoice.id,
|
||||||
lines: customLines,
|
lines: customLines,
|
||||||
}
|
}
|
||||||
const journalEntry = await createJournalEntry(supabase, user.id, input)
|
const journalEntry = await createJournalEntry(supabase, companyId, user.id, input)
|
||||||
journalEntryId = journalEntry?.id ?? null
|
journalEntryId = journalEntry?.id ?? null
|
||||||
} else if (accountingMethod === 'accrual') {
|
} else if (accountingMethod === 'accrual') {
|
||||||
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
|
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
|
||||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||||
supabase,
|
supabase,
|
||||||
|
companyId,
|
||||||
user.id,
|
user.id,
|
||||||
invoice as Invoice,
|
invoice as Invoice,
|
||||||
paymentDate,
|
paymentDate,
|
||||||
@@ -142,6 +146,7 @@ export async function POST(
|
|||||||
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
|
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
|
||||||
const journalEntry = await createInvoiceCashEntry(
|
const journalEntry = await createInvoiceCashEntry(
|
||||||
supabase,
|
supabase,
|
||||||
|
companyId,
|
||||||
user.id,
|
user.id,
|
||||||
invoice as Invoice,
|
invoice as Invoice,
|
||||||
paymentDate,
|
paymentDate,
|
||||||
@@ -168,7 +173,7 @@ export async function POST(
|
|||||||
paid_amount: invoice.total,
|
paid_amount: invoice.total,
|
||||||
})
|
})
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
|
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { EntityType, Invoice } from '@/types'
|
import type { EntityType, Invoice } from '@/types'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
@@ -26,12 +27,14 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Fetch invoice
|
// Fetch invoice
|
||||||
const { data: invoice, error: invoiceError } = await supabase
|
const { data: invoice, error: invoiceError } = await supabase
|
||||||
.from('invoices')
|
.from('invoices')
|
||||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (invoiceError || !invoice) {
|
if (invoiceError || !invoice) {
|
||||||
@@ -50,7 +53,7 @@ export async function POST(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.update({ status: 'sent' })
|
.update({ status: 'sent' })
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
|
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
|
||||||
@@ -60,7 +63,7 @@ export async function POST(
|
|||||||
const { data: settings } = await supabase
|
const { data: settings } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('accounting_method, entity_type')
|
.select('accounting_method, entity_type')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||||
@@ -72,6 +75,7 @@ export async function POST(
|
|||||||
try {
|
try {
|
||||||
const journalEntry = await createInvoiceJournalEntry(
|
const journalEntry = await createInvoiceJournalEntry(
|
||||||
supabase,
|
supabase,
|
||||||
|
companyId,
|
||||||
user.id,
|
user.id,
|
||||||
invoice as Invoice,
|
invoice as Invoice,
|
||||||
(settings?.entity_type as EntityType) || 'enskild_firma',
|
(settings?.entity_type as EntityType) || 'enskild_firma',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { renderToBuffer } from '@react-pdf/renderer'
|
import { renderToBuffer } from '@react-pdf/renderer'
|
||||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -17,6 +18,8 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Fetch invoice with customer and items
|
// Fetch invoice with customer and items
|
||||||
const { data: invoice, error: invoiceError } = await supabase
|
const { data: invoice, error: invoiceError } = await supabase
|
||||||
.from('invoices')
|
.from('invoices')
|
||||||
@@ -26,7 +29,7 @@ export async function GET(
|
|||||||
items:invoice_items(*)
|
items:invoice_items(*)
|
||||||
`)
|
`)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (invoiceError || !invoice) {
|
if (invoiceError || !invoice) {
|
||||||
@@ -37,7 +40,7 @@ export async function GET(
|
|||||||
const { data: company, error: companyError } = await supabase
|
const { data: company, error: companyError } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (companyError || !company) {
|
if (companyError || !company) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/invoices/[id]
|
* DELETE /api/invoices/[id]
|
||||||
@@ -21,12 +22,14 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Fetch invoice to verify ownership and status
|
// Fetch invoice to verify ownership and status
|
||||||
const { data: invoice, error: fetchError } = await supabase
|
const { data: invoice, error: fetchError } = await supabase
|
||||||
.from('invoices')
|
.from('invoices')
|
||||||
.select('id, status, user_id')
|
.select('id, status, user_id')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (fetchError || !invoice) {
|
if (fetchError || !invoice) {
|
||||||
@@ -54,7 +57,7 @@ export async function DELETE(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (deleteError) {
|
if (deleteError) {
|
||||||
return NextResponse.json({ error: deleteError.message }, { status: 500 })
|
return NextResponse.json({ error: deleteError.message }, { status: 500 })
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ vi.mock('@/lib/init', () => ({
|
|||||||
ensureInitialized: vi.fn(),
|
ensureInitialized: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
const mockRenderToBuffer = vi.fn()
|
const mockRenderToBuffer = vi.fn()
|
||||||
vi.mock('@react-pdf/renderer', () => ({
|
vi.mock('@react-pdf/renderer', () => ({
|
||||||
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
|
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
|
||||||
@@ -184,6 +189,7 @@ describe('POST /api/invoices/[id]/send', () => {
|
|||||||
)
|
)
|
||||||
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
|
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
'company-1',
|
||||||
'user-1',
|
'user-1',
|
||||||
expect.objectContaining({ id: 'inv-1' }),
|
expect.objectContaining({ id: 'inv-1' }),
|
||||||
'enskild_firma'
|
'enskild_firma'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@/lib/email/invoice-templates'
|
} from '@/lib/email/invoice-templates'
|
||||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
@@ -29,6 +30,8 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Check if email is configured
|
// Check if email is configured
|
||||||
const emailService = getEmailService()
|
const emailService = getEmailService()
|
||||||
if (!emailService.isConfigured()) {
|
if (!emailService.isConfigured()) {
|
||||||
@@ -47,7 +50,7 @@ export async function POST(
|
|||||||
items:invoice_items(*)
|
items:invoice_items(*)
|
||||||
`)
|
`)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (invoiceError || !invoice) {
|
if (invoiceError || !invoice) {
|
||||||
@@ -67,7 +70,7 @@ export async function POST(
|
|||||||
const { data: company, error: companyError } = await supabase
|
const { data: company, error: companyError } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (companyError || !company) {
|
if (companyError || !company) {
|
||||||
@@ -161,7 +164,7 @@ export async function POST(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.update({ status: 'sent' })
|
.update({ status: 'sent' })
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
console.error('Failed to update invoice status:', updateError)
|
console.error('Failed to update invoice status:', updateError)
|
||||||
@@ -175,6 +178,7 @@ export async function POST(
|
|||||||
try {
|
try {
|
||||||
const journalEntry = await createInvoiceJournalEntry(
|
const journalEntry = await createInvoiceJournalEntry(
|
||||||
supabase,
|
supabase,
|
||||||
|
companyId,
|
||||||
user.id,
|
user.id,
|
||||||
invoice as Invoice,
|
invoice as Invoice,
|
||||||
(company as CompanySettings).entity_type
|
(company as CompanySettings).entity_type
|
||||||
@@ -196,7 +200,7 @@ export async function POST(
|
|||||||
if (isRealInvoice) {
|
if (isRealInvoice) {
|
||||||
try {
|
try {
|
||||||
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
||||||
await uploadDocument(supabase, user.id, {
|
await uploadDocument(supabase, user.id, companyId, {
|
||||||
name: filename,
|
name: filename,
|
||||||
buffer: pdfArrayBuffer,
|
buffer: pdfArrayBuffer,
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
@@ -212,7 +216,7 @@ export async function POST(
|
|||||||
|
|
||||||
await eventBus.emit({
|
await eventBus.emit({
|
||||||
type: 'invoice.sent',
|
type: 'invoice.sent',
|
||||||
payload: { invoice: invoice as Invoice, userId: user.id },
|
payload: { invoice: invoice as Invoice, companyId, userId: user.id },
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ vi.mock('@/lib/init', () => ({
|
|||||||
ensureInitialized: vi.fn(),
|
ensureInitialized: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
const mockGetVatRules = vi.fn()
|
const mockGetVatRules = vi.fn()
|
||||||
const mockCalculateVat = vi.fn()
|
const mockCalculateVat = vi.fn()
|
||||||
const mockGetAvailableVatRates = vi.fn()
|
const mockGetAvailableVatRates = vi.fn()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
|||||||
import { renderToBuffer } from '@react-pdf/renderer'
|
import { renderToBuffer } from '@react-pdf/renderer'
|
||||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||||
import { getVatRules } from '@/lib/invoices/vat-rules'
|
import { getVatRules } from '@/lib/invoices/vat-rules'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +21,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { customer_id, invoice_date, due_date, currency, items, your_reference, our_reference, notes, document_type } = body
|
const { customer_id, invoice_date, due_date, currency, items, your_reference, our_reference, notes, document_type } = body
|
||||||
|
|
||||||
@@ -32,7 +35,7 @@ export async function POST(request: Request) {
|
|||||||
.from('customers')
|
.from('customers')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', customer_id)
|
.eq('id', customer_id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (customerError || !customer) {
|
if (customerError || !customer) {
|
||||||
@@ -43,7 +46,7 @@ export async function POST(request: Request) {
|
|||||||
const { data: company, error: companyError } = await supabase
|
const { data: company, error: companyError } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (companyError || !company) {
|
if (companyError || !company) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
|||||||
import {
|
import {
|
||||||
createCreditNoteJournalEntry,
|
createCreditNoteJournalEntry,
|
||||||
} from '@/lib/bookkeeping/invoice-entries'
|
} from '@/lib/bookkeeping/invoice-entries'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const status = searchParams.get('status')
|
const status = searchParams.get('status')
|
||||||
const limit = parseInt(searchParams.get('limit') || '50')
|
const limit = parseInt(searchParams.get('limit') || '50')
|
||||||
@@ -29,7 +32,7 @@ export async function GET(request: Request) {
|
|||||||
let query = supabase
|
let query = supabase
|
||||||
.from('invoices')
|
.from('invoices')
|
||||||
.select('*, customer:customers(*)', { count: 'exact' })
|
.select('*, customer:customers(*)', { count: 'exact' })
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.order('invoice_date', { ascending: false })
|
.order('invoice_date', { ascending: false })
|
||||||
.range(offset, offset + limit - 1)
|
.range(offset, offset + limit - 1)
|
||||||
|
|
||||||
@@ -55,6 +58,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
let rawBody: unknown
|
let rawBody: unknown
|
||||||
try {
|
try {
|
||||||
rawBody = await request.json()
|
rawBody = await request.json()
|
||||||
@@ -78,7 +83,7 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return createCreditNote(supabase, user.id, parsed.data)
|
return createCreditNote(supabase, companyId, user.id, parsed.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = CreateInvoiceSchema.safeParse(rawBody)
|
const parsed = CreateInvoiceSchema.safeParse(rawBody)
|
||||||
@@ -100,7 +105,7 @@ export async function POST(request: Request) {
|
|||||||
.from('customers')
|
.from('customers')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', invoiceInput.customer_id)
|
.eq('id', invoiceInput.customer_id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (customerError || !customer) {
|
if (customerError || !customer) {
|
||||||
@@ -178,6 +183,7 @@ export async function POST(request: Request) {
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
customer_id: invoiceInput.customer_id,
|
customer_id: invoiceInput.customer_id,
|
||||||
invoice_number: invoiceNumber,
|
invoice_number: invoiceNumber,
|
||||||
invoice_date: invoiceInput.invoice_date,
|
invoice_date: invoiceInput.invoice_date,
|
||||||
@@ -246,7 +252,7 @@ export async function POST(request: Request) {
|
|||||||
if (completeInvoice && documentType === 'invoice') {
|
if (completeInvoice && documentType === 'invoice') {
|
||||||
await eventBus.emit({
|
await eventBus.emit({
|
||||||
type: 'invoice.created',
|
type: 'invoice.created',
|
||||||
payload: { invoice: completeInvoice as Invoice, userId: user.id },
|
payload: { invoice: completeInvoice as Invoice, companyId, userId: user.id },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +262,7 @@ export async function POST(request: Request) {
|
|||||||
// Create a credit note for an existing invoice
|
// Create a credit note for an existing invoice
|
||||||
async function createCreditNote(
|
async function createCreditNote(
|
||||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||||
|
companyId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
input: { credited_invoice_id: string; reason?: string }
|
input: { credited_invoice_id: string; reason?: string }
|
||||||
) {
|
) {
|
||||||
@@ -264,7 +271,7 @@ async function createCreditNote(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.select('*, items:invoice_items(*)')
|
.select('*, items:invoice_items(*)')
|
||||||
.eq('id', input.credited_invoice_id)
|
.eq('id', input.credited_invoice_id)
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (originalError || !originalInvoice) {
|
if (originalError || !originalInvoice) {
|
||||||
@@ -300,6 +307,7 @@ async function createCreditNote(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
company_id: companyId,
|
||||||
customer_id: originalInvoice.customer_id,
|
customer_id: originalInvoice.customer_id,
|
||||||
invoice_number: creditNoteNumber,
|
invoice_number: creditNoteNumber,
|
||||||
invoice_date: new Date().toISOString().split('T')[0],
|
invoice_date: new Date().toISOString().split('T')[0],
|
||||||
@@ -373,7 +381,7 @@ async function createCreditNote(
|
|||||||
const { data: creditNoteSettings } = await supabase
|
const { data: creditNoteSettings } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('entity_type, accounting_method')
|
.select('entity_type, accounting_method')
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
|
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
|
||||||
@@ -385,6 +393,7 @@ async function createCreditNote(
|
|||||||
try {
|
try {
|
||||||
const journalEntry = await createCreditNoteJournalEntry(
|
const journalEntry = await createCreditNoteJournalEntry(
|
||||||
supabase,
|
supabase,
|
||||||
|
companyId,
|
||||||
userId,
|
userId,
|
||||||
completeCreditNote as Invoice,
|
completeCreditNote as Invoice,
|
||||||
entityType,
|
entityType,
|
||||||
@@ -402,7 +411,7 @@ async function createCreditNote(
|
|||||||
|
|
||||||
await eventBus.emit({
|
await eventBus.emit({
|
||||||
type: 'credit_note.created',
|
type: 'credit_note.created',
|
||||||
payload: { creditNote: completeCreditNote as CreditNote, userId },
|
payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
|
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import type { KPIPreferences } from '@/types'
|
import type { KPIPreferences } from '@/types'
|
||||||
|
|
||||||
const EXTENSION_ID = 'core/kpi'
|
const EXTENSION_ID = 'core/kpi'
|
||||||
@@ -11,10 +12,12 @@ export async function GET() {
|
|||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('extension_data')
|
.from('extension_data')
|
||||||
.select('value')
|
.select('value')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.eq('extension_id', EXTENSION_ID)
|
.eq('extension_id', EXTENSION_ID)
|
||||||
.eq('key', KEY)
|
.eq('key', KEY)
|
||||||
.single()
|
.single()
|
||||||
@@ -28,6 +31,8 @@ export async function PUT(request: Request) {
|
|||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
let body: unknown
|
let body: unknown
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -64,6 +69,7 @@ export async function PUT(request: Request) {
|
|||||||
.upsert(
|
.upsert(
|
||||||
{
|
{
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
company_id: companyId,
|
||||||
extension_id: EXTENSION_ID,
|
extension_id: EXTENSION_ID,
|
||||||
key: KEY,
|
key: KEY,
|
||||||
value: merged,
|
value: merged,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { createAuthCode } from '@/lib/auth/oauth-codes'
|
import { createAuthCode } from '@/lib/auth/oauth-codes'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth 2.0 Authorization Endpoint.
|
* OAuth 2.0 Authorization Endpoint.
|
||||||
@@ -88,11 +89,13 @@ export async function GET(request: Request) {
|
|||||||
return buildLoginRedirect(request)
|
return buildLoginRedirect(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Get company name for the consent page
|
// Get company name for the consent page
|
||||||
const { data: settings } = await supabase
|
const { data: settings } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('company_name')
|
.select('company_name')
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
const companyName = settings?.company_name || user.email
|
const companyName = settings?.company_name || user.email
|
||||||
@@ -180,6 +183,8 @@ export async function POST(request: Request) {
|
|||||||
return buildLoginRedirect(request)
|
return buildLoginRedirect(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Parse form body
|
// Parse form body
|
||||||
const formData = await request.formData()
|
const formData = await request.formData()
|
||||||
const consent = formData.get('consent')
|
const consent = formData.get('consent')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { decryptAuthCode, verifyPkce, hashAuthCode } from '@/lib/auth/oauth-codes'
|
import { decryptAuthCode, verifyPkce, hashAuthCode } from '@/lib/auth/oauth-codes'
|
||||||
import { generateApiKey, createServiceClientNoCookies, ALL_SCOPES } from '@/lib/auth/api-keys'
|
import { generateApiKey, createServiceClientNoCookies, ALL_SCOPES } from '@/lib/auth/api-keys'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth 2.0 Token Endpoint.
|
* OAuth 2.0 Token Endpoint.
|
||||||
@@ -100,6 +101,9 @@ export async function POST(request: Request) {
|
|||||||
.lt('created_at', new Date(Date.now() - 10 * 60 * 1000).toISOString())
|
.lt('created_at', new Date(Date.now() - 10 * 60 * 1000).toISOString())
|
||||||
.then(() => {})
|
.then(() => {})
|
||||||
|
|
||||||
|
// Resolve company context for the user
|
||||||
|
const companyId = await requireCompanyId(supabase, payload.userId)
|
||||||
|
|
||||||
// Create the API key now (after PKCE verification — prevents orphaned keys)
|
// Create the API key now (after PKCE verification — prevents orphaned keys)
|
||||||
const { key, hash, prefix } = generateApiKey()
|
const { key, hash, prefix } = generateApiKey()
|
||||||
|
|
||||||
@@ -107,6 +111,7 @@ export async function POST(request: Request) {
|
|||||||
.from('api_keys')
|
.from('api_keys')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: payload.userId,
|
user_id: payload.userId,
|
||||||
|
company_id: companyId,
|
||||||
key_hash: hash,
|
key_hash: hash,
|
||||||
key_prefix: prefix,
|
key_prefix: prefix,
|
||||||
name: 'MCP-klient (OAuth)',
|
name: 'MCP-klient (OAuth)',
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ vi.mock('@/lib/supabase/server', () => ({
|
|||||||
}))
|
}))
|
||||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
// Mock the counterparty templates (non-critical side effect)
|
// Mock the counterparty templates (non-critical side effect)
|
||||||
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
|
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
|
||||||
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
|
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { eventBus } from '@/lib/events'
|
import { eventBus } from '@/lib/events'
|
||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||||
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||||
@@ -48,13 +49,14 @@ ensureInitialized()
|
|||||||
async function ensureFiscalPeriod(
|
async function ensureFiscalPeriod(
|
||||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
companyId: string,
|
||||||
date: string,
|
date: string,
|
||||||
fiscalYearStartMonth: number = 1
|
fiscalYearStartMonth: number = 1
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { data: existing } = await supabase
|
const { data: existing } = await supabase
|
||||||
.from('fiscal_periods')
|
.from('fiscal_periods')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.lte('period_start', date)
|
.lte('period_start', date)
|
||||||
.gte('period_end', date)
|
.gte('period_end', date)
|
||||||
.eq('is_closed', false)
|
.eq('is_closed', false)
|
||||||
@@ -91,6 +93,7 @@ async function ensureFiscalPeriod(
|
|||||||
.from('fiscal_periods')
|
.from('fiscal_periods')
|
||||||
.upsert({
|
.upsert({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
company_id: companyId,
|
||||||
name: periodName,
|
name: periodName,
|
||||||
period_start: periodStart,
|
period_start: periodStart,
|
||||||
period_end: periodEnd,
|
period_end: periodEnd,
|
||||||
@@ -108,6 +111,7 @@ async function ensureFiscalPeriod(
|
|||||||
async function commitCategorizeTransaction(
|
async function commitCategorizeTransaction(
|
||||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
companyId: string,
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||||
const txId = params.transaction_id as string
|
const txId = params.transaction_id as string
|
||||||
@@ -119,7 +123,7 @@ async function commitCategorizeTransaction(
|
|||||||
.from('transactions')
|
.from('transactions')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', txId)
|
.eq('id', txId)
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (fetchError || !transaction) {
|
if (fetchError || !transaction) {
|
||||||
@@ -136,7 +140,7 @@ async function commitCategorizeTransaction(
|
|||||||
const { data: settings } = await supabase
|
const { data: settings } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('entity_type, fiscal_year_start_month')
|
.select('entity_type, fiscal_year_start_month')
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||||
@@ -156,13 +160,13 @@ async function commitCategorizeTransaction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure fiscal period exists
|
// Ensure fiscal period exists
|
||||||
await ensureFiscalPeriod(supabase, userId, transaction.date, fiscalYearStartMonth)
|
await ensureFiscalPeriod(supabase, userId, companyId, transaction.date, fiscalYearStartMonth)
|
||||||
|
|
||||||
// Create journal entry
|
// Create journal entry
|
||||||
let journalEntryId: string | null = null
|
let journalEntryId: string | null = null
|
||||||
try {
|
try {
|
||||||
const journalEntry = await createTransactionJournalEntry(
|
const journalEntry = await createTransactionJournalEntry(
|
||||||
supabase, userId, transaction as Transaction, mappingResult
|
supabase, companyId, userId, transaction as Transaction, mappingResult
|
||||||
)
|
)
|
||||||
if (journalEntry) {
|
if (journalEntry) {
|
||||||
journalEntryId = journalEntry.id
|
journalEntryId = journalEntry.id
|
||||||
@@ -202,6 +206,7 @@ async function commitCategorizeTransaction(
|
|||||||
account: mappingResult.debit_account,
|
account: mappingResult.debit_account,
|
||||||
taxCode: mappingResult.vat_lines[0]?.account_number || '',
|
taxCode: mappingResult.vat_lines[0]?.account_number || '',
|
||||||
userId,
|
userId,
|
||||||
|
companyId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -211,12 +216,14 @@ async function commitCategorizeTransaction(
|
|||||||
async function commitCreateCustomer(
|
async function commitCreateCustomer(
|
||||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
companyId: string,
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('customers')
|
.from('customers')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
company_id: companyId,
|
||||||
name: params.name as string,
|
name: params.name as string,
|
||||||
customer_type: params.customer_type as string,
|
customer_type: params.customer_type as string,
|
||||||
email: (params.email as string) || null,
|
email: (params.email as string) || null,
|
||||||
@@ -247,7 +254,7 @@ async function commitCreateCustomer(
|
|||||||
vat_number_validated_at: new Date().toISOString(),
|
vat_number_validated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.eq('id', data.id)
|
.eq('id', data.id)
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn('Auto-VIES validation failed:', err)
|
log.warn('Auto-VIES validation failed:', err)
|
||||||
@@ -256,7 +263,7 @@ async function commitCreateCustomer(
|
|||||||
|
|
||||||
await eventBus.emit({
|
await eventBus.emit({
|
||||||
type: 'customer.created',
|
type: 'customer.created',
|
||||||
payload: { customer: data as Customer, userId },
|
payload: { customer: data as Customer, userId, companyId },
|
||||||
})
|
})
|
||||||
|
|
||||||
return { data: { customer_id: data.id } }
|
return { data: { customer_id: data.id } }
|
||||||
@@ -265,6 +272,7 @@ async function commitCreateCustomer(
|
|||||||
async function commitCreateInvoice(
|
async function commitCreateInvoice(
|
||||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
companyId: string,
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||||
const customerId = params.customer_id as string
|
const customerId = params.customer_id as string
|
||||||
@@ -281,7 +289,7 @@ async function commitCreateInvoice(
|
|||||||
.from('customers')
|
.from('customers')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', customerId)
|
.eq('id', customerId)
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (customerError || !customer) {
|
if (customerError || !customer) {
|
||||||
@@ -340,6 +348,7 @@ async function commitCreateInvoice(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
company_id: companyId,
|
||||||
customer_id: customerId,
|
customer_id: customerId,
|
||||||
invoice_number: invoiceNumber,
|
invoice_number: invoiceNumber,
|
||||||
invoice_date: (params.invoice_date as string) || new Date().toISOString().split('T')[0],
|
invoice_date: (params.invoice_date as string) || new Date().toISOString().split('T')[0],
|
||||||
@@ -406,7 +415,7 @@ async function commitCreateInvoice(
|
|||||||
if (completeInvoice) {
|
if (completeInvoice) {
|
||||||
await eventBus.emit({
|
await eventBus.emit({
|
||||||
type: 'invoice.created',
|
type: 'invoice.created',
|
||||||
payload: { invoice: completeInvoice as Invoice, userId },
|
payload: { invoice: completeInvoice as Invoice, userId, companyId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,6 +425,7 @@ async function commitCreateInvoice(
|
|||||||
async function commitMarkInvoicePaid(
|
async function commitMarkInvoicePaid(
|
||||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
companyId: string,
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||||
const invoiceId = params.invoice_id as string
|
const invoiceId = params.invoice_id as string
|
||||||
@@ -425,7 +435,7 @@ async function commitMarkInvoicePaid(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||||
.eq('id', invoiceId)
|
.eq('id', invoiceId)
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||||
@@ -436,7 +446,7 @@ async function commitMarkInvoicePaid(
|
|||||||
const { data: settings } = await supabase
|
const { data: settings } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('accounting_method, entity_type')
|
.select('accounting_method, entity_type')
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||||
@@ -447,12 +457,12 @@ async function commitMarkInvoicePaid(
|
|||||||
if (isRealInvoice) {
|
if (isRealInvoice) {
|
||||||
if (accountingMethod === 'accrual') {
|
if (accountingMethod === 'accrual') {
|
||||||
const je = await createInvoicePaymentJournalEntry(
|
const je = await createInvoicePaymentJournalEntry(
|
||||||
supabase, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
|
supabase, companyId, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
|
||||||
)
|
)
|
||||||
journalEntryId = je?.id ?? null
|
journalEntryId = je?.id ?? null
|
||||||
} else {
|
} else {
|
||||||
const je = await createInvoiceCashEntry(
|
const je = await createInvoiceCashEntry(
|
||||||
supabase, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
|
supabase, companyId, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
|
||||||
)
|
)
|
||||||
journalEntryId = je?.id ?? null
|
journalEntryId = je?.id ?? null
|
||||||
}
|
}
|
||||||
@@ -463,7 +473,7 @@ async function commitMarkInvoicePaid(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.update({ status: 'paid', paid_at: now, paid_amount: invoice.total })
|
.update({ status: 'paid', paid_at: now, paid_amount: invoice.total })
|
||||||
.eq('id', invoiceId)
|
.eq('id', invoiceId)
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
|
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
|
||||||
|
|
||||||
@@ -473,6 +483,7 @@ async function commitMarkInvoicePaid(
|
|||||||
async function commitSendInvoice(
|
async function commitSendInvoice(
|
||||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
companyId: string,
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
userEmail?: string
|
userEmail?: string
|
||||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||||
@@ -487,7 +498,7 @@ async function commitSendInvoice(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||||
.eq('id', invoiceId)
|
.eq('id', invoiceId)
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||||
@@ -501,7 +512,7 @@ async function commitSendInvoice(
|
|||||||
const { data: company, error: companyError } = await supabase
|
const { data: company, error: companyError } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (companyError || !company) return { error: 'Company settings missing', status: 500 }
|
if (companyError || !company) return { error: 'Company settings missing', status: 500 }
|
||||||
@@ -554,14 +565,14 @@ async function commitSendInvoice(
|
|||||||
|
|
||||||
if (!result.success) return { error: `Failed to send email: ${result.error}`, status: 500 }
|
if (!result.success) return { error: `Failed to send email: ${result.error}`, status: 500 }
|
||||||
|
|
||||||
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('user_id', userId)
|
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('company_id', companyId)
|
||||||
|
|
||||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||||
let createdJournalEntryId: string | undefined
|
let createdJournalEntryId: string | undefined
|
||||||
if (isRealInvoice && (company.accounting_method === 'accrual' || !company.accounting_method)) {
|
if (isRealInvoice && (company.accounting_method === 'accrual' || !company.accounting_method)) {
|
||||||
try {
|
try {
|
||||||
const je = await createInvoiceJournalEntry(
|
const je = await createInvoiceJournalEntry(
|
||||||
supabase, userId, invoice as Invoice, (company as CompanySettings).entity_type
|
supabase, companyId, userId, invoice as Invoice, (company as CompanySettings).entity_type
|
||||||
)
|
)
|
||||||
if (je) {
|
if (je) {
|
||||||
createdJournalEntryId = je.id
|
createdJournalEntryId = je.id
|
||||||
@@ -573,7 +584,7 @@ async function commitSendInvoice(
|
|||||||
if (isRealInvoice) {
|
if (isRealInvoice) {
|
||||||
try {
|
try {
|
||||||
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
||||||
await uploadDocument(supabase, userId, {
|
await uploadDocument(supabase, userId, companyId, {
|
||||||
name: filename,
|
name: filename,
|
||||||
buffer: pdfArrayBuffer,
|
buffer: pdfArrayBuffer,
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
@@ -584,7 +595,7 @@ async function commitSendInvoice(
|
|||||||
} catch { /* non-blocking */ }
|
} catch { /* non-blocking */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId } })
|
await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId, companyId } })
|
||||||
|
|
||||||
return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } }
|
return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } }
|
||||||
}
|
}
|
||||||
@@ -592,6 +603,7 @@ async function commitSendInvoice(
|
|||||||
async function commitMarkInvoiceSent(
|
async function commitMarkInvoiceSent(
|
||||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
companyId: string,
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||||
const invoiceId = params.invoice_id as string
|
const invoiceId = params.invoice_id as string
|
||||||
@@ -600,7 +612,7 @@ async function commitMarkInvoiceSent(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||||
.eq('id', invoiceId)
|
.eq('id', invoiceId)
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||||
@@ -610,14 +622,14 @@ async function commitMarkInvoiceSent(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.update({ status: 'sent' })
|
.update({ status: 'sent' })
|
||||||
.eq('id', invoiceId)
|
.eq('id', invoiceId)
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
|
|
||||||
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
|
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
|
||||||
|
|
||||||
const { data: settings } = await supabase
|
const { data: settings } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('accounting_method, entity_type')
|
.select('accounting_method, entity_type')
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||||
@@ -626,7 +638,7 @@ async function commitMarkInvoiceSent(
|
|||||||
if (isRealInvoice && (settings?.accounting_method === 'accrual' || !settings?.accounting_method)) {
|
if (isRealInvoice && (settings?.accounting_method === 'accrual' || !settings?.accounting_method)) {
|
||||||
try {
|
try {
|
||||||
const je = await createInvoiceJournalEntry(
|
const je = await createInvoiceJournalEntry(
|
||||||
supabase, userId, invoice as Invoice,
|
supabase, companyId, userId, invoice as Invoice,
|
||||||
(settings?.entity_type as EntityType) || 'enskild_firma',
|
(settings?.entity_type as EntityType) || 'enskild_firma',
|
||||||
invoice.customer?.name
|
invoice.customer?.name
|
||||||
)
|
)
|
||||||
@@ -643,6 +655,7 @@ async function commitMarkInvoiceSent(
|
|||||||
async function commitMatchTransactionInvoice(
|
async function commitMatchTransactionInvoice(
|
||||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
companyId: string,
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||||
const transactionId = params.transaction_id as string
|
const transactionId = params.transaction_id as string
|
||||||
@@ -652,7 +665,7 @@ async function commitMatchTransactionInvoice(
|
|||||||
.from('transactions')
|
.from('transactions')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', transactionId)
|
.eq('id', transactionId)
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (txError || !transaction) return { error: 'Transaction not found', status: 404 }
|
if (txError || !transaction) return { error: 'Transaction not found', status: 404 }
|
||||||
@@ -663,7 +676,7 @@ async function commitMatchTransactionInvoice(
|
|||||||
.from('invoices')
|
.from('invoices')
|
||||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||||
.eq('id', invoiceId)
|
.eq('id', invoiceId)
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (invError || !invoice) return { error: 'Invoice not found', status: 404 }
|
if (invError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||||
@@ -673,7 +686,7 @@ async function commitMatchTransactionInvoice(
|
|||||||
|
|
||||||
// Storno conflicting journal entry
|
// Storno conflicting journal entry
|
||||||
if (transaction.journal_entry_id) {
|
if (transaction.journal_entry_id) {
|
||||||
await reverseEntry(supabase, userId, transaction.journal_entry_id)
|
await reverseEntry(supabase, companyId, userId, transaction.journal_entry_id)
|
||||||
await supabase.from('transactions').update({ journal_entry_id: null }).eq('id', transactionId)
|
await supabase.from('transactions').update({ journal_entry_id: null }).eq('id', transactionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,7 +701,7 @@ async function commitMatchTransactionInvoice(
|
|||||||
const { data: settings } = await supabase
|
const { data: settings } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('accounting_method, entity_type')
|
.select('accounting_method, entity_type')
|
||||||
.eq('user_id', userId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||||
@@ -698,12 +711,12 @@ async function commitMatchTransactionInvoice(
|
|||||||
try {
|
try {
|
||||||
if (accountingMethod === 'cash' && isFullyPaid) {
|
if (accountingMethod === 'cash' && isFullyPaid) {
|
||||||
const je = await createInvoiceCashEntry(
|
const je = await createInvoiceCashEntry(
|
||||||
supabase, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
|
supabase, companyId, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
|
||||||
)
|
)
|
||||||
journalEntryId = je?.id ?? null
|
journalEntryId = je?.id ?? null
|
||||||
} else {
|
} else {
|
||||||
const je = await createInvoicePaymentJournalEntry(
|
const je = await createInvoicePaymentJournalEntry(
|
||||||
supabase, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount
|
supabase, companyId, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount
|
||||||
)
|
)
|
||||||
journalEntryId = je?.id ?? null
|
journalEntryId = je?.id ?? null
|
||||||
}
|
}
|
||||||
@@ -733,6 +746,7 @@ async function commitMatchTransactionInvoice(
|
|||||||
|
|
||||||
await supabase.from('invoice_payments').insert({
|
await supabase.from('invoice_payments').insert({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
company_id: companyId,
|
||||||
invoice_id: invoiceId,
|
invoice_id: invoiceId,
|
||||||
payment_date: transaction.date,
|
payment_date: transaction.date,
|
||||||
amount: paidAmount,
|
amount: paidAmount,
|
||||||
@@ -757,7 +771,7 @@ async function commitMatchTransactionInvoice(
|
|||||||
try {
|
try {
|
||||||
await eventBus.emit({
|
await eventBus.emit({
|
||||||
type: 'invoice.match_confirmed',
|
type: 'invoice.match_confirmed',
|
||||||
payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId },
|
payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId, companyId },
|
||||||
})
|
})
|
||||||
} catch { /* non-critical */ }
|
} catch { /* non-critical */ }
|
||||||
|
|
||||||
@@ -778,12 +792,14 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
// Fetch the pending operation
|
// Fetch the pending operation
|
||||||
const { data: op, error: fetchError } = await supabase
|
const { data: op, error: fetchError } = await supabase
|
||||||
.from('pending_operations')
|
.from('pending_operations')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (fetchError || !op) {
|
if (fetchError || !op) {
|
||||||
@@ -804,25 +820,25 @@ export async function POST(
|
|||||||
|
|
||||||
switch (pendingOp.operation_type) {
|
switch (pendingOp.operation_type) {
|
||||||
case 'categorize_transaction':
|
case 'categorize_transaction':
|
||||||
result = await commitCategorizeTransaction(supabase, user.id, pendingOp.params)
|
result = await commitCategorizeTransaction(supabase, user.id, companyId, pendingOp.params)
|
||||||
break
|
break
|
||||||
case 'create_customer':
|
case 'create_customer':
|
||||||
result = await commitCreateCustomer(supabase, user.id, pendingOp.params)
|
result = await commitCreateCustomer(supabase, user.id, companyId, pendingOp.params)
|
||||||
break
|
break
|
||||||
case 'create_invoice':
|
case 'create_invoice':
|
||||||
result = await commitCreateInvoice(supabase, user.id, pendingOp.params)
|
result = await commitCreateInvoice(supabase, user.id, companyId, pendingOp.params)
|
||||||
break
|
break
|
||||||
case 'mark_invoice_paid':
|
case 'mark_invoice_paid':
|
||||||
result = await commitMarkInvoicePaid(supabase, user.id, pendingOp.params)
|
result = await commitMarkInvoicePaid(supabase, user.id, companyId, pendingOp.params)
|
||||||
break
|
break
|
||||||
case 'send_invoice':
|
case 'send_invoice':
|
||||||
result = await commitSendInvoice(supabase, user.id, pendingOp.params, user.email)
|
result = await commitSendInvoice(supabase, user.id, companyId, pendingOp.params, user.email)
|
||||||
break
|
break
|
||||||
case 'mark_invoice_sent':
|
case 'mark_invoice_sent':
|
||||||
result = await commitMarkInvoiceSent(supabase, user.id, pendingOp.params)
|
result = await commitMarkInvoiceSent(supabase, user.id, companyId, pendingOp.params)
|
||||||
break
|
break
|
||||||
case 'match_transaction_invoice':
|
case 'match_transaction_invoice':
|
||||||
result = await commitMatchTransactionInvoice(supabase, user.id, pendingOp.params)
|
result = await commitMatchTransactionInvoice(supabase, user.id, companyId, pendingOp.params)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 })
|
return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 })
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({
|
|||||||
createClient: () => Promise.resolve(mockSupabase),
|
createClient: () => Promise.resolve(mockSupabase),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
import { POST } from '../../reject/route'
|
import { POST } from '../../reject/route'
|
||||||
|
|
||||||
describe('POST /api/pending-operations/:id/reject', () => {
|
describe('POST /api/pending-operations/:id/reject', () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/pending-operations/:id/reject
|
* POST /api/pending-operations/:id/reject
|
||||||
@@ -18,11 +19,13 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { data: op, error: fetchError } = await supabase
|
const { data: op, error: fetchError } = await supabase
|
||||||
.from('pending_operations')
|
.from('pending_operations')
|
||||||
.select('id, status')
|
.select('id, status')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (fetchError || !op) {
|
if (fetchError || !op) {
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ vi.mock('@/lib/supabase/server', () => ({
|
|||||||
createClient: () => Promise.resolve(mockSupabase),
|
createClient: () => Promise.resolve(mockSupabase),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
import { GET } from '../route'
|
import { GET } from '../route'
|
||||||
|
|
||||||
describe('GET /api/pending-operations', () => {
|
describe('GET /api/pending-operations', () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { validateQuery } from '@/lib/api/validate'
|
import { validateQuery } from '@/lib/api/validate'
|
||||||
import { PendingOperationsQuerySchema } from '@/lib/api/schemas'
|
import { PendingOperationsQuerySchema } from '@/lib/api/schemas'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/pending-operations
|
* GET /api/pending-operations
|
||||||
@@ -16,6 +17,8 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const result = validateQuery(request, PendingOperationsQuerySchema)
|
const result = validateQuery(request, PendingOperationsQuerySchema)
|
||||||
if (!result.success) return result.response
|
if (!result.success) return result.response
|
||||||
const { status, limit, offset } = result.data
|
const { status, limit, offset } = result.data
|
||||||
@@ -23,7 +26,7 @@ export async function GET(request: Request) {
|
|||||||
const { data, error, count } = await supabase
|
const { data, error, count } = await supabase
|
||||||
.from('pending_operations')
|
.from('pending_operations')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.eq('user_id', user.id)
|
.eq('company_id', companyId)
|
||||||
.eq('status', status)
|
.eq('status', status)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.range(offset, offset + limit - 1)
|
.range(offset, offset + limit - 1)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
|
|||||||
import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
|
import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
|
||||||
import { validateBody } from '@/lib/api/validate'
|
import { validateBody } from '@/lib/api/validate'
|
||||||
import { BankLinkSchema } from '@/lib/api/schemas'
|
import { BankLinkSchema } from '@/lib/api/schemas'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
|
|
||||||
@@ -15,11 +16,13 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const validation = await validateBody(request, BankLinkSchema)
|
const validation = await validateBody(request, BankLinkSchema)
|
||||||
if (!validation.success) return validation.response
|
if (!validation.success) return validation.response
|
||||||
const { transaction_id, journal_entry_id } = validation.data
|
const { transaction_id, journal_entry_id } = validation.data
|
||||||
|
|
||||||
const result = await manualLink(supabase, user.id, transaction_id, journal_entry_id)
|
const result = await manualLink(supabase, companyId, transaction_id, journal_entry_id)
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
|
|||||||
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
||||||
import { validateBody } from '@/lib/api/validate'
|
import { validateBody } from '@/lib/api/validate'
|
||||||
import { RunReconciliationSchema } from '@/lib/api/schemas'
|
import { RunReconciliationSchema } from '@/lib/api/schemas'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
|
|
||||||
@@ -15,11 +16,13 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const validation = await validateBody(request, RunReconciliationSchema)
|
const validation = await validateBody(request, RunReconciliationSchema)
|
||||||
if (!validation.success) return validation.response
|
if (!validation.success) return validation.response
|
||||||
const { date_from, date_to, dry_run } = validation.data
|
const { date_from, date_to, dry_run } = validation.data
|
||||||
|
|
||||||
const result = await runReconciliation(supabase, user.id, {
|
const result = await runReconciliation(supabase, companyId, {
|
||||||
dateFrom: date_from,
|
dateFrom: date_from,
|
||||||
dateTo: date_to,
|
dateTo: date_to,
|
||||||
dryRun: dry_run ?? false,
|
dryRun: dry_run ?? false,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
|
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -10,11 +11,13 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const dateFrom = searchParams.get('date_from') || undefined
|
const dateFrom = searchParams.get('date_from') || undefined
|
||||||
const dateTo = searchParams.get('date_to') || undefined
|
const dateTo = searchParams.get('date_to') || undefined
|
||||||
|
|
||||||
const status = await getReconciliationStatus(supabase, user.id, dateFrom, dateTo)
|
const status = await getReconciliationStatus(supabase, companyId, dateFrom, dateTo)
|
||||||
|
|
||||||
return NextResponse.json({ data: status })
|
return NextResponse.json({ data: status })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
|||||||
import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
||||||
import { validateBody } from '@/lib/api/validate'
|
import { validateBody } from '@/lib/api/validate'
|
||||||
import { BankUnlinkSchema } from '@/lib/api/schemas'
|
import { BankUnlinkSchema } from '@/lib/api/schemas'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -12,11 +13,13 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const validation = await validateBody(request, BankUnlinkSchema)
|
const validation = await validateBody(request, BankUnlinkSchema)
|
||||||
if (!validation.success) return validation.response
|
if (!validation.success) return validation.response
|
||||||
const { transaction_id } = validation.data
|
const { transaction_id } = validation.data
|
||||||
|
|
||||||
const result = await unlinkReconciliation(supabase, user.id, transaction_id)
|
const result = await unlinkReconciliation(supabase, companyId, transaction_id)
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
|
import { fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -10,11 +11,13 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const dateFrom = searchParams.get('date_from') || undefined
|
const dateFrom = searchParams.get('date_from') || undefined
|
||||||
const dateTo = searchParams.get('date_to') || undefined
|
const dateTo = searchParams.get('date_to') || undefined
|
||||||
|
|
||||||
const lines = await fetchUnlinkedGLLines(supabase, user.id, dateFrom, dateTo)
|
const lines = await fetchUnlinkedGLLines(supabase, companyId, dateFrom, dateTo)
|
||||||
|
|
||||||
return NextResponse.json({ data: lines })
|
return NextResponse.json({ data: lines })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { generateARLedger } from '@/lib/reports/ar-ledger'
|
import { generateARLedger } from '@/lib/reports/ar-ledger'
|
||||||
import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
|
import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
|
||||||
|
import { requireCompanyId } from '@/lib/company/context'
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -12,15 +13,17 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyId = await requireCompanyId(supabase, user.id)
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const asOfDate = searchParams.get('as_of_date') || undefined
|
const asOfDate = searchParams.get('as_of_date') || undefined
|
||||||
const periodId = searchParams.get('period_id') || undefined
|
const periodId = searchParams.get('period_id') || undefined
|
||||||
|
|
||||||
const ledger = await generateARLedger(supabase, user.id, asOfDate)
|
const ledger = await generateARLedger(supabase, companyId, asOfDate)
|
||||||
|
|
||||||
let reconciliation = null
|
let reconciliation = null
|
||||||
if (periodId) {
|
if (periodId) {
|
||||||
reconciliation = await generateARReconciliation(supabase, user.id, periodId)
|
reconciliation = await generateARReconciliation(supabase, companyId, periodId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ vi.mock('@/lib/core/audit/audit-service', () => ({
|
|||||||
getAuditLog: vi.fn(),
|
getAuditLog: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/company/context', () => ({
|
||||||
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||||
|
}))
|
||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
||||||
import { GET } from '../route'
|
import { GET } from '../route'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user