feat: multi-tenant company refactor (GNU-19) (#153)
* feat: multi-tenant company refactor (GNU-19) Introduce companies table, company_members, and user_preferences to support multiple companies per user. All data scoping changes from user_id to company_id across the entire codebase. Key changes: - Database migration: new tables, company_id on 40+ tables, backfill, RLS rewrite from user_id to company-member-based, updated RPCs - Types: Company, CompanyMember, CompanyRole, UserPreferences types; company_id added to all entity interfaces; companyId on all events - Engine: all 7 core functions take companyId; storno, period, year-end services updated; 16 report generators updated - Middleware: company context resolution (cookie → prefs → first company) - API routes: ~120 routes updated with requireCompanyId() - Frontend: CompanyProvider context, layout/dashboard/onboarding updated - Extensions: context factory, 9 extensions, all lib files updated - Tests: 1880 tests passing, all helpers updated with company_id defaults Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add database migrations for multi-tenant company and team system (GNU-19) Adds company_invitations, company creation RPC, team_members, account deletion RPC, and teams table refactor migrations. Updates base multi-tenant migration with cascading FKs and onboarding_step column. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add team types and update core infrastructure for multi-tenancy (GNU-19) Adds TeamRole, MemberSource, and Team types. Refactors Supabase service client to be stateless, updates middleware for team-aware routing, extends CompanyContext with team/role fields, and updates extension service types to accept companyId. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: thread company_id through business logic functions (GNU-19) Replaces user_id scoping with company_id across all lib modules: bookkeeping, documents, transactions, invoices, reconciliation, tax, deadlines, and import. Updates corresponding tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: thread company_id through API routes and extensions (GNU-19) Updates all existing API routes to extract and pass companyId. Updates enable-banking and arcim-migration extensions for company-scoped transaction ingestion and sync. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add company and team management API routes (GNU-19) Adds CRUD endpoints for company members, company invitations, team members, and team invitations. Includes invite token utilities, email templates, and company switch server action. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add team/company UI components, pages, and dashboard updates (GNU-19) Adds CompanySwitcher, ConsultantEmptyState, Step0RoleChoice, company members and team management panels. Updates dashboard layout for team-aware routing, onboarding for multi-step role choice, and auth callback for team invite acceptance. Ignores supabase/.branches/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add null guards for company in import page (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: move appUrl declaration to outer scope in invite route (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add optional chaining for company.name in members section (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add optional chaining for second company.name in members section (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add null guards for company in extension components (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: pass companyId to executeSIEImport in arcim-migration extension (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update tests to use companyId instead of userId and improve type handling --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1756af6c5e
commit
0dd1f5ebc1
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user