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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-03-31 16:41:52 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 1756af6c5e
commit 0dd1f5ebc1
290 changed files with 7690 additions and 1339 deletions
+6 -4
View File
@@ -1,4 +1,4 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
@@ -31,9 +31,11 @@ export async function POST(request: Request) {
}
try {
// Delete the auth user via service role — all data cascades via ON DELETE CASCADE
const serviceClient = await createServiceClient()
const { error } = await serviceClient.auth.admin.deleteUser(user.id)
// RPC disables protective triggers, deletes from auth.users (CASCADE
// cleans up all public tables), then re-enables triggers — all in one tx.
const { error } = await supabase.rpc('delete_user_account', {
target_user_id: user.id,
})
if (error) {
console.error('Failed to delete user:', error)
+7 -2
View File
@@ -15,6 +15,11 @@ vi.mock('@/lib/extensions/ai-consent', () => ({
),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { createClient } from '@/lib/supabase/server'
import { hasAiConsent, grantAiConsent, revokeAiConsent } from '@/lib/extensions/ai-consent'
import { GET, POST, DELETE } from '../route'
@@ -91,7 +96,7 @@ describe('POST /api/ai-consent', () => {
expect(status).toBe(200)
expect(body.data.consented).toBe(true)
expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'receipt-ocr')
expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'receipt-ocr')
})
it('returns 400 for non-AI extension', async () => {
@@ -131,6 +136,6 @@ describe('DELETE /api/ai-consent', () => {
expect(status).toBe(200)
expect(body.data.consented).toBe(false)
expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'ai-chat')
expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'ai-chat')
})
})
+10 -3
View File
@@ -7,6 +7,7 @@ import {
revokeAiConsent,
isAiExtension,
} from '@/lib/extensions/ai-consent'
import { requireCompanyId } from '@/lib/company/context'
export async function GET() {
const supabase = await createClient()
@@ -16,9 +17,11 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const statuses: Record<string, boolean> = {}
for (const ext of AI_EXTENSIONS) {
statuses[ext] = await hasAiConsent(supabase, user.id, ext)
statuses[ext] = await hasAiConsent(supabase, companyId, ext)
}
return NextResponse.json({ data: statuses })
@@ -32,6 +35,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { extension_id } = body
@@ -42,7 +47,7 @@ export async function POST(request: Request) {
)
}
await grantAiConsent(supabase, user.id, extension_id)
await grantAiConsent(supabase, user.id, companyId, extension_id)
return NextResponse.json({ data: { consented: true } })
}
@@ -54,6 +59,8 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { extension_id } = body
@@ -64,6 +71,6 @@ export async function DELETE(request: Request) {
)
}
await revokeAiConsent(supabase, user.id, extension_id)
await revokeAiConsent(supabase, user.id, companyId, extension_id)
return NextResponse.json({ data: { consented: false } })
}
@@ -9,6 +9,11 @@ vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { createClient } from '@/lib/supabase/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { GET } from '../route'
+3
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import type { AuditAction } from '@/types'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -11,6 +12,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const filters = {
+4 -1
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -9,6 +10,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const from = searchParams.get('from')
const to = searchParams.get('to')
@@ -27,7 +30,7 @@ export async function GET(request: Request) {
let entriesQuery = supabase
.from('journal_entries')
.select('id, entry_date')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('status', 'posted')
if (dateFrom) {
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { UpdateAccountSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function DELETE(
request: Request,
@@ -15,11 +16,13 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch the account to check if it's a system account
const { data: account, error: fetchError } = await supabase
.from('chart_of_accounts')
.select('id, is_system_account')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('account_number', number)
.single()
@@ -51,7 +54,7 @@ export async function DELETE(
.from('chart_of_accounts')
.delete()
.eq('id', account.id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
@@ -72,6 +75,8 @@ export async function PUT(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, UpdateAccountSchema)
if (!validation.success) return validation.response
const body = validation.data
@@ -79,7 +84,7 @@ export async function PUT(
const { data, error } = await supabase
.from('chart_of_accounts')
.update(body)
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('account_number', number)
.select()
.single()
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { requireCompanyId } from '@/lib/company/context'
/**
* POST /api/bookkeeping/accounts/activate
@@ -17,6 +18,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const accountNumbers: string[] = body.account_numbers
@@ -28,7 +31,7 @@ export async function POST(request: Request) {
const { data: existing } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('user_id', user.id)
.eq('company_id', companyId)
.in('account_number', accountNumbers)
const existingNumbers = new Set((existing || []).map((a) => a.account_number))
@@ -42,6 +45,7 @@ export async function POST(request: Request) {
return {
user_id: user.id,
company_id: companyId,
account_number: ref.account_number,
account_name: ref.account_name,
account_class: ref.account_class,
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/bookkeeping/accounts/reference
@@ -17,13 +18,15 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch user's chart of accounts (paginated to avoid 1000-row limit)
try {
const userAccounts = await fetchAllRows<{ account_number: string; is_active: boolean; is_system_account: boolean }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, is_active, is_system_account')
.eq('user_id', user.id)
.eq('company_id', companyId)
.range(from, to)
)
+7 -1
View File
@@ -3,6 +3,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { CreateAccountSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -12,6 +13,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const accountClass = searchParams.get('class')
const activeOnly = searchParams.get('active') !== 'false'
@@ -21,7 +24,7 @@ export async function GET(request: Request) {
let query = supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('sort_order')
if (activeOnly) {
@@ -53,10 +56,13 @@ export async function POST(request: Request) {
if (!validation.success) return validation.response
const body = validation.data
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('chart_of_accounts')
.insert({
user_id: user.id,
company_id: companyId,
account_number: body.account_number,
account_name: body.account_name,
account_class: parseInt(body.account_number[0]),
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
import { requireCompanyId } from '@/lib/company/context'
export async function POST(
request: Request,
@@ -14,8 +15,10 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const period = await closePeriod(supabase, user.id, id)
const period = await closePeriod(supabase, companyId, user.id, id)
return NextResponse.json({ data: period })
} catch (err) {
return NextResponse.json(
@@ -4,6 +4,7 @@ import {
previewCurrencyRevaluation,
executeCurrencyRevaluation,
} from '@/lib/bookkeeping/currency-revaluation'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET: Preview currency revaluation for a fiscal period
@@ -20,20 +21,22 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
// Fetch period to get closing date
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
}
const preview = await previewCurrencyRevaluation(supabase, user.id, period.period_end)
const preview = await previewCurrencyRevaluation(supabase, companyId, period.period_end)
return NextResponse.json({ data: preview })
} catch (err) {
return NextResponse.json(
@@ -58,13 +61,15 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
// Fetch period to get closing date
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
@@ -75,7 +80,7 @@ export async function POST(
return NextResponse.json({ error: 'Period is already closed' }, { status: 400 })
}
const result = await executeCurrencyRevaluation(supabase, user.id, period.period_end, id)
const result = await executeCurrencyRevaluation(supabase, companyId, period.period_end, id, user.id)
if (!result) {
return NextResponse.json({ data: null, message: 'No foreign currency items to revalue' })
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { lockPeriod } from '@/lib/core/bookkeeping/period-service'
import { requireCompanyId } from '@/lib/company/context'
export async function POST(
request: Request,
@@ -14,8 +15,10 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const period = await lockPeriod(supabase, user.id, id)
const period = await lockPeriod(supabase, companyId, user.id, id)
return NextResponse.json({ data: period })
} catch (err) {
return NextResponse.json(
@@ -5,6 +5,7 @@ import {
previewYearEndClosing,
executeYearEndClosing,
} from '@/lib/core/bookkeeping/year-end-service'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET: Validate readiness and preview year-end closing
@@ -21,10 +22,12 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const [validation, preview] = await Promise.all([
validateYearEndReadiness(supabase, user.id, id),
previewYearEndClosing(supabase, user.id, id),
validateYearEndReadiness(supabase, companyId, user.id, id),
previewYearEndClosing(supabase, companyId, user.id, id),
])
return NextResponse.json({ data: { validation, preview } })
@@ -51,8 +54,10 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const result = await executeYearEndClosing(supabase, user.id, id)
const result = await executeYearEndClosing(supabase, companyId, user.id, id)
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
+8 -2
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import { validateBody } from '@/lib/api/validate'
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function GET() {
const supabase = await createClient()
@@ -12,10 +13,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('fiscal_periods')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('period_start', { ascending: false })
if (error) {
@@ -33,6 +36,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateFiscalPeriodSchema)
if (!validation.success) return validation.response
const body = validation.data
@@ -47,7 +52,7 @@ export async function POST(request: Request) {
const { data: overlapping } = await supabase
.from('fiscal_periods')
.select('id, name')
.eq('user_id', user.id)
.eq('company_id', companyId)
.lte('period_start', body.period_end)
.gte('period_end', body.period_start)
.limit(1)
@@ -63,6 +68,7 @@ export async function POST(request: Request) {
.from('fiscal_periods')
.insert({
user_id: user.id,
company_id: companyId,
name: body.name,
period_start: body.period_start,
period_end: body.period_end,
@@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => mockCreateClient(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route'
function buildMockSupabase({
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(
request: Request,
@@ -13,12 +14,14 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch the requested entry with lines
const { data: entry, error } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error || !entry) {
@@ -38,7 +41,7 @@ export async function GET(
const { data: referencing } = await supabase
.from('journal_entries')
.select('id')
.eq('user_id', user.id)
.eq('company_id', companyId)
.or(`reverses_id.eq.${id},reversed_by_id.eq.${id},correction_of_id.eq.${id}`)
if (referencing) {
@@ -57,7 +60,7 @@ export async function GET(
const { data: batchEntries } = await supabase
.from('journal_entries')
.select('id, reverses_id, reversed_by_id, correction_of_id')
.eq('user_id', user.id)
.eq('company_id', companyId)
.in('id', batch)
if (!batchEntries) continue
@@ -81,7 +84,7 @@ export async function GET(
const { data: refs } = await supabase
.from('journal_entries')
.select('id')
.eq('user_id', user.id)
.eq('company_id', companyId)
.or(batchOr)
if (refs) {
@@ -99,7 +102,7 @@ export async function GET(
const { data: chainEntries } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', user.id)
.eq('company_id', companyId)
.in('id', chainIds)
.order('created_at', { ascending: true })
@@ -15,6 +15,10 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCorrectEntry = vi.fn()
vi.mock('@/lib/core/bookkeeping/storno-service', () => ({
correctEntry: (...args: unknown[]) => mockCorrectEntry(...args),
@@ -100,7 +104,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
expect(status).toBe(200)
expect(body.data.reversal).toEqual(reversal)
expect(body.data.corrected).toEqual(corrected)
expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'entry-1', lines)
expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1', lines)
})
it('returns 400 when correctEntry throws for unbalanced lines', async () => {
@@ -4,6 +4,7 @@ import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -19,12 +20,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CorrectJournalEntrySchema)
if (!validation.success) return validation.response
const body = validation.data
try {
const result = await correctEntry(supabase, user.id, id, body.lines)
const result = await correctEntry(supabase, companyId, user.id, id, body.lines)
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
@@ -16,6 +16,10 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockReverseEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
@@ -64,7 +68,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => {
expect(status).toBe(200)
expect(body.data).toEqual(reversalEntry)
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'entry-1')
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1')
})
it('returns 400 when engine throws', async () => {
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -17,8 +18,10 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const reversalEntry = await reverseEntry(supabase, user.id, id)
const reversalEntry = await reverseEntry(supabase, companyId, user.id, id)
return NextResponse.json({ data: reversalEntry })
} catch (err) {
return NextResponse.json(
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(
request: Request,
@@ -13,11 +14,13 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error) {
@@ -16,6 +16,10 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCreateJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
@@ -137,7 +141,7 @@ describe('POST /api/bookkeeping/journal-entries', () => {
expect(status).toBe(200)
expect(body.data).toEqual(entry)
expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', input)
expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', input)
})
it('returns 400 when engine throws', async () => {
+7 -2
View File
@@ -4,6 +4,7 @@ import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -15,6 +16,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const status = searchParams.get('status')
@@ -29,7 +32,7 @@ export async function GET(request: Request) {
let query = supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
.eq('user_id', user.id)
.eq('company_id', companyId)
if (sortDate === 'asc' || sortDate === 'desc') {
query = query
@@ -76,12 +79,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateJournalEntrySchema)
if (!validation.success) return validation.response
const body = validation.data
try {
const entry = await createJournalEntry(supabase, user.id, body)
const entry = await createJournalEntry(supabase, companyId, user.id, body)
return NextResponse.json({ data: entry })
} catch (err) {
return NextResponse.json(
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { validateBody } from '@/lib/api/validate'
import { EvaluateMappingRulesSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction } from '@/types'
export async function POST(request: Request) {
@@ -13,6 +14,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, EvaluateMappingRulesSchema)
if (!validation.success) return validation.response
const body = validation.data
@@ -25,7 +28,7 @@ export async function POST(request: Request) {
.from('transactions')
.select('*')
.eq('id', body.transaction_id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error || !data) {
@@ -38,7 +41,7 @@ export async function POST(request: Request) {
}
try {
const result = await evaluateMappingRules(supabase, user.id, transaction)
const result = await evaluateMappingRules(supabase, companyId, transaction)
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
+7 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { CreateMappingRuleSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function GET() {
const supabase = await createClient()
@@ -11,10 +12,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('mapping_rules')
.select('*')
.or(`user_id.eq.${user.id},user_id.is.null`)
.or(`company_id.eq.${companyId},company_id.is.null`)
.eq('is_active', true)
.order('priority')
@@ -33,6 +36,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, CreateMappingRuleSchema)
if (!result.success) return result.response
const body = result.data
@@ -41,6 +46,7 @@ export async function POST(request: Request) {
.from('mapping_rules')
.insert({
user_id: user.id,
company_id: companyId,
rule_name: body.rule_name,
rule_type: body.rule_type,
priority: body.priority || 10,
+2 -2
View File
@@ -101,7 +101,7 @@ export async function GET(
? supabase
.from('deadlines')
.select('*')
.eq('user_id', feed.user_id)
.eq('company_id', feed.company_id)
.gte('due_date', startStr)
.lte('due_date', endStr)
.order('due_date')
@@ -112,7 +112,7 @@ export async function GET(
? supabase
.from('invoices')
.select('*, customer:customers(*)')
.eq('user_id', feed.user_id)
.eq('company_id', feed.company_id)
.gte('due_date', startStr)
.lte('due_date', endStr)
.order('due_date')
+14 -4
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import type { UpdateCalendarFeedInput } from '@/types'
/**
@@ -15,10 +16,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data: feed, error } = await supabase
.from('calendar_feeds')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error && error.code !== 'PGRST116') {
@@ -57,11 +60,13 @@ export async function POST() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Check if feed already exists
const { data: existingFeed } = await supabase
.from('calendar_feeds')
.select('id')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (existingFeed) {
@@ -76,6 +81,7 @@ export async function POST() {
.from('calendar_feeds')
.insert({
user_id: user.id,
company_id: companyId,
is_active: true,
include_tax_deadlines: true,
include_invoices: true,
@@ -111,12 +117,14 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body: UpdateCalendarFeedInput = await request.json()
const { data: feed, error } = await supabase
.from('calendar_feeds')
.update(body)
.eq('user_id', user.id)
.eq('company_id', companyId)
.select()
.single()
@@ -148,6 +156,8 @@ export async function DELETE() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Generate a new token by updating with a new UUID
const { data: feed, error } = await supabase
.from('calendar_feeds')
@@ -156,7 +166,7 @@ export async function DELETE() {
access_count: 0,
last_accessed_at: null,
})
.eq('user_id', user.id)
.eq('company_id', companyId)
.select()
.single()
+86
View File
@@ -0,0 +1,86 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* DELETE /api/company/members/[id]
* Remove a member from the current company.
* Only company owners and admins can remove members.
* Cannot remove team-sourced members (they must be removed from the team).
*/
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const { id: memberId } = await params
const serviceClient = await createServiceClient()
// Check caller has permission
const { data: callerMembership } = await serviceClient
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.single()
if (!callerMembership || !['owner', 'admin'].includes(callerMembership.role)) {
return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
}
// Look up the member (source column may not exist if migration not yet applied)
let member: { id: string; user_id: string; role: string; source?: string } | null = null
const { data: memberWithSource } = await serviceClient
.from('company_members')
.select('id, user_id, role, source')
.eq('id', memberId)
.eq('company_id', companyId)
.single()
if (memberWithSource) {
member = memberWithSource
} else {
const { data: memberFallback } = await serviceClient
.from('company_members')
.select('id, user_id, role')
.eq('id', memberId)
.eq('company_id', companyId)
.single()
member = memberFallback ? { ...memberFallback, source: 'direct' } : null
}
if (!member) {
return NextResponse.json({ error: 'Medlem hittades inte.' }, { status: 404 })
}
if (member.user_id === user.id) {
return NextResponse.json({ error: 'Du kan inte ta bort dig själv.' }, { status: 400 })
}
if (member.role === 'owner') {
return NextResponse.json({ error: 'Ägaren kan inte tas bort.' }, { status: 400 })
}
if (member.source === 'team') {
return NextResponse.json({
error: 'Denna medlem läggs till via teamet. Ta bort från teamet istället.',
}, { status: 400 })
}
const { error } = await serviceClient
.from('company_members')
.delete()
.eq('id', memberId)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: 'Kunde inte ta bort medlem.' }, { status: 500 })
}
return NextResponse.json({ data: { removed: memberId } })
}
@@ -0,0 +1,62 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* DELETE /api/company/members/invite/[id]
* Revoke a pending company invitation.
* Only company owners and admins can revoke.
*/
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const { id: inviteId } = await params
const serviceClient = await createServiceClient()
// Check caller has permission
const { data: callerMembership } = await serviceClient
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.single()
if (!callerMembership || !['owner', 'admin'].includes(callerMembership.role)) {
return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
}
// Look up the invitation
const { data: invitation } = await serviceClient
.from('company_invitations')
.select('id, company_id, status')
.eq('id', inviteId)
.eq('company_id', companyId)
.single()
if (!invitation) {
return NextResponse.json({ error: 'Inbjudan hittades inte.' }, { status: 404 })
}
if (invitation.status !== 'pending') {
return NextResponse.json({ error: 'Inbjudan är inte väntande.' }, { status: 400 })
}
// Revoke the invitation
const { error } = await serviceClient
.from('company_invitations')
.update({ status: 'revoked' })
.eq('id', inviteId)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: 'Kunde inte återkalla inbjudan.' }, { status: 500 })
}
return NextResponse.json({ data: { revoked: inviteId } })
}
+158
View File
@@ -0,0 +1,158 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { generateInviteToken, getInviteExpiry } from '@/lib/auth/invite-tokens'
import { getEmailService } from '@/lib/email/service'
import {
generateInviteEmailSubject,
generateInviteEmailHtml,
generateInviteEmailText,
} from '@/lib/email/invite-templates'
/**
* POST /api/company/members/invite
* Invite a user to the current company (e.g., a client as viewer).
* Only company owners and admins can invite.
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const serviceClient = await createServiceClient()
// Check caller has permission
const { data: callerMembership } = await serviceClient
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.single()
if (!callerMembership || !['owner', 'admin'].includes(callerMembership.role)) {
return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
}
const body = await request.json()
const email = (body.email as string || '').trim().toLowerCase()
const role = (body.role as string) || 'viewer'
if (!email || !email.includes('@')) {
return NextResponse.json({ error: 'Ogiltig e-postadress.' }, { status: 400 })
}
if (!['admin', 'member', 'viewer'].includes(role)) {
return NextResponse.json({ error: 'Ogiltig roll.' }, { status: 400 })
}
// Check if email is already a member of this company
const { data: existingMembers } = await serviceClient
.from('company_members')
.select('id, user_id')
.eq('company_id', companyId)
if (existingMembers && existingMembers.length > 0) {
const memberUserIds = existingMembers.map((m) => m.user_id)
const { data: memberProfiles } = await serviceClient
.from('profiles')
.select('id, email')
.in('id', memberUserIds)
const alreadyMember = memberProfiles?.some(
(p) => p.email?.toLowerCase() === email
)
if (alreadyMember) {
return NextResponse.json({ error: 'Denna person är redan medlem.' }, { status: 409 })
}
}
// Check for existing pending invite
const { data: existingInvite } = await serviceClient
.from('company_invitations')
.select('id, status')
.eq('company_id', companyId)
.eq('email', email)
.single()
if (existingInvite && existingInvite.status === 'pending') {
return NextResponse.json({ error: 'En inbjudan har redan skickats till denna e-post.' }, { status: 409 })
}
// Get company name for the email
const { data: company } = await serviceClient
.from('companies')
.select('name')
.eq('id', companyId)
.single()
// Generate token
const { token, hash } = generateInviteToken()
const expiresAt = getInviteExpiry()
// Upsert invitation
if (existingInvite) {
const { error } = await serviceClient
.from('company_invitations')
.update({
token_hash: hash,
invited_by: user.id,
status: 'pending',
expires_at: expiresAt.toISOString(),
role,
})
.eq('id', existingInvite.id)
if (error) {
return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 })
}
} else {
const { error } = await serviceClient
.from('company_invitations')
.insert({
company_id: companyId,
email,
role,
token_hash: hash,
invited_by: user.id,
status: 'pending',
expires_at: expiresAt.toISOString(),
})
if (error) {
return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 })
}
}
// Send email
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const emailService = getEmailService()
if (emailService.isConfigured()) {
const inviteUrl = `${appUrl}/invite/${token}`
const emailData = {
companyName: company?.name || 'Företag',
inviterEmail: user.email || '',
inviteUrl,
}
const result = await emailService.sendEmail({
to: email,
subject: generateInviteEmailSubject(emailData),
html: generateInviteEmailHtml(emailData),
text: generateInviteEmailText(emailData),
})
if (!result.success) {
console.error('[company/members/invite] email send failed:', result.error)
}
}
// In development, return the invite URL directly (no email service)
const isDev = process.env.NODE_ENV === 'development'
const devInviteUrl = isDev ? `${appUrl}/invite/${token}` : undefined
return NextResponse.json({
data: { email, status: 'pending', ...(isDev && { inviteUrl: devInviteUrl }) },
})
}
+80
View File
@@ -0,0 +1,80 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/company/members
* Returns members and pending invitations for the current company.
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const serviceClient = await createServiceClient()
// Fetch members (source column may not exist if migration not yet applied)
let members: { id: string; user_id: string; role: string; source?: string; joined_at: string }[] | null = null
const { data: membersWithSource, error: membersError } = await serviceClient
.from('company_members')
.select('id, user_id, role, source, joined_at')
.eq('company_id', companyId)
.order('joined_at', { ascending: true })
if (membersError) {
// Fallback: query without source column
const { data: membersFallback, error: fallbackError } = await serviceClient
.from('company_members')
.select('id, user_id, role, joined_at')
.eq('company_id', companyId)
.order('joined_at', { ascending: true })
if (fallbackError) {
return NextResponse.json({ error: 'Kunde inte hämta medlemmar.' }, { status: 500 })
}
members = (membersFallback || []).map((m) => ({ ...m, source: 'direct' as const }))
} else {
members = membersWithSource
}
// Fetch emails from profiles
const userIds = (members || []).map((m) => m.user_id)
const { data: profiles } = userIds.length > 0
? await serviceClient
.from('profiles')
.select('id, email')
.in('id', userIds)
: { data: [] }
const emailMap = new Map((profiles || []).map((p) => [p.id, p.email]))
// Fetch pending company invitations
const { data: invitations } = await serviceClient
.from('company_invitations')
.select('id, email, role, status, expires_at, created_at')
.eq('company_id', companyId)
.eq('status', 'pending')
.order('created_at', { ascending: false })
// Get current user's role
const currentMember = members?.find((m) => m.user_id === user.id)
const canInvite = currentMember?.role === 'owner' || currentMember?.role === 'admin'
return NextResponse.json({
data: {
members: (members || []).map((m) => ({
id: m.id,
user_id: m.user_id,
email: emailMap.get(m.user_id) || '',
role: m.role,
source: m.source,
joined_at: m.joined_at,
is_current_user: m.user_id === user.id,
})),
invitations: invitations || [],
canInvite,
},
})
}
+14 -7
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { UpdateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { requireCompanyId } from '@/lib/company/context'
import { createLogger } from '@/lib/logger'
const log = createLogger('api/customers/[id]')
@@ -22,11 +23,13 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('customers')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error) {
@@ -41,7 +44,7 @@ export async function GET(
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, status, total, currency')
.eq('customer_id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('invoice_date', { ascending: false })
return NextResponse.json({
@@ -67,6 +70,8 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, UpdateCustomerSchema)
if (!result.success) return result.response
const body = result.data
@@ -91,7 +96,7 @@ export async function PATCH(
.from('customers')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.select()
.single()
@@ -113,7 +118,7 @@ export async function PATCH(
vat_number_validated_at: new Date().toISOString(),
})
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
data.vat_number_validated = true
data.vat_number_validated_at = new Date().toISOString()
@@ -125,7 +130,7 @@ export async function PATCH(
vat_number_validated_at: null,
})
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
data.vat_number_validated = false
data.vat_number_validated_at = null
@@ -139,7 +144,7 @@ export async function PATCH(
vat_number_validated_at: null,
})
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
data.vat_number_validated = false
data.vat_number_validated_at = null
@@ -167,11 +172,13 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { error } = await supabase
.from('customers')
.delete()
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
+9 -3
View File
@@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { requireCompanyId } from '@/lib/company/context'
import { createLogger } from '@/lib/logger'
import type { Customer } from '@/types'
@@ -21,10 +22,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('customers')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('name', { ascending: true })
if (error) {
@@ -43,6 +46,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, CreateCustomerSchema)
if (!result.success) return result.response
const body = result.data
@@ -51,6 +56,7 @@ export async function POST(request: Request) {
.from('customers')
.insert({
user_id: user.id,
company_id: companyId,
name: body.name,
customer_type: body.customer_type,
email: body.email,
@@ -84,7 +90,7 @@ export async function POST(request: Request) {
vat_number_validated_at: new Date().toISOString(),
})
.eq('id', data.id)
.eq('user_id', user.id)
.eq('company_id', companyId)
data.vat_number_validated = true
data.vat_number_validated_at = new Date().toISOString()
@@ -96,7 +102,7 @@ export async function POST(request: Request) {
await eventBus.emit({
type: 'customer.created',
payload: { customer: data as Customer, userId: user.id },
payload: { customer: data as Customer, companyId, userId: user.id },
})
return NextResponse.json({ data })
+5 -2
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* POST /api/deadlines/[id]/complete
@@ -20,12 +21,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// First, get current deadline state
const { data: existing, error: fetchError } = await supabase
.from('deadlines')
.select('is_completed')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchError) {
@@ -44,7 +47,7 @@ export async function POST(
completed_at: newCompletedState ? new Date().toISOString() : null,
})
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.select('*, customer:customers(id, name)')
.single()
+11 -4
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import type { CreateDeadlineInput } from '@/types'
/**
@@ -21,11 +22,13 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('deadlines')
.select('*, customer:customers(id, name)')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error) {
@@ -57,6 +60,8 @@ export async function PUT(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body: Partial<CreateDeadlineInput> = await request.json()
// First, get existing deadline to verify ownership
@@ -64,7 +69,7 @@ export async function PUT(
.from('deadlines')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchError) {
@@ -89,7 +94,7 @@ export async function PUT(
.from('deadlines')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.select('*, customer:customers(id, name)')
.single()
@@ -119,11 +124,13 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { error } = await supabase
.from('deadlines')
.delete()
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
+7 -2
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine'
import { requireCompanyId } from '@/lib/company/context'
import type { DeadlineStatus } from '@/types'
/**
@@ -19,6 +20,8 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const body = await request.json()
@@ -41,7 +44,7 @@ export async function PATCH(
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
}
const result = await updateDeadlineStatus(supabase, id, user.id, newStatus)
const result = await updateDeadlineStatus(supabase, id, companyId, newStatus)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
@@ -66,13 +69,15 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const { data: deadline, error } = await supabase
.from('deadlines')
.select('status, is_completed, due_date')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error || !deadline) {
+7 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { CreateDeadlineSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/deadlines
@@ -23,6 +24,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Parse query params
const { searchParams } = new URL(request.url)
const status = searchParams.get('status') || 'all'
@@ -34,7 +37,7 @@ export async function GET(request: Request) {
let query = supabase
.from('deadlines')
.select('*, customer:customers(id, name)')
.eq('user_id', user.id)
.eq('company_id', companyId)
// Apply filters
if (status === 'pending') {
@@ -79,6 +82,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateDeadlineSchema)
if (!validation.success) return validation.response
const body = validation.data
@@ -88,6 +93,7 @@ export async function POST(request: Request) {
.from('deadlines')
.insert({
user_id: user.id,
company_id: companyId,
title: body.title,
due_date: body.due_date,
due_time: body.due_time || null,
+4 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -25,6 +26,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
try {
@@ -39,7 +42,7 @@ export async function POST(
const document = await linkToJournalEntry(
supabase,
user.id,
companyId,
id,
body.journal_entry_id,
body.journal_entry_line_id
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -20,6 +21,8 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
// Fetch document record
@@ -27,7 +30,7 @@ export async function GET(
.from('document_attachments')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (docError || !doc) {
+4 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { verifyIntegrity } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -21,10 +22,12 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
try {
const result = await verifyIntegrity(supabase, user.id, id)
const result = await verifyIntegrity(supabase, companyId, id)
return NextResponse.json({ data: result })
} catch (error) {
+8 -3
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { createNewVersion } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -24,6 +25,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
try {
@@ -68,14 +71,16 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
// First, check if the document belongs to the user
// First, check if the document belongs to the company
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, original_id')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (docError || !doc) {
@@ -89,7 +94,7 @@ export async function GET(
const { data: versions, error: versionsError } = await supabase
.from('document_attachments')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.or(`id.eq.${rootId},original_id.eq.${rootId}`)
.order('version', { ascending: true })
+4 -1
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/documents/counts?journal_entry_ids=id1,id2,...
@@ -15,6 +16,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const idsParam = searchParams.get('journal_entry_ids')
@@ -35,7 +38,7 @@ export async function GET(request: Request) {
const { data, error } = await supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('is_current_version', true)
.in('journal_entry_id', ids)
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { runDocumentMatchingSweep } from '@/lib/documents/batch-match'
import { requireCompanyId } from '@/lib/company/context'
export async function POST(request: Request) {
const supabase = await createClient()
@@ -10,6 +11,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Optional: pass specific inbox item IDs to match
let inboxItemIds: string[] | undefined
try {
@@ -22,7 +25,7 @@ export async function POST(request: Request) {
}
try {
const result = await runDocumentMatchingSweep(supabase, user.id, inboxItemIds)
const result = await runDocumentMatchingSweep(supabase, companyId, inboxItemIds)
return NextResponse.json({ data: result })
} catch (error) {
console.error('[match-sweep] Failed:', error)
+7 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -24,6 +25,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
@@ -38,7 +41,7 @@ export async function POST(request: Request) {
const buffer = await file.arrayBuffer()
const document = await uploadDocument(supabase, user.id, {
const document = await uploadDocument(supabase, user.id, companyId, {
name: file.name,
buffer,
type: file.type,
@@ -77,6 +80,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const journalEntryId = searchParams.get('journal_entry_id')
const currentOnly = searchParams.get('current_only') !== 'false'
@@ -86,7 +91,7 @@ export async function GET(request: Request) {
let query = supabase
.from('document_attachments')
.select('*', { count: 'exact' })
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
+5
View File
@@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
// Mock API key auth
const mockValidateApiKey = vi.fn()
const mockExtractBearerToken = vi.fn()
+4 -1
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { validateQuery } from '@/lib/api/validate'
import { EventsQuerySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import type { SupabaseClient } from '@supabase/supabase-js'
/**
@@ -40,6 +41,8 @@ export async function GET(request: Request) {
userId = user.id
}
const companyId = await requireCompanyId(supabase, userId)
// Validate query params
const result = validateQuery(request, EventsQuerySchema)
if (!result.success) return result.response
@@ -49,7 +52,7 @@ export async function GET(request: Request) {
let query = supabase
.from('event_log')
.select('sequence, event_type, entity_id, data, created_at')
.eq('user_id', userId)
.eq('company_id', companyId)
.order('sequence', { ascending: true })
.limit(limit)
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(
request: Request,
@@ -13,6 +14,8 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const extensionId = `${sector}/${slug}`
const { searchParams } = new URL(request.url)
@@ -21,7 +24,7 @@ export async function GET(
let query = supabase
.from('extension_data')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', extensionId)
const prefix = searchParams.get('prefix')
@@ -53,6 +56,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { key, value } = body
@@ -67,6 +72,7 @@ export async function POST(
.upsert(
{
user_id: user.id,
company_id: companyId,
extension_id: extensionId,
key,
value,
@@ -95,6 +101,8 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const key = searchParams.get('key')
@@ -107,7 +115,7 @@ export async function DELETE(
const { error } = await supabase
.from('extension_data')
.delete()
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', extensionId)
.eq('key', key)
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(
_request: Request,
@@ -13,12 +14,14 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const extensionId = `${sector}/${slug}`
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', extensionId)
.eq('key', 'settings')
.single()
@@ -38,6 +41,8 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const extensionId = `${sector}/${slug}`
@@ -45,7 +50,7 @@ export async function PATCH(
const { data: existing } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', extensionId)
.eq('key', 'settings')
.single()
@@ -57,6 +62,7 @@ export async function PATCH(
.upsert(
{
user_id: user.id,
company_id: companyId,
extension_id: extensionId,
key: 'settings',
value: mergedSettings,
@@ -81,7 +81,7 @@ export async function GET(request: Request) {
// Look up pending connection by oauth_state (CSRF-safe)
const { data: pendingConnection, error: findError } = await supabase
.from('bank_connections')
.select('id, user_id')
.select('id, user_id, company_id')
.eq('oauth_state', state)
.eq('status', 'pending')
.single()
@@ -98,6 +98,7 @@ export async function GET(request: Request) {
}
const userId = pendingConnection.user_id
const companyId = pendingConnection.company_id
console.log('[enable-banking] Exchanging code for session', {
connectionId: pendingConnection.id,
@@ -166,7 +167,7 @@ export async function GET(request: Request) {
const { data: userSettings } = await supabase
.from('company_settings')
.select('onboarding_complete')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
const redirectTarget = userSettings?.onboarding_complete
@@ -143,7 +143,7 @@ export async function GET(request: Request) {
const { data: sieOverlap } = await supabase
.from('sie_imports')
.select('id')
.eq('user_id', connection.user_id)
.eq('company_id', connection.company_id)
.eq('status', 'completed')
.gte('fiscal_year_end', fromDate)
.limit(1)
@@ -156,6 +156,7 @@ export async function GET(request: Request) {
const syncResults = await Promise.all(
accounts.map(account => syncAccountTransactions(
supabase,
connection.company_id,
connection.user_id,
connection.id,
account,
@@ -284,7 +285,7 @@ async function sendConsentExpiryNotification(
const { data: companySettings } = await supabase
.from('company_settings')
.select('company_name')
.eq('user_id', userId)
.eq('company_id', connection.company_id)
.single()
const emailData = {
@@ -15,6 +15,11 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/extensions/context-factory', () => ({
createExtensionContext: vi.fn().mockReturnValue({
userId: 'user-1',
+5 -2
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { extensionRegistry } from '@/lib/extensions/registry'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
import { requireCompanyId } from '@/lib/company/context'
import type { ApiRouteDefinition } from '@/lib/extensions/types'
ensureInitialized()
@@ -117,9 +118,11 @@ async function handleRequest(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// AI consent check
if (isAiExtension(extensionId)) {
const consented = await hasAiConsent(supabase, user.id, extensionId)
const consented = await hasAiConsent(supabase, companyId, extensionId)
if (!consented) {
return NextResponse.json(
{ error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' },
@@ -145,7 +148,7 @@ async function handleRequest(
}
// Build context and dispatch
const ctx = createExtensionContext(supabase, user.id, extensionId)
const ctx = createExtensionContext(supabase, user.id, companyId, extensionId)
return matchedRoute.handler(handlerRequest, ctx)
}
@@ -142,7 +142,7 @@ describe('Invoice Inbox Webhook Route', () => {
created_at: '2024-06-15T10:00:00Z',
})
mockExtractAttachments.mockReturnValue([])
mockResolveUserFromEmail.mockResolvedValue('user-1')
mockResolveUserFromEmail.mockResolvedValue({ userId: 'user-1', companyId: 'company-1' })
// Insert inbox item with error status
enqueueMany([
@@ -79,13 +79,15 @@ export async function POST(request: Request) {
const supabase = createServiceClient()
// Resolve user from recipient email
const userId = await resolveUserFromEmail(payload.to, supabase)
const resolved = await resolveUserFromEmail(payload.to, supabase)
if (!userId) {
if (!resolved) {
console.warn(`[document-inbox] No user found for email: ${payload.to}`)
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
const { userId, companyId } = resolved
// Build raw email payload for BFL 7:2 archiving (no binary attachment content)
const rawEmailPayload = buildRawEmailPayload(body, payload)
@@ -96,6 +98,7 @@ export async function POST(request: Request) {
await supabase
.from('invoice_inbox_items')
.insert({
company_id: companyId,
user_id: userId,
status: 'error',
source: 'email',
@@ -131,6 +134,7 @@ export async function POST(request: Request) {
const { data: document, error: docError } = await supabase
.from('document_attachments')
.insert({
company_id: companyId,
user_id: userId,
storage_path: storagePath,
file_name: attachment.filename,
@@ -159,6 +163,7 @@ export async function POST(request: Request) {
const { data: inboxItem, error: itemError } = await supabase
.from('invoice_inbox_items')
.insert({
company_id: companyId,
user_id: userId,
status: 'processing',
source: 'email',
@@ -197,7 +202,7 @@ export async function POST(request: Request) {
const { data: suppliers } = await supabase
.from('suppliers')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers)
@@ -222,7 +227,7 @@ export async function POST(request: Request) {
// Use pre-extracted receipt data from unified call
const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath)
const result = await processReceiptFromDocument(supabase, userId, attachment.content, attachment.content_type, {
const result = await processReceiptFromDocument(supabase, userId, companyId, attachment.content, attachment.content_type, {
documentId: document.id,
source: 'email',
emailFrom: payload.from,
+6 -1
View File
@@ -4,6 +4,7 @@ import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
import { generateExternalId } from '@/lib/import/bank-file/parser'
import { requireCompanyId } from '@/lib/company/context'
import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
import type { Transaction } from '@/types'
@@ -33,6 +34,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body: ExecuteRequest = await request.json()
const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true } = body
@@ -46,6 +49,7 @@ export async function POST(request: Request) {
.from('bank_file_imports')
.upsert({
user_id: user.id,
company_id: companyId,
filename,
file_hash,
file_format: format,
@@ -76,7 +80,7 @@ export async function POST(request: Request) {
}))
// Run ingestion pipeline
const ingestResult = await ingestTransactions(supabase, user.id, rawTransactions)
const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions)
// Update import record with results
await supabase
@@ -106,6 +110,7 @@ export async function POST(request: Request) {
payload: {
transactions: importedTransactions as Transaction[],
userId: user.id,
companyId,
},
})
}
+5 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser'
import { decodeFileContent } from '@/lib/import/bank-file/encoding'
import { requireCompanyId } from '@/lib/company/context'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
/**
@@ -18,6 +19,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const formData = await request.formData()
const file = formData.get('file') as File | null
const formatOverride = formData.get('format') as BankFileFormatId | null
@@ -41,7 +44,7 @@ export async function POST(request: Request) {
const { data: existingImport } = await supabase
.from('bank_file_imports')
.select('id, status, imported_count, created_at')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('file_hash', fileHash)
.single()
@@ -67,7 +70,7 @@ export async function POST(request: Request) {
const { count } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('company_id', companyId)
.gte('date', parseResult.date_from || '1970-01-01')
.lte('date', parseResult.date_to || '2099-12-31')
+8 -3
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/import/sie/[id]
@@ -20,11 +21,13 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('sie_imports')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (error) {
@@ -62,12 +65,14 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Check current status before deleting
const { data: importRecord } = await supabase
.from('sie_imports')
.select('status')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (!importRecord) {
@@ -84,7 +89,7 @@ export async function DELETE(
.from('sie_imports')
.delete()
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import type { SIEAccount } from '@/lib/import/types'
/**
@@ -63,6 +64,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const body = await request.json()
const accounts: SIEAccount[] = body.accounts
@@ -80,6 +83,7 @@ export async function POST(request: Request) {
return {
user_id: user.id,
company_id: companyId,
account_number: account.number,
account_name: account.name,
account_class: accountClass,
+11 -4
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
import { suggestMappings } from '@/lib/import/account-mapper'
import { executeSIEImport } from '@/lib/import/sie-import'
@@ -26,6 +27,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
// Get form data with file and options
const formData = await request.formData()
@@ -63,7 +66,7 @@ export async function POST(request: Request) {
const { data: storedMappings } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
mappings = suggestMappings(
parsed.accounts,
@@ -90,14 +93,15 @@ export async function POST(request: Request) {
...new Set(mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)),
]
const existingAccounts = await fetchAllRows(({ from, to }) =>
const allCompanyAccounts = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number')
.eq('user_id', user.id)
.in('account_number', mappedAccountNumbers)
.eq('company_id', companyId)
.range(from, to)
)
const mappedSet = new Set(mappedAccountNumbers)
const existingAccounts = allCompanyAccounts.filter((a) => mappedSet.has(a.account_number))
// Build a lookup from SIE mappings for account names (used for bas_range accounts)
const mappingNameLookup = new Map<string, string>()
@@ -116,6 +120,7 @@ export async function POST(request: Request) {
// Account exists in BAS reference — use full metadata
return {
user_id: user.id,
company_id: companyId,
account_number: ref.account_number,
account_name: ref.account_name,
account_class: ref.account_class,
@@ -146,6 +151,7 @@ export async function POST(request: Request) {
return {
user_id: user.id,
company_id: companyId,
account_number: num,
account_name: accountName,
account_class: accountClass,
@@ -176,6 +182,7 @@ export async function POST(request: Request) {
// Execute the import
const result = await executeSIEImport(
supabase,
companyId,
user.id,
parsed,
mappings,
+13 -3
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { saveMappings } from '@/lib/import/sie-import'
import { requireCompanyId } from '@/lib/company/context'
import type { AccountMapping } from '@/lib/import/types'
/**
@@ -18,10 +19,12 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('source_account')
if (error) {
@@ -46,6 +49,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const mappings: AccountMapping[] = body.mappings
@@ -79,6 +84,8 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { sourceAccount, targetAccount } = body
@@ -93,6 +100,7 @@ export async function PUT(request: Request) {
.from('sie_account_mappings')
.upsert({
user_id: user.id,
company_id: companyId,
source_account: sourceAccount,
target_account: targetAccount,
confidence: 1.0,
@@ -125,6 +133,8 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const sourceAccount = searchParams.get('sourceAccount')
@@ -133,7 +143,7 @@ export async function DELETE(request: Request) {
const { error } = await supabase
.from('sie_account_mappings')
.delete()
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('source_account', sourceAccount)
if (error) {
@@ -144,7 +154,7 @@ export async function DELETE(request: Request) {
const { error } = await supabase
.from('sie_account_mappings')
.delete()
.eq('user_id', user.id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
+4 -1
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import {
parseSIEFile,
validateSIEFile,
@@ -27,6 +28,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
// Get form data with file
const formData = await request.formData()
@@ -91,7 +94,7 @@ export async function POST(request: Request) {
const { data: storedMappings } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
// Match against the full BAS reference (1,276 accounts) instead of only
// the user's active chart (~40 accounts). Accounts that match will be
+4 -1
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/import/sie
@@ -16,6 +17,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Parse query params
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get('limit') || '20', 10)
@@ -25,7 +28,7 @@ export async function GET(request: Request) {
let query = supabase
.from('sie_imports')
.select('*', { count: 'exact' })
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
+6 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice } from '@/types'
ensureInitialized()
@@ -25,12 +26,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch proforma with items
const { data: proforma, error: proformaError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (proformaError || !proforma) {
@@ -61,6 +64,7 @@ export async function POST(
.from('invoices')
.insert({
user_id: user.id,
company_id: companyId,
customer_id: proforma.customer_id,
invoice_number: invoiceNumber,
invoice_date: new Date().toISOString().split('T')[0],
@@ -129,7 +133,7 @@ export async function POST(
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId: user.id },
payload: { invoice: completeInvoice as Invoice, companyId, userId: user.id },
})
}
@@ -17,6 +17,10 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCreateInvoicePaymentJournalEntry = vi.fn()
const mockCreateInvoiceCashEntry = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
@@ -137,6 +141,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(body.journal_entry_id).toBe('je-1')
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
expect.any(String),
@@ -172,6 +177,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(body.journal_entry_id).toBe('je-2')
expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
expect.any(String),
@@ -235,6 +241,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
// Should call createJournalEntry directly with custom lines
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({
entry_date: '2025-03-17',
+10 -5
View File
@@ -7,6 +7,7 @@ import {
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
ensureInitialized()
@@ -35,12 +36,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
@@ -83,7 +86,7 @@ export async function POST(
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
@@ -107,7 +110,7 @@ export async function POST(
}
// User-provided lines from PaymentBookingDialog
const fiscalPeriodId = await findFiscalPeriod(supabase, user.id, paymentDate)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
if (!fiscalPeriodId) {
return NextResponse.json(
{ error: 'Ingen öppen räkenskapsperiod för betalningsdatumet' },
@@ -125,12 +128,13 @@ export async function POST(
source_id: invoice.id,
lines: customLines,
}
const journalEntry = await createJournalEntry(supabase, user.id, input)
const journalEntry = await createJournalEntry(supabase, companyId, user.id, input)
journalEntryId = journalEntry?.id ?? null
} else if (accountingMethod === 'accrual') {
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
const journalEntry = await createInvoicePaymentJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
paymentDate,
@@ -142,6 +146,7 @@ export async function POST(
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
const journalEntry = await createInvoiceCashEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
paymentDate,
@@ -168,7 +173,7 @@ export async function POST(
paid_amount: invoice.total,
})
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
+7 -3
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import type { EntityType, Invoice } from '@/types'
ensureInitialized()
@@ -26,12 +27,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
@@ -50,7 +53,7 @@ export async function POST(
.from('invoices')
.update({ status: 'sent' })
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
@@ -60,7 +63,7 @@ export async function POST(
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
@@ -72,6 +75,7 @@ export async function POST(
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
(settings?.entity_type as EntityType) || 'enskild_firma',
+5 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
export async function GET(
@@ -17,6 +18,8 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice with customer and items
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
@@ -26,7 +29,7 @@ export async function GET(
items:invoice_items(*)
`)
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
@@ -37,7 +40,7 @@ export async function GET(
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (companyError || !company) {
+5 -2
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* DELETE /api/invoices/[id]
@@ -21,12 +22,14 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice to verify ownership and status
const { data: invoice, error: fetchError } = await supabase
.from('invoices')
.select('id, status, user_id')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchError || !invoice) {
@@ -54,7 +57,7 @@ export async function DELETE(
.from('invoices')
.delete()
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
@@ -19,6 +19,11 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockRenderToBuffer = vi.fn()
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
@@ -184,6 +189,7 @@ describe('POST /api/invoices/[id]/send', () => {
)
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
'enskild_firma'
+9 -5
View File
@@ -12,6 +12,7 @@ import {
} from '@/lib/email/invoice-templates'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
ensureInitialized()
@@ -29,6 +30,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Check if email is configured
const emailService = getEmailService()
if (!emailService.isConfigured()) {
@@ -47,7 +50,7 @@ export async function POST(
items:invoice_items(*)
`)
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
@@ -67,7 +70,7 @@ export async function POST(
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (companyError || !company) {
@@ -161,7 +164,7 @@ export async function POST(
.from('invoices')
.update({ status: 'sent' })
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (updateError) {
console.error('Failed to update invoice status:', updateError)
@@ -175,6 +178,7 @@ export async function POST(
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
(company as CompanySettings).entity_type
@@ -196,7 +200,7 @@ export async function POST(
if (isRealInvoice) {
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, user.id, {
await uploadDocument(supabase, user.id, companyId, {
name: filename,
buffer: pdfArrayBuffer,
type: 'application/pdf',
@@ -212,7 +216,7 @@ export async function POST(
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: invoice as Invoice, userId: user.id },
payload: { invoice: invoice as Invoice, companyId, userId: user.id },
})
return NextResponse.json({
+5
View File
@@ -17,6 +17,11 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockGetVatRules = vi.fn()
const mockCalculateVat = vi.fn()
const mockGetAvailableVatRates = vi.fn()
+5 -2
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { getVatRules } from '@/lib/invoices/vat-rules'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
/**
@@ -20,6 +21,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { customer_id, invoice_date, due_date, currency, items, your_reference, our_reference, notes, document_type } = body
@@ -32,7 +35,7 @@ export async function POST(request: Request) {
.from('customers')
.select('*')
.eq('id', customer_id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (customerError || !customer) {
@@ -43,7 +46,7 @@ export async function POST(request: Request) {
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (companyError || !company) {
+16 -7
View File
@@ -9,6 +9,7 @@ import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import {
createCreditNoteJournalEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -21,6 +22,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const limit = parseInt(searchParams.get('limit') || '50')
@@ -29,7 +32,7 @@ export async function GET(request: Request) {
let query = supabase
.from('invoices')
.select('*, customer:customers(*)', { count: 'exact' })
.eq('user_id', user.id)
.eq('company_id', companyId)
.order('invoice_date', { ascending: false })
.range(offset, offset + limit - 1)
@@ -55,6 +58,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
let rawBody: unknown
try {
rawBody = await request.json()
@@ -78,7 +83,7 @@ export async function POST(request: Request) {
{ status: 400 },
)
}
return createCreditNote(supabase, user.id, parsed.data)
return createCreditNote(supabase, companyId, user.id, parsed.data)
}
const parsed = CreateInvoiceSchema.safeParse(rawBody)
@@ -100,7 +105,7 @@ export async function POST(request: Request) {
.from('customers')
.select('*')
.eq('id', invoiceInput.customer_id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (customerError || !customer) {
@@ -178,6 +183,7 @@ export async function POST(request: Request) {
.from('invoices')
.insert({
user_id: user.id,
company_id: companyId,
customer_id: invoiceInput.customer_id,
invoice_number: invoiceNumber,
invoice_date: invoiceInput.invoice_date,
@@ -246,7 +252,7 @@ export async function POST(request: Request) {
if (completeInvoice && documentType === 'invoice') {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId: user.id },
payload: { invoice: completeInvoice as Invoice, companyId, userId: user.id },
})
}
@@ -256,6 +262,7 @@ export async function POST(request: Request) {
// Create a credit note for an existing invoice
async function createCreditNote(
supabase: Awaited<ReturnType<typeof createClient>>,
companyId: string,
userId: string,
input: { credited_invoice_id: string; reason?: string }
) {
@@ -264,7 +271,7 @@ async function createCreditNote(
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('id', input.credited_invoice_id)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (originalError || !originalInvoice) {
@@ -300,6 +307,7 @@ async function createCreditNote(
.from('invoices')
.insert({
user_id: userId,
company_id: companyId,
customer_id: originalInvoice.customer_id,
invoice_number: creditNoteNumber,
invoice_date: new Date().toISOString().split('T')[0],
@@ -373,7 +381,7 @@ async function createCreditNote(
const { data: creditNoteSettings } = await supabase
.from('company_settings')
.select('entity_type, accounting_method')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
@@ -385,6 +393,7 @@ async function createCreditNote(
try {
const journalEntry = await createCreditNoteJournalEntry(
supabase,
companyId,
userId,
completeCreditNote as Invoice,
entityType,
@@ -402,7 +411,7 @@ async function createCreditNote(
await eventBus.emit({
type: 'credit_note.created',
payload: { creditNote: completeCreditNote as CreditNote, userId },
payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
})
}
+7 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import { requireCompanyId } from '@/lib/company/context'
import type { KPIPreferences } from '@/types'
const EXTENSION_ID = 'core/kpi'
@@ -11,10 +12,12 @@ export async function GET() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', EXTENSION_ID)
.eq('key', KEY)
.single()
@@ -28,6 +31,8 @@ export async function PUT(request: Request) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
let body: unknown
try {
body = await request.json()
@@ -64,6 +69,7 @@ export async function PUT(request: Request) {
.upsert(
{
user_id: user.id,
company_id: companyId,
extension_id: EXTENSION_ID,
key: KEY,
value: merged,
+6 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createAuthCode } from '@/lib/auth/oauth-codes'
import { requireCompanyId } from '@/lib/company/context'
/**
* OAuth 2.0 Authorization Endpoint.
@@ -88,11 +89,13 @@ export async function GET(request: Request) {
return buildLoginRedirect(request)
}
const companyId = await requireCompanyId(supabase, user.id)
// Get company name for the consent page
const { data: settings } = await supabase
.from('company_settings')
.select('company_name')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
const companyName = settings?.company_name || user.email
@@ -180,6 +183,8 @@ export async function POST(request: Request) {
return buildLoginRedirect(request)
}
await requireCompanyId(supabase, user.id)
// Parse form body
const formData = await request.formData()
const consent = formData.get('consent')
+5
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { decryptAuthCode, verifyPkce, hashAuthCode } from '@/lib/auth/oauth-codes'
import { generateApiKey, createServiceClientNoCookies, ALL_SCOPES } from '@/lib/auth/api-keys'
import { requireCompanyId } from '@/lib/company/context'
/**
* OAuth 2.0 Token Endpoint.
@@ -100,6 +101,9 @@ export async function POST(request: Request) {
.lt('created_at', new Date(Date.now() - 10 * 60 * 1000).toISOString())
.then(() => {})
// Resolve company context for the user
const companyId = await requireCompanyId(supabase, payload.userId)
// Create the API key now (after PKCE verification — prevents orphaned keys)
const { key, hash, prefix } = generateApiKey()
@@ -107,6 +111,7 @@ export async function POST(request: Request) {
.from('api_keys')
.insert({
user_id: payload.userId,
company_id: companyId,
key_hash: hash,
key_prefix: prefix,
name: 'MCP-klient (OAuth)',
@@ -15,6 +15,11 @@ vi.mock('@/lib/supabase/server', () => ({
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
// Mock the counterparty templates (non-critical side effect)
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
+55 -39
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
@@ -48,13 +49,14 @@ ensureInitialized()
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
date: string,
fiscalYearStartMonth: number = 1
): Promise<boolean> {
const { data: existing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.eq('company_id', companyId)
.lte('period_start', date)
.gte('period_end', date)
.eq('is_closed', false)
@@ -91,6 +93,7 @@ async function ensureFiscalPeriod(
.from('fiscal_periods')
.upsert({
user_id: userId,
company_id: companyId,
name: periodName,
period_start: periodStart,
period_end: periodEnd,
@@ -108,6 +111,7 @@ async function ensureFiscalPeriod(
async function commitCategorizeTransaction(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const txId = params.transaction_id as string
@@ -119,7 +123,7 @@ async function commitCategorizeTransaction(
.from('transactions')
.select('*')
.eq('id', txId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (fetchError || !transaction) {
@@ -136,7 +140,7 @@ async function commitCategorizeTransaction(
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, fiscal_year_start_month')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
@@ -156,13 +160,13 @@ async function commitCategorizeTransaction(
}
// Ensure fiscal period exists
await ensureFiscalPeriod(supabase, userId, transaction.date, fiscalYearStartMonth)
await ensureFiscalPeriod(supabase, userId, companyId, transaction.date, fiscalYearStartMonth)
// Create journal entry
let journalEntryId: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
supabase, userId, transaction as Transaction, mappingResult
supabase, companyId, userId, transaction as Transaction, mappingResult
)
if (journalEntry) {
journalEntryId = journalEntry.id
@@ -202,6 +206,7 @@ async function commitCategorizeTransaction(
account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId,
companyId,
},
})
@@ -211,12 +216,14 @@ async function commitCategorizeTransaction(
async function commitCreateCustomer(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const { data, error } = await supabase
.from('customers')
.insert({
user_id: userId,
company_id: companyId,
name: params.name as string,
customer_type: params.customer_type as string,
email: (params.email as string) || null,
@@ -247,7 +254,7 @@ async function commitCreateCustomer(
vat_number_validated_at: new Date().toISOString(),
})
.eq('id', data.id)
.eq('user_id', userId)
.eq('company_id', companyId)
}
} catch (err) {
log.warn('Auto-VIES validation failed:', err)
@@ -256,7 +263,7 @@ async function commitCreateCustomer(
await eventBus.emit({
type: 'customer.created',
payload: { customer: data as Customer, userId },
payload: { customer: data as Customer, userId, companyId },
})
return { data: { customer_id: data.id } }
@@ -265,6 +272,7 @@ async function commitCreateCustomer(
async function commitCreateInvoice(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const customerId = params.customer_id as string
@@ -281,7 +289,7 @@ async function commitCreateInvoice(
.from('customers')
.select('*')
.eq('id', customerId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (customerError || !customer) {
@@ -340,6 +348,7 @@ async function commitCreateInvoice(
.from('invoices')
.insert({
user_id: userId,
company_id: companyId,
customer_id: customerId,
invoice_number: invoiceNumber,
invoice_date: (params.invoice_date as string) || new Date().toISOString().split('T')[0],
@@ -406,7 +415,7 @@ async function commitCreateInvoice(
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId },
payload: { invoice: completeInvoice as Invoice, userId, companyId },
})
}
@@ -416,6 +425,7 @@ async function commitCreateInvoice(
async function commitMarkInvoicePaid(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const invoiceId = params.invoice_id as string
@@ -425,7 +435,7 @@ async function commitMarkInvoicePaid(
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
@@ -436,7 +446,7 @@ async function commitMarkInvoicePaid(
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
@@ -447,12 +457,12 @@ async function commitMarkInvoicePaid(
if (isRealInvoice) {
if (accountingMethod === 'accrual') {
const je = await createInvoicePaymentJournalEntry(
supabase, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
supabase, companyId, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
)
journalEntryId = je?.id ?? null
} else {
const je = await createInvoiceCashEntry(
supabase, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
supabase, companyId, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
)
journalEntryId = je?.id ?? null
}
@@ -463,7 +473,7 @@ async function commitMarkInvoicePaid(
.from('invoices')
.update({ status: 'paid', paid_at: now, paid_amount: invoice.total })
.eq('id', invoiceId)
.eq('user_id', userId)
.eq('company_id', companyId)
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
@@ -473,6 +483,7 @@ async function commitMarkInvoicePaid(
async function commitSendInvoice(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>,
userEmail?: string
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
@@ -487,7 +498,7 @@ async function commitSendInvoice(
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
@@ -501,7 +512,7 @@ async function commitSendInvoice(
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (companyError || !company) return { error: 'Company settings missing', status: 500 }
@@ -554,14 +565,14 @@ async function commitSendInvoice(
if (!result.success) return { error: `Failed to send email: ${result.error}`, status: 500 }
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('user_id', userId)
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('company_id', companyId)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let createdJournalEntryId: string | undefined
if (isRealInvoice && (company.accounting_method === 'accrual' || !company.accounting_method)) {
try {
const je = await createInvoiceJournalEntry(
supabase, userId, invoice as Invoice, (company as CompanySettings).entity_type
supabase, companyId, userId, invoice as Invoice, (company as CompanySettings).entity_type
)
if (je) {
createdJournalEntryId = je.id
@@ -573,7 +584,7 @@ async function commitSendInvoice(
if (isRealInvoice) {
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, userId, {
await uploadDocument(supabase, userId, companyId, {
name: filename,
buffer: pdfArrayBuffer,
type: 'application/pdf',
@@ -584,7 +595,7 @@ async function commitSendInvoice(
} catch { /* non-blocking */ }
}
await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId } })
await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId, companyId } })
return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } }
}
@@ -592,6 +603,7 @@ async function commitSendInvoice(
async function commitMarkInvoiceSent(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const invoiceId = params.invoice_id as string
@@ -600,7 +612,7 @@ async function commitMarkInvoiceSent(
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
@@ -610,14 +622,14 @@ async function commitMarkInvoiceSent(
.from('invoices')
.update({ status: 'sent' })
.eq('id', invoiceId)
.eq('user_id', userId)
.eq('company_id', companyId)
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
@@ -626,7 +638,7 @@ async function commitMarkInvoiceSent(
if (isRealInvoice && (settings?.accounting_method === 'accrual' || !settings?.accounting_method)) {
try {
const je = await createInvoiceJournalEntry(
supabase, userId, invoice as Invoice,
supabase, companyId, userId, invoice as Invoice,
(settings?.entity_type as EntityType) || 'enskild_firma',
invoice.customer?.name
)
@@ -643,6 +655,7 @@ async function commitMarkInvoiceSent(
async function commitMatchTransactionInvoice(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const transactionId = params.transaction_id as string
@@ -652,7 +665,7 @@ async function commitMatchTransactionInvoice(
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (txError || !transaction) return { error: 'Transaction not found', status: 404 }
@@ -663,7 +676,7 @@ async function commitMatchTransactionInvoice(
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (invError || !invoice) return { error: 'Invoice not found', status: 404 }
@@ -673,7 +686,7 @@ async function commitMatchTransactionInvoice(
// Storno conflicting journal entry
if (transaction.journal_entry_id) {
await reverseEntry(supabase, userId, transaction.journal_entry_id)
await reverseEntry(supabase, companyId, userId, transaction.journal_entry_id)
await supabase.from('transactions').update({ journal_entry_id: null }).eq('id', transactionId)
}
@@ -688,7 +701,7 @@ async function commitMatchTransactionInvoice(
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
@@ -698,12 +711,12 @@ async function commitMatchTransactionInvoice(
try {
if (accountingMethod === 'cash' && isFullyPaid) {
const je = await createInvoiceCashEntry(
supabase, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
supabase, companyId, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
)
journalEntryId = je?.id ?? null
} else {
const je = await createInvoicePaymentJournalEntry(
supabase, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount
supabase, companyId, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount
)
journalEntryId = je?.id ?? null
}
@@ -733,6 +746,7 @@ async function commitMatchTransactionInvoice(
await supabase.from('invoice_payments').insert({
user_id: userId,
company_id: companyId,
invoice_id: invoiceId,
payment_date: transaction.date,
amount: paidAmount,
@@ -757,7 +771,7 @@ async function commitMatchTransactionInvoice(
try {
await eventBus.emit({
type: 'invoice.match_confirmed',
payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId },
payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId, companyId },
})
} catch { /* non-critical */ }
@@ -778,12 +792,14 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch the pending operation
const { data: op, error: fetchError } = await supabase
.from('pending_operations')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchError || !op) {
@@ -804,25 +820,25 @@ export async function POST(
switch (pendingOp.operation_type) {
case 'categorize_transaction':
result = await commitCategorizeTransaction(supabase, user.id, pendingOp.params)
result = await commitCategorizeTransaction(supabase, user.id, companyId, pendingOp.params)
break
case 'create_customer':
result = await commitCreateCustomer(supabase, user.id, pendingOp.params)
result = await commitCreateCustomer(supabase, user.id, companyId, pendingOp.params)
break
case 'create_invoice':
result = await commitCreateInvoice(supabase, user.id, pendingOp.params)
result = await commitCreateInvoice(supabase, user.id, companyId, pendingOp.params)
break
case 'mark_invoice_paid':
result = await commitMarkInvoicePaid(supabase, user.id, pendingOp.params)
result = await commitMarkInvoicePaid(supabase, user.id, companyId, pendingOp.params)
break
case 'send_invoice':
result = await commitSendInvoice(supabase, user.id, pendingOp.params, user.email)
result = await commitSendInvoice(supabase, user.id, companyId, pendingOp.params, user.email)
break
case 'mark_invoice_sent':
result = await commitMarkInvoiceSent(supabase, user.id, pendingOp.params)
result = await commitMarkInvoiceSent(supabase, user.id, companyId, pendingOp.params)
break
case 'match_transaction_invoice':
result = await commitMatchTransactionInvoice(supabase, user.id, pendingOp.params)
result = await commitMatchTransactionInvoice(supabase, user.id, companyId, pendingOp.params)
break
default:
return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 })
@@ -11,6 +11,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { POST } from '../../reject/route'
describe('POST /api/pending-operations/:id/reject', () => {
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* POST /api/pending-operations/:id/reject
@@ -18,11 +19,13 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { data: op, error: fetchError } = await supabase
.from('pending_operations')
.select('id, status')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchError || !op) {
@@ -10,6 +10,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route'
describe('GET /api/pending-operations', () => {
+4 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateQuery } from '@/lib/api/validate'
import { PendingOperationsQuerySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/pending-operations
@@ -16,6 +17,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const result = validateQuery(request, PendingOperationsQuerySchema)
if (!result.success) return result.response
const { status, limit, offset } = result.data
@@ -23,7 +26,7 @@ export async function GET(request: Request) {
const { data, error, count } = await supabase
.from('pending_operations')
.select('*', { count: 'exact' })
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('status', status)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
+4 -1
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate'
import { BankLinkSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -15,11 +16,13 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, BankLinkSchema)
if (!validation.success) return validation.response
const { transaction_id, journal_entry_id } = validation.data
const result = await manualLink(supabase, user.id, transaction_id, journal_entry_id)
const result = await manualLink(supabase, companyId, transaction_id, journal_entry_id)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
+4 -1
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate'
import { RunReconciliationSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
@@ -15,11 +16,13 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, RunReconciliationSchema)
if (!validation.success) return validation.response
const { date_from, date_to, dry_run } = validation.data
const result = await runReconciliation(supabase, user.id, {
const result = await runReconciliation(supabase, companyId, {
dateFrom: date_from,
dateTo: date_to,
dryRun: dry_run ?? false,
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -10,11 +11,13 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const status = await getReconciliationStatus(supabase, user.id, dateFrom, dateTo)
const status = await getReconciliationStatus(supabase, companyId, dateFrom, dateTo)
return NextResponse.json({ data: status })
}
+4 -1
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate'
import { BankUnlinkSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function POST(request: Request) {
const supabase = await createClient()
@@ -12,11 +13,13 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, BankUnlinkSchema)
if (!validation.success) return validation.response
const { transaction_id } = validation.data
const result = await unlinkReconciliation(supabase, user.id, transaction_id)
const result = await unlinkReconciliation(supabase, companyId, transaction_id)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -10,11 +11,13 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const lines = await fetchUnlinkedGLLines(supabase, user.id, dateFrom, dateTo)
const lines = await fetchUnlinkedGLLines(supabase, companyId, dateFrom, dateTo)
return NextResponse.json({ data: lines })
}
+5 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -12,15 +13,17 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const asOfDate = searchParams.get('as_of_date') || undefined
const periodId = searchParams.get('period_id') || undefined
const ledger = await generateARLedger(supabase, user.id, asOfDate)
const ledger = await generateARLedger(supabase, companyId, asOfDate)
let reconciliation = null
if (periodId) {
reconciliation = await generateARReconciliation(supabase, user.id, periodId)
reconciliation = await generateARReconciliation(supabase, companyId, periodId)
}
return NextResponse.json({
@@ -9,6 +9,11 @@ vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { createClient } from '@/lib/supabase/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { GET } from '../route'
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { requireCompanyId } from '@/lib/company/context'
import type { AuditLogEntry, AuditAction } from '@/types'
const CSV_HEADERS = 'timestamp,action,table_name,record_id,description,old_state,new_state'
@@ -34,6 +35,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const format = searchParams.get('format') || 'json'
@@ -52,7 +55,7 @@ export async function GET(request: Request) {
const pageSize = 500
while (true) {
const result = await getAuditLog(supabase, user.id, {
const result = await getAuditLog(supabase, companyId, {
...filters,
page,
pageSize,
+5 -2
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -10,6 +11,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
@@ -21,11 +24,11 @@ export async function GET(request: Request) {
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
try {
const result = await generateBalanceSheet(supabase, user.id, periodId)
const result = await generateBalanceSheet(supabase, companyId, periodId)
if (period) {
result.period = {
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateFullArchive } from '@/lib/reports/full-archive-export'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -10,6 +11,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
@@ -18,7 +21,7 @@ export async function GET(request: Request) {
}
try {
const zipBuffer = await generateFullArchive(supabase, user.id, {
const zipBuffer = await generateFullArchive(supabase, companyId, {
period_id: periodId,
include_documents: searchParams.get('include_documents') !== 'false',
})
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -11,6 +12,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const accountFrom = searchParams.get('account_from') || undefined
@@ -20,7 +23,7 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
const data = await generateGeneralLedger(supabase, user.id, periodId, accountFrom, accountTo)
const data = await generateGeneralLedger(supabase, companyId, periodId, accountFrom, accountTo)
return NextResponse.json({ data })
}
+5 -2
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -10,6 +11,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
@@ -22,11 +25,11 @@ export async function GET(request: Request) {
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
try {
const result = await generateIncomeStatement(supabase, user.id, periodId)
const result = await generateIncomeStatement(supabase, companyId, periodId)
if (period) {
result.period = {
+4 -1
View File
@@ -6,6 +6,7 @@ import {
sruFileToString,
getSRUFilename,
} from '@/lib/reports/ink2/sru-generator'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/reports/ink2
@@ -24,6 +25,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const format = searchParams.get('format') || 'json'
@@ -36,7 +39,7 @@ export async function GET(request: Request) {
}
try {
const declaration = await generateINK2Declaration(supabase, user.id, periodId)
const declaration = await generateINK2Declaration(supabase, companyId, periodId)
if (format === 'sru') {
const sruFile = generateSRUFile(declaration)
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateJournalRegister } from '@/lib/reports/journal-register'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -11,6 +12,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
@@ -18,7 +21,7 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
const data = await generateJournalRegister(supabase, user.id, periodId)
const data = await generateJournalRegister(supabase, companyId, periodId)
return NextResponse.json({ data })
}
+10 -7
View File
@@ -11,6 +11,7 @@ import {
calculateAvgPaymentDays,
} from '@/lib/reports/kpi'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import { requireCompanyId } from '@/lib/company/context'
import type { KPIReport, KPIPreferences } from '@/types'
export async function GET(request: Request) {
@@ -18,6 +19,8 @@ export async function GET(request: Request) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
if (!periodId) {
@@ -28,7 +31,7 @@ export async function GET(request: Request) {
.from('fiscal_periods')
.select('*')
.eq('id', periodId)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
@@ -39,7 +42,7 @@ export async function GET(request: Request) {
const { data: prefsData } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', 'core/kpi')
.eq('key', 'preferences')
.single()
@@ -50,14 +53,14 @@ export async function GET(request: Request) {
const [incomeStatement, trialBalanceResult, arLedger, monthlyBreakdown, paidInvoicesResult] =
await Promise.all([
generateIncomeStatement(supabase, user.id, periodId),
generateTrialBalance(supabase, user.id, periodId),
generateARLedger(supabase, user.id),
generateMonthlyBreakdown(supabase, user.id, periodId),
generateIncomeStatement(supabase, companyId, periodId),
generateTrialBalance(supabase, companyId, periodId),
generateARLedger(supabase, companyId),
generateMonthlyBreakdown(supabase, companyId, periodId),
supabase
.from('invoices')
.select('invoice_date, paid_at')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('status', 'paid')
.not('paid_at', 'is', null),
])
+4 -1
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -10,6 +11,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
@@ -18,7 +21,7 @@ export async function GET(request: Request) {
}
try {
const data = await generateMonthlyBreakdown(supabase, user.id, periodId)
const data = await generateMonthlyBreakdown(supabase, companyId, periodId)
return NextResponse.json({ data })
} catch {
return NextResponse.json({ error: 'Failed to generate monthly breakdown' }, { status: 500 })
+4 -1
View File
@@ -6,6 +6,7 @@ import {
sruFileToString,
getSRUFilename,
} from '@/lib/reports/ne-bilaga/sru-generator'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/reports/ne-bilaga
@@ -28,6 +29,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const format = searchParams.get('format') || 'json'
@@ -40,7 +43,7 @@ export async function GET(request: Request) {
}
try {
const declaration = await generateNEDeclaration(supabase, user.id, periodId)
const declaration = await generateNEDeclaration(supabase, companyId, periodId)
if (format === 'sru') {
// Generate and return SRU file

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