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
@@ -26,6 +26,7 @@ function makeBASAccount(number: string, name: string): BASAccount {
return {
id: `bas-${number}`,
user_id: 'user-1',
company_id: 'company-1',
account_number: number,
account_name: name,
account_class: classNum,
+1
View File
@@ -17,6 +17,7 @@ function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
address: null,
fiscalYears: [{ yearIndex: 0, start: new Date(2024, 0, 1), end: new Date(2024, 11, 31) }],
currency: 'SEK',
kontoPlanType: null,
},
accounts: [
{ number: '1510', name: 'Kundfordringar' },
+45 -31
View File
@@ -85,7 +85,7 @@ export function generateImportPreview(
*/
export async function checkDuplicateImport(
supabase: SupabaseClient,
userId: string,
companyId: string,
fileContent: string
): Promise<SIEImport | null> {
const fileHash = await calculateFileHash(fileContent)
@@ -93,7 +93,7 @@ export async function checkDuplicateImport(
const { data } = await supabase
.from('sie_imports')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('file_hash', fileHash)
.eq('status', 'completed')
.single()
@@ -107,7 +107,7 @@ export async function checkDuplicateImport(
*/
async function cleanupStaleImportRecords(
supabase: SupabaseClient,
userId: string,
companyId: string,
fileHash: string
): Promise<void> {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
@@ -115,10 +115,9 @@ async function cleanupStaleImportRecords(
await supabase
.from('sie_imports')
.delete()
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('file_hash', fileHash)
.in('status', ['pending', 'failed'])
.lt('created_at', oneHourAgo)
}
/**
@@ -126,7 +125,7 @@ async function cleanupStaleImportRecords(
*/
async function ensureFiscalPeriod(
supabase: SupabaseClient,
userId: string,
companyId: string,
startDate: Date,
endDate: Date
): Promise<string> {
@@ -134,7 +133,7 @@ async function ensureFiscalPeriod(
const { data: containing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.eq('company_id', companyId)
.lte('period_start', formatDate(startDate))
.gte('period_end', formatDate(endDate))
.single()
@@ -148,7 +147,7 @@ async function ensureFiscalPeriod(
const { data: overlapping } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.eq('company_id', companyId)
.lte('period_start', formatDate(endDate))
.gte('period_end', formatDate(startDate))
.order('period_start', { ascending: false })
@@ -168,7 +167,7 @@ async function ensureFiscalPeriod(
const { data: newPeriod, error } = await supabase
.from('fiscal_periods')
.insert({
user_id: userId,
company_id: companyId,
name,
period_start: formatDate(startDate),
period_end: formatDate(endDate),
@@ -257,6 +256,7 @@ export function validateIBBalance(
*/
async function createOpeningBalanceEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
parsed: ParsedSIEFile,
@@ -319,7 +319,7 @@ async function createOpeningBalanceEntry(
const fiscalYearStart = parsed.stats.fiscalYearStart
const entryDate = fiscalYearStart ? formatDate(fiscalYearStart) : formatDate(new Date())
const entry = await createJournalEntry(supabase, userId, {
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
description: 'Ingående balanser från SIE-import',
@@ -336,6 +336,7 @@ async function createOpeningBalanceEntry(
*/
async function importVouchers(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
parsed: ParsedSIEFile,
@@ -553,7 +554,7 @@ async function importVouchers(
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('id, account_number')
.eq('user_id', userId)
.eq('company_id', companyId)
.in('account_number', [...allAccountNumbers])
const accountIdMap = new Map<string, string>()
@@ -563,7 +564,7 @@ async function importVouchers(
// Get starting voucher number
const { data: startNumber } = await supabase.rpc('next_voucher_number', {
p_user_id: userId,
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_series: voucherSeries,
})
@@ -586,6 +587,7 @@ async function importVouchers(
// Prepare journal entry headers
const entryInserts = batch.map((v, i) => ({
user_id: userId,
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
voucher_number: currentVoucherNumber + batchStart + i,
voucher_series: voucherSeries,
@@ -746,7 +748,7 @@ async function importVouchers(
if (results.created > 0) {
const highestUsed = currentVoucherNumber + preparedVouchers.length - 1
await supabase.rpc('reserve_voucher_range', {
p_user_id: userId,
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_series: voucherSeries,
p_highest_used: highestUsed,
@@ -785,6 +787,7 @@ export function isBalanceSheetAccount(accountNumber: string): boolean {
*/
async function createMigrationAdjustmentEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
parsed: ParsedSIEFile,
@@ -916,7 +919,7 @@ async function createMigrationAdjustmentEntry(
const firstDate = skippedDates[0] || '?'
const lastDate = skippedDates[skippedDates.length - 1] || '?'
const entry = await createJournalEntry(supabase, userId, {
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
description: `Omföringsverifikation: justering för ${skippedDetails.length} exkluderade verifikationer (${firstId}–${lastId}, ${firstDate}–${lastDate}) vid SIE-import`,
@@ -934,6 +937,7 @@ async function createMigrationAdjustmentEntry(
*/
async function ensureAccountExists(
supabase: SupabaseClient,
companyId: string,
userId: string,
accountNumber: string,
accountName: string
@@ -941,7 +945,7 @@ async function ensureAccountExists(
const { data } = await supabase
.from('chart_of_accounts')
.select('id')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('account_number', accountNumber)
.single()
@@ -952,6 +956,7 @@ async function ensureAccountExists(
if (basRef) {
await supabase.from('chart_of_accounts').insert({
user_id: userId,
company_id: companyId,
account_number: accountNumber,
account_name: basRef.account_name,
account_class: basRef.account_class,
@@ -977,6 +982,7 @@ async function ensureAccountExists(
await supabase.from('chart_of_accounts').insert({
user_id: userId,
company_id: companyId,
account_number: accountNumber,
account_name: accountName,
account_class: classNum,
@@ -996,6 +1002,7 @@ async function ensureAccountExists(
*/
async function createPendingImportRecord(
supabase: SupabaseClient,
companyId: string,
userId: string,
parsed: ParsedSIEFile,
fileContent: string,
@@ -1004,12 +1011,13 @@ async function createPendingImportRecord(
const fileHash = await calculateFileHash(fileContent)
// Clean up any stale pending/failed records for this hash to avoid UNIQUE conflicts
await cleanupStaleImportRecords(supabase, userId, fileHash)
await cleanupStaleImportRecords(supabase, companyId, fileHash)
const { data, error } = await supabase
.from('sie_imports')
.insert({
user_id: userId,
company_id: companyId,
filename,
file_hash: fileHash,
org_number: parsed.header.orgNumber,
@@ -1042,7 +1050,7 @@ async function createPendingImportRecord(
async function finalizeImportRecord(
supabase: SupabaseClient,
importId: string,
userId: string,
companyId: string,
result: ImportResult,
fileContent: string,
documentation?: MigrationDocumentation
@@ -1064,7 +1072,7 @@ async function finalizeImportRecord(
// Archive the SIE file to Supabase Storage (BFL 7 kap 1-2§ retention)
if (result.success) {
const storagePath = `${userId}/${importId}.se`
const storagePath = `${companyId}/${importId}.se`
const fileBlob = new Blob([fileContent], { type: 'text/plain; charset=cp437' })
const { error: uploadError } = await supabase.storage
.from('sie-files')
@@ -1086,14 +1094,14 @@ async function finalizeImportRecord(
*/
export async function saveMappings(
supabase: SupabaseClient,
userId: string,
companyId: string,
mappings: AccountMapping[]
): Promise<void> {
// Filter to only mapped accounts
const mappingsToSave = mappings
.filter((m) => m.targetAccount)
.map((m) => ({
user_id: userId,
company_id: companyId,
source_account: m.sourceAccount,
source_name: m.sourceName,
target_account: m.targetAccount,
@@ -1118,11 +1126,11 @@ export async function saveMappings(
/**
* Load existing account mappings for a user
*/
export async function loadMappings(supabase: SupabaseClient, userId: string): Promise<Map<string, AccountMapping>> {
export async function loadMappings(supabase: SupabaseClient, companyId: string): Promise<Map<string, AccountMapping>> {
const { data } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
const map = new Map<string, AccountMapping>()
@@ -1146,6 +1154,7 @@ export async function loadMappings(supabase: SupabaseClient, userId: string): Pr
*/
export async function executeSIEImport(
supabase: SupabaseClient,
companyId: string,
userId: string,
parsed: ParsedSIEFile,
mappings: AccountMapping[],
@@ -1180,7 +1189,7 @@ export async function executeSIEImport(
}
// Check for duplicate import (only completed imports count as duplicates)
const duplicate = await checkDuplicateImport(supabase, userId, options.fileContent)
const duplicate = await checkDuplicateImport(supabase, companyId, options.fileContent)
if (duplicate) {
result.errors.push(
`This file has already been imported on ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'}`
@@ -1191,6 +1200,7 @@ export async function executeSIEImport(
// Create pending import record early — ensures tracking even if later steps fail
result.importId = await createPendingImportRecord(
supabase,
companyId,
userId,
parsed,
options.fileContent,
@@ -1210,6 +1220,7 @@ export async function executeSIEImport(
seenTargets.add(mapping.targetAccount)
await ensureAccountExists(
supabase,
companyId,
userId,
mapping.targetAccount,
mapping.targetName
@@ -1229,7 +1240,7 @@ export async function executeSIEImport(
if (options.createFiscalPeriod) {
result.fiscalPeriodId = await ensureFiscalPeriod(
supabase,
userId,
companyId,
fiscalYearStart,
fiscalYearEnd
)
@@ -1238,7 +1249,7 @@ export async function executeSIEImport(
const { data: existing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.eq('company_id', companyId)
.lte('period_start', formatDate(fiscalYearStart))
.gte('period_end', formatDate(fiscalYearEnd))
.single()
@@ -1323,6 +1334,7 @@ export async function executeSIEImport(
result.openingBalanceEntryId = await createOpeningBalanceEntry(
supabase,
companyId,
userId,
result.fiscalPeriodId,
parsed,
@@ -1362,10 +1374,11 @@ export async function executeSIEImport(
}
// Ensure öresutjämning account 3741 exists in the user's chart
await ensureAccountExists(supabase, userId, '3741', 'Öresutjämning vid import')
await ensureAccountExists(supabase, companyId, userId, '3741', 'Öresutjämning vid import')
const voucherResults = await importVouchers(
supabase,
companyId,
userId,
result.fiscalPeriodId,
parsed,
@@ -1421,6 +1434,7 @@ export async function executeSIEImport(
try {
const adjustment = await createMigrationAdjustmentEntry(
supabase,
companyId,
userId,
result.fiscalPeriodId,
parsed,
@@ -1454,7 +1468,7 @@ export async function executeSIEImport(
// Save account mappings for future use (non-fatal — import data is already committed)
try {
await saveMappings(supabase, userId, mappings)
await saveMappings(supabase, companyId, mappings)
} catch (mappingError) {
console.error('[sie-import] Failed to save mappings (non-fatal):', mappingError)
result.warnings.push('Kunde inte spara kontomappningar — påverkar inte importerade data')
@@ -1472,7 +1486,7 @@ export async function executeSIEImport(
end: formatDate(fiscalYearEnd),
},
importedAt: new Date().toISOString(),
importedBy: userId,
importedBy: companyId,
accountMappings: {
total: mappingStats.total,
exact: mappingStats.exact,
@@ -1527,7 +1541,7 @@ export async function executeSIEImport(
await finalizeImportRecord(
supabase,
result.importId,
userId,
companyId,
result,
options.fileContent,
documentation
@@ -1537,7 +1551,7 @@ export async function executeSIEImport(
if (result.success && parsed.vouchers.length > 0) {
try {
const templateCount = await populateTemplatesFromSieVouchers(
supabase, userId, parsed.vouchers
supabase, companyId, parsed.vouchers
)
if (templateCount > 0) {
console.info(`[sie-import] ${templateCount} counterparty templates extracted from voucher history`)
@@ -1565,7 +1579,7 @@ export async function executeSIEImport(
await finalizeImportRecord(
supabase,
result.importId,
userId,
companyId,
result,
options.fileContent
)