* 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>
302 lines
9.5 KiB
TypeScript
302 lines
9.5 KiB
TypeScript
import { createJournalEntry, findFiscalPeriod } from './engine'
|
|
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
|
|
import { extractNetAmount, extractVatAmount } from './vat-entries'
|
|
import { createLogger } from '@/lib/logger'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type {
|
|
CreateJournalEntryInput,
|
|
CreateJournalEntryLineInput,
|
|
JournalEntry,
|
|
MappingResult,
|
|
Transaction,
|
|
} from '@/types'
|
|
|
|
const log = createLogger('transaction-entries')
|
|
|
|
/**
|
|
* Create a journal entry from a bank transaction using mapping engine result
|
|
*
|
|
* Standard expense pattern (domestic purchase with 25% VAT):
|
|
* Debit 5xxx/6xxx Expense account [net amount]
|
|
* Debit 2641 Ingående moms [VAT amount]
|
|
* Credit 1930 Företagskonto [total]
|
|
*
|
|
* Standard expense pattern (no VAT deduction):
|
|
* Debit 5xxx/6xxx Expense account [total]
|
|
* Credit 1930 Företagskonto [total]
|
|
*
|
|
* Private expense pattern:
|
|
* Debit 2013 Eget uttag [total]
|
|
* Credit 1930 Företagskonto [total]
|
|
*
|
|
* EU reverse charge purchase pattern:
|
|
* Debit 5xxx/6xxx Expense account [total]
|
|
* Debit 2645 Beräknad ingående moms [fiktiv VAT]
|
|
* Credit 2614 Utgående moms omvänd [fiktiv VAT]
|
|
* Credit 1930 Företagskonto [total]
|
|
*
|
|
* Income pattern:
|
|
* Debit 1930 Företagskonto [total]
|
|
* Credit 3xxx Revenue account [total]
|
|
*/
|
|
export async function createTransactionJournalEntry(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
transaction: Transaction,
|
|
mappingResult: MappingResult
|
|
): Promise<JournalEntry | null> {
|
|
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
|
throw new Error(
|
|
`Invalid mapping result: debit_account="${mappingResult.debit_account}", credit_account="${mappingResult.credit_account}". Both must be non-empty.`
|
|
)
|
|
}
|
|
|
|
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, transaction.date)
|
|
if (!fiscalPeriodId) {
|
|
log.warn('No open fiscal period found for transaction date:', transaction.date)
|
|
return null
|
|
}
|
|
|
|
const absAmountSek = Math.abs(resolveSekAmount(
|
|
transaction.amount, transaction.amount_sek, transaction.currency, transaction.exchange_rate
|
|
))
|
|
const absAmount = absAmountSek
|
|
const isExpense = transaction.amount < 0
|
|
const isForeign = transaction.currency !== 'SEK'
|
|
const currencyMeta = buildCurrencyMetadata(
|
|
transaction.currency,
|
|
isForeign ? Math.abs(transaction.amount) : undefined,
|
|
transaction.exchange_rate
|
|
)
|
|
const lines: CreateJournalEntryLineInput[] = []
|
|
|
|
if (mappingResult.default_private) {
|
|
// Private expense — use entity-specific account from mappingResult
|
|
lines.push(
|
|
{
|
|
account_number: mappingResult.debit_account,
|
|
debit_amount: absAmount,
|
|
credit_amount: 0,
|
|
line_description: `Privat: ${transaction.description}`,
|
|
},
|
|
{
|
|
account_number: mappingResult.credit_account || '1930',
|
|
debit_amount: 0,
|
|
credit_amount: absAmount,
|
|
line_description: transaction.description,
|
|
}
|
|
)
|
|
} else if (mappingResult.all_lines_complete) {
|
|
// Multi-line pattern: vat_lines contains ALL non-settlement lines with correct amounts.
|
|
// Settlement line = full absAmount on the appropriate side.
|
|
const settlementAccount = isExpense
|
|
? (mappingResult.credit_account || '1930')
|
|
: (mappingResult.debit_account || '1930')
|
|
|
|
if (isExpense) {
|
|
// All non-settlement lines (business, VAT, tax, rounding)
|
|
for (const line of mappingResult.vat_lines) {
|
|
lines.push({
|
|
account_number: line.account_number,
|
|
debit_amount: line.debit_amount,
|
|
credit_amount: line.credit_amount,
|
|
line_description: line.description || transaction.description,
|
|
})
|
|
}
|
|
// Credit bank for full amount
|
|
lines.push({
|
|
account_number: settlementAccount,
|
|
debit_amount: 0,
|
|
credit_amount: absAmount,
|
|
line_description: transaction.description,
|
|
...(settlementAccount === '1930' ? currencyMeta : {}),
|
|
})
|
|
} else {
|
|
// Debit bank for full amount
|
|
lines.push({
|
|
account_number: settlementAccount,
|
|
debit_amount: absAmount,
|
|
credit_amount: 0,
|
|
line_description: transaction.description,
|
|
...(settlementAccount === '1930' ? currencyMeta : {}),
|
|
})
|
|
// All non-settlement lines
|
|
for (const line of mappingResult.vat_lines) {
|
|
lines.push({
|
|
account_number: line.account_number,
|
|
debit_amount: line.debit_amount,
|
|
credit_amount: line.credit_amount,
|
|
line_description: line.description || transaction.description,
|
|
})
|
|
}
|
|
}
|
|
} else if (isExpense) {
|
|
// Business expense (legacy single debit/credit path)
|
|
const debitAccount = mappingResult.debit_account
|
|
const creditAccount = mappingResult.credit_account || '1930'
|
|
|
|
if (mappingResult.vat_lines.length > 0) {
|
|
// Has VAT handling (reverse charge or input VAT)
|
|
for (const vatLine of mappingResult.vat_lines) {
|
|
lines.push({
|
|
account_number: vatLine.account_number,
|
|
debit_amount: vatLine.debit_amount,
|
|
credit_amount: vatLine.credit_amount,
|
|
line_description: vatLine.description,
|
|
})
|
|
}
|
|
|
|
// Expense account gets the net amount (total minus VAT if applicable)
|
|
const vatDebit = mappingResult.vat_lines
|
|
.filter((l) => l.debit_amount > 0 && l.account_number === '2641')
|
|
.reduce((sum, l) => sum + l.debit_amount, 0)
|
|
// Round to 2 decimal places to avoid floating point issues
|
|
const netAmount = Math.round((absAmount - vatDebit) * 100) / 100
|
|
|
|
lines.push({
|
|
account_number: debitAccount,
|
|
debit_amount: netAmount,
|
|
credit_amount: 0,
|
|
line_description: transaction.description,
|
|
})
|
|
} else {
|
|
// No VAT handling - debit full amount to expense account
|
|
lines.push({
|
|
account_number: debitAccount,
|
|
debit_amount: absAmount,
|
|
credit_amount: 0,
|
|
line_description: transaction.description,
|
|
})
|
|
}
|
|
|
|
// Credit bank account
|
|
lines.push({
|
|
account_number: creditAccount,
|
|
debit_amount: 0,
|
|
credit_amount: absAmount,
|
|
line_description: transaction.description,
|
|
...(creditAccount === '1930' ? currencyMeta : {}),
|
|
})
|
|
} else {
|
|
// Income (legacy single debit/credit path)
|
|
const debitAccount = mappingResult.debit_account || '1930'
|
|
const creditAccount = mappingResult.credit_account
|
|
|
|
if (mappingResult.vat_lines.length > 0) {
|
|
// Has output VAT
|
|
const vatCredit = mappingResult.vat_lines
|
|
.filter(l => l.credit_amount > 0)
|
|
.reduce((sum, l) => sum + l.credit_amount, 0)
|
|
const netAmount = Math.round((absAmount - vatCredit) * 100) / 100
|
|
|
|
// Debit bank for gross amount
|
|
lines.push({
|
|
account_number: debitAccount,
|
|
debit_amount: absAmount,
|
|
credit_amount: 0,
|
|
line_description: transaction.description,
|
|
})
|
|
// Credit revenue for net amount
|
|
lines.push({
|
|
account_number: creditAccount,
|
|
debit_amount: 0,
|
|
credit_amount: netAmount,
|
|
line_description: transaction.description,
|
|
})
|
|
// Credit output VAT
|
|
for (const vatLine of mappingResult.vat_lines) {
|
|
lines.push({
|
|
account_number: vatLine.account_number,
|
|
debit_amount: vatLine.debit_amount,
|
|
credit_amount: vatLine.credit_amount,
|
|
line_description: vatLine.description,
|
|
})
|
|
}
|
|
} else {
|
|
// No VAT - simple two-line entry
|
|
lines.push(
|
|
{
|
|
account_number: debitAccount,
|
|
debit_amount: absAmount,
|
|
credit_amount: 0,
|
|
line_description: transaction.description,
|
|
},
|
|
{
|
|
account_number: creditAccount,
|
|
debit_amount: 0,
|
|
credit_amount: absAmount,
|
|
line_description: transaction.description,
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
const input: CreateJournalEntryInput = {
|
|
fiscal_period_id: fiscalPeriodId,
|
|
entry_date: transaction.date,
|
|
description: transaction.description,
|
|
source_type: 'bank_transaction',
|
|
source_id: transaction.id,
|
|
lines,
|
|
}
|
|
|
|
return createJournalEntry(supabase, companyId, userId, input)
|
|
}
|
|
|
|
/**
|
|
* Create a standard domestic expense entry with input VAT deduction
|
|
*/
|
|
export function buildDomesticExpenseLines(
|
|
amount: number,
|
|
expenseAccount: string,
|
|
description: string,
|
|
vatRate: number = 0.25
|
|
): CreateJournalEntryLineInput[] {
|
|
const absAmount = Math.abs(amount)
|
|
const lines: CreateJournalEntryLineInput[] = []
|
|
|
|
if (vatRate > 0) {
|
|
const vatAmount = extractVatAmount(absAmount, vatRate)
|
|
const netAmount = extractNetAmount(absAmount, vatRate)
|
|
|
|
lines.push(
|
|
{
|
|
account_number: expenseAccount,
|
|
debit_amount: netAmount,
|
|
credit_amount: 0,
|
|
line_description: description,
|
|
},
|
|
{
|
|
account_number: '2641', // Ingående moms
|
|
debit_amount: vatAmount,
|
|
credit_amount: 0,
|
|
line_description: `Ingående moms ${vatRate * 100}%`,
|
|
},
|
|
{
|
|
account_number: '1930', // Företagskonto
|
|
debit_amount: 0,
|
|
credit_amount: absAmount,
|
|
line_description: description,
|
|
}
|
|
)
|
|
} else {
|
|
lines.push(
|
|
{
|
|
account_number: expenseAccount,
|
|
debit_amount: absAmount,
|
|
credit_amount: 0,
|
|
line_description: description,
|
|
},
|
|
{
|
|
account_number: '1930',
|
|
debit_amount: 0,
|
|
credit_amount: absAmount,
|
|
line_description: description,
|
|
}
|
|
)
|
|
}
|
|
|
|
return lines
|
|
}
|