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
@@ -18,6 +18,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),
@@ -155,7 +159,7 @@ describe('POST /api/transactions/[id]/book', () => {
expect(body.journal_entry_id).toBe('je-new')
expect(body.data.id).toBe('je-new')
expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', {
expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', {
fiscal_period_id: VALID_UUID,
entry_date: '2025-01-15',
description: 'Test booking',
+6 -2
View File
@@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { validateBody } from '@/lib/api/validate'
import { BookTransactionSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction } from '@/types'
ensureInitialized()
@@ -22,6 +23,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, BookTransactionSchema)
if (!validation.success) return validation.response
const { fiscal_period_id, entry_date, description, lines } = validation.data
@@ -31,7 +34,7 @@ export async function POST(
.from('transactions')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchError || !transaction) {
@@ -49,7 +52,7 @@ export async function POST(
// Create journal entry via the engine
let journalEntry
try {
journalEntry = await createJournalEntry(supabase, user.id, {
journalEntry = await createJournalEntry(supabase, companyId, user.id, {
fiscal_period_id,
entry_date,
description,
@@ -90,6 +93,7 @@ export async function POST(
account: lines[0]?.account_number || '',
taxCode: '',
userId: user.id,
companyId,
},
})
} catch {
@@ -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 mockBuildMappingResultFromCategory = vi.fn()
vi.mock('@/lib/bookkeeping/category-mapping', () => ({
buildMappingResultFromCategory: (...args: unknown[]) =>
@@ -161,7 +166,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
expect(body.category).toBe('expense_software')
expect(mockSaveUserMappingRule).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'company-1',
'GitHub',
'6200',
'1930',
+18 -11
View File
@@ -7,6 +7,7 @@ import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEnt
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
import { requireCompanyId } from '@/lib/company/context'
import type { CategorizationTemplate } from '@/types'
import { validateBody } from '@/lib/api/validate'
import { CategorizeTransactionSchema } from '@/lib/api/schemas'
@@ -20,6 +21,7 @@ ensureInitialized()
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
companyId: string,
date: string,
fiscalYearStartMonth: number = 1
): Promise<boolean> {
@@ -27,7 +29,7 @@ async function ensureFiscalPeriod(
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)
@@ -68,11 +70,12 @@ async function ensureFiscalPeriod(
.from('fiscal_periods')
.upsert({
user_id: userId,
company_id: companyId,
name: periodName,
period_start: periodStart,
period_end: periodEnd,
}, {
onConflict: 'user_id,period_start,period_end',
onConflict: 'company_id,period_start,period_end',
})
if (error) {
@@ -96,6 +99,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Parse and validate request body
const validation = await validateBody(request, CategorizeTransactionSchema)
if (!validation.success) return validation.response
@@ -107,7 +112,7 @@ export async function POST(
.from('transactions')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchError || !transaction) {
@@ -149,7 +154,7 @@ export async function POST(
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, fiscal_year_start_month')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
@@ -188,7 +193,7 @@ export async function POST(
.from('categorization_templates')
.select('*')
.eq('id', body.counterparty_template_id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('is_active', true)
.maybeSingle()
@@ -233,7 +238,7 @@ export async function POST(
const { data: accountExists } = await supabase
.from('chart_of_accounts')
.select('account_number, account_class')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('account_number', body.account_override)
.single()
@@ -266,7 +271,7 @@ export async function POST(
}
// Ensure fiscal period exists for the transaction date
await ensureFiscalPeriod(supabase, user.id, transaction.date, fiscalYearStartMonth)
await ensureFiscalPeriod(supabase, user.id, companyId, transaction.date, fiscalYearStartMonth)
// Try to create journal entry
let journalEntryCreated = false
@@ -276,6 +281,7 @@ export async function POST(
try {
const journalEntry = await createTransactionJournalEntry(
supabase,
companyId,
user.id,
transaction as Transaction,
mappingResult
@@ -296,7 +302,7 @@ export async function POST(
try {
await saveUserMappingRule(
supabase,
user.id,
companyId,
transaction.merchant_name,
mappingResult.debit_account,
mappingResult.credit_account,
@@ -333,7 +339,7 @@ export async function POST(
.from('document_attachments')
.update({ journal_entry_id: journalEntryId })
.eq('id', receipt.document_id)
.eq('user_id', user.id)
.eq('company_id', companyId)
}
} catch (linkErr) {
console.error('[categorize] Failed to link receipt document:', linkErr)
@@ -347,7 +353,7 @@ export async function POST(
.from('invoice_inbox_items')
.update({ status: 'confirmed' })
.eq('id', body.inbox_item_id)
.eq('user_id', user.id)
.eq('company_id', companyId)
// Link inbox item's document to the journal entry
if (journalEntryId) {
@@ -362,7 +368,7 @@ export async function POST(
.from('document_attachments')
.update({ journal_entry_id: journalEntryId })
.eq('id', inboxItem.document_id)
.eq('user_id', user.id)
.eq('company_id', companyId)
}
}
} catch (inboxErr) {
@@ -395,6 +401,7 @@ export async function POST(
account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId: user.id,
companyId,
},
})
@@ -37,6 +37,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: (...args: unknown[]) => mockCreateClient(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
describe('POST /api/transactions/[id]/describe', () => {
let POST: typeof import('../route').POST
+6 -3
View File
@@ -6,6 +6,7 @@ import { DescribeTransactionSchema } from '@/lib/api/schemas'
import { extensionRegistry } from '@/lib/extensions/registry'
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
import { findCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction, EntityType, VatTreatment } from '@/types'
import type { Extension } from '@/lib/extensions/types'
@@ -82,6 +83,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, DescribeTransactionSchema)
if (!validation.success) return validation.response
const { description } = validation.data
@@ -91,7 +94,7 @@ export async function POST(
.from('transactions')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchError || !transaction) {
@@ -102,7 +105,7 @@ export async function POST(
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
@@ -155,7 +158,7 @@ export async function POST(
const { count } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('merchant_name', transaction.merchant_name)
.is('journal_entry_id', null)
.neq('id', id)
@@ -40,6 +40,10 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { POST } from '../route'
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
@@ -212,6 +216,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
// Verify accrual payment entry was called with paymentAmount
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: VALID_UUID }),
'2024-06-15',
@@ -270,7 +275,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBe('je-payment')
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'je-conflict')
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'je-conflict')
})
it('returns 500 when storno fails — no partial state change', async () => {
@@ -10,6 +10,7 @@ import { MatchInvoiceSchema } from '@/lib/api/schemas'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import type { EntityType, Invoice, Transaction } from '@/types'
ensureInitialized()
@@ -39,6 +40,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Parse and validate request body
const validation = await validateBody(request, MatchInvoiceSchema)
if (!validation.success) return validation.response
@@ -49,7 +52,7 @@ export async function POST(
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchTxError || !transaction) {
@@ -77,7 +80,7 @@ export async function POST(
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice_id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchInvError || !invoice) {
@@ -97,7 +100,7 @@ export async function POST(
// If storno fails, return 500 immediately — nothing else has been modified.
if (transaction.journal_entry_id) {
try {
await reverseEntry(supabase, user.id, transaction.journal_entry_id)
await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id)
// Clear the journal_entry_id on the transaction
const { error: clearJeError } = await supabase
@@ -136,7 +139,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'
@@ -151,6 +154,7 @@ export async function POST(
// Kontantmetoden, full payment: combined revenue entry with per-line VAT rates
const journalEntry = await createInvoiceCashEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
transaction.date,
@@ -168,6 +172,7 @@ export async function POST(
// final payment when createInvoiceCashEntry is called.
const journalEntry = await createInvoicePaymentJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
transaction.date,
@@ -180,6 +185,7 @@ export async function POST(
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
const journalEntry = await createInvoicePaymentJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
transaction.date,
@@ -235,6 +241,7 @@ export async function POST(
.from('invoice_payments')
.insert({
user_id: user.id,
company_id: companyId,
invoice_id,
payment_date: transaction.date,
amount: paidAmount,
@@ -292,6 +299,7 @@ export async function POST(
invoice: invoice as Invoice,
transaction: transaction as Transaction,
userId: user.id,
companyId,
},
})
} catch {
@@ -9,6 +9,7 @@ import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types'
ensureInitialized()
@@ -31,6 +32,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, MatchSupplierInvoiceSchema)
if (!validation.success) return validation.response
const { supplier_invoice_id } = validation.data
@@ -40,7 +43,7 @@ export async function POST(
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchTxError || !transaction) {
@@ -67,7 +70,7 @@ export async function POST(
.from('supplier_invoices')
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
.eq('id', supplier_invoice_id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (fetchInvError || !invoice) {
@@ -88,7 +91,7 @@ export async function POST(
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
@@ -100,6 +103,7 @@ export async function POST(
if (accountingMethod === 'cash') {
const journalEntry = await createSupplierInvoiceCashEntry(
supabase,
companyId,
user.id,
invoice as SupplierInvoice,
(invoice.items || []) as SupplierInvoiceItem[],
@@ -110,6 +114,7 @@ export async function POST(
} else {
const journalEntry = await createSupplierInvoicePaymentEntry(
supabase,
companyId,
user.id,
invoice as SupplierInvoice,
paymentAmount,
@@ -157,6 +162,7 @@ export async function POST(
.from('supplier_invoice_payments')
.insert({
user_id: user.id,
company_id: companyId,
supplier_invoice_id,
payment_date: transaction.date,
amount: paymentAmount,
@@ -205,6 +211,7 @@ export async function POST(
supplierInvoice: invoice as SupplierInvoice,
transaction: transaction as Transaction,
userId: user.id,
companyId,
},
})
} catch {
@@ -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),
@@ -103,6 +107,6 @@ describe('POST /api/transactions/[id]/uncategorize', () => {
expect(status).toBe(200)
expect(body).toEqual({ success: true })
expect(mockReverseEntry).toHaveBeenCalledWith(mockSupabase, 'user-1', 'je-1')
expect(mockReverseEntry).toHaveBeenCalledWith(mockSupabase, 'company-1', 'user-1', 'je-1')
})
})
@@ -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()
@@ -15,6 +16,8 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
// Fetch transaction
@@ -22,7 +25,7 @@ export async function POST(
.from('transactions')
.select('id, journal_entry_id')
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (txError || !transaction) {
@@ -38,7 +41,7 @@ export async function POST(
.from('journal_entries')
.select('id, status')
.eq('id', transaction.journal_entry_id)
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (entryError || !entry) {
@@ -51,7 +54,7 @@ export async function POST(
// Storno reversal (legally compliant — never deletes)
try {
await reverseEntry(supabase, user.id, transaction.journal_entry_id)
await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id)
} catch (err) {
const message = err instanceof Error ? err.message : 'Reversal failed'
return NextResponse.json({ error: message }, { status: 500 })
@@ -66,7 +69,7 @@ export async function POST(
journal_entry_id: null,
})
.eq('id', id)
.eq('user_id', user.id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: 'Failed to reset transaction' }, { status: 500 })
@@ -65,6 +65,11 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: (...args: unknown[]) => mockCreateClient(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
describe('POST /api/transactions/batch-describe', () => {
let POST: typeof import('../route').POST
@@ -171,7 +176,7 @@ describe('POST /api/transactions/batch-describe', () => {
// Verify mapping rule was saved with user description
expect(mockSaveUserMappingRule).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'company-1',
'Staples',
'6110',
'1930',
+10 -4
View File
@@ -7,6 +7,7 @@ import { BatchDescribeSchema } from '@/lib/api/schemas'
import { getTemplateById, buildMappingResultFromTemplate } from '@/lib/bookkeeping/booking-templates'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction, EntityType, TransactionCategory } from '@/types'
ensureInitialized()
@@ -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 validation = await validateBody(request, BatchDescribeSchema)
if (!validation.success) return validation.response
const { merchant_name, template_id, is_business, user_description } = validation.data
@@ -34,7 +37,7 @@ export async function POST(request: Request) {
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, fiscal_year_start_month')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
@@ -44,7 +47,7 @@ export async function POST(request: Request) {
const { data: transactions, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('merchant_name', merchant_name)
.is('journal_entry_id', null)
.order('date', { ascending: true })
@@ -99,16 +102,18 @@ export async function POST(request: Request) {
.from('fiscal_periods')
.upsert({
user_id: user.id,
company_id: companyId,
name: periodName,
period_start: periodStart,
period_end: periodEnd,
}, { onConflict: 'user_id,period_start,period_end' })
}, { onConflict: 'company_id,period_start,period_end' })
// Create journal entry
let journalEntryId: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
supabase,
companyId,
user.id,
tx as Transaction,
mappingResult
@@ -137,6 +142,7 @@ export async function POST(request: Request) {
account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId: user.id,
companyId,
},
})
@@ -154,7 +160,7 @@ export async function POST(request: Request) {
const sampleResult = buildMappingResultFromTemplate(template, sampleTx, entityType)
await saveUserMappingRule(
supabase,
user.id,
companyId,
merchant_name,
sampleResult.debit_account,
sampleResult.credit_account,
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction } from '@/types'
/**
@@ -16,11 +17,13 @@ export async function POST() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Fetch uncategorized income transactions without a potential match
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.is('is_business', null)
.gt('amount', 0)
.is('potential_invoice_id', null)
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction, EntityType } from '@/types'
/**
@@ -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 { transaction_ids } = await request.json()
if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) {
@@ -30,7 +33,7 @@ export async function POST(request: Request) {
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', user.id)
.eq('company_id', companyId)
.in('id', ids)
if (txError || !transactions) {
@@ -41,7 +44,7 @@ export async function POST(request: Request) {
const { data: mappingRules } = 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', { ascending: false })
@@ -49,7 +52,7 @@ export async function POST(request: Request) {
const { data: historicalTxns } = await supabase
.from('transactions')
.select('category')
.eq('user_id', user.id)
.eq('company_id', companyId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.neq('category', 'private')
@@ -67,7 +70,7 @@ export async function POST(request: Request) {
const { data: aiRecords } = await supabase
.from('extension_data')
.select('key, value')
.eq('user_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', 'ai-categorization')
.in('key', aiKeys)
@@ -90,12 +93,12 @@ export async function POST(request: Request) {
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
const entityType = (settings?.entity_type as EntityType) || undefined
// Batch counterparty template matching (1 DB query, in-memory matching)
const counterpartyMatches = await findCounterpartyTemplatesBatch(supabase, user.id, transactions as Transaction[])
const counterpartyMatches = await findCounterpartyTemplatesBatch(supabase, companyId, transactions as Transaction[])
// Generate initial suggestions for each transaction
const suggestions: Record<string, SuggestedCategory[]> = {}
@@ -147,7 +150,7 @@ export async function POST(request: Request) {
const { data: matchedInboxItems } = await supabase
.from('invoice_inbox_items')
.select('matched_transaction_id, suggested_template_id, suggested_template_confidence')
.eq('user_id', user.id)
.eq('company_id', companyId)
.in('matched_transaction_id', ids)
.not('suggested_template_id', 'is', null)