Files
accounted/lib/core/bookkeeping/storno-service.ts
T
MattssonandClaude Opus 4.6 0dd1f5ebc1 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>
2026-03-31 16:41:52 +02:00

267 lines
8.7 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import type {
CreateJournalEntryLineInput,
JournalEntry,
JournalEntryLine,
} from '@/types'
import { validateBalance, getNextVoucherNumber, getSwedishLocalDate } from '@/lib/bookkeeping/engine'
/**
* Storno Service - 3-step correction flow per Bokföringslagen
*
* Swedish bookkeeping law requires that committed entries cannot be modified.
* To correct an error, you must:
* 1. Create a storno (reversal) entry that nullifies the original
* 2. Create a corrected entry with the right data
* 3. Link all three via reverses_id, reversed_by_id, correction_of_id
*/
/**
* Correct an existing posted journal entry using the storno method.
*
* Returns: { reversal, corrected } - the two new entries created
*/
export async function correctEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
originalEntryId: string,
correctedLines: CreateJournalEntryLineInput[]
): Promise<{ reversal: JournalEntry; corrected: JournalEntry }> {
// Validate the corrected lines are balanced
const balance = validateBalance(correctedLines)
if (!balance.valid) {
throw new Error(
`Corrected entry is not balanced: debits (${balance.totalDebit}) != credits (${balance.totalCredit})`
)
}
// Fetch original entry with lines
const { data: original, error: fetchError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', originalEntryId)
.eq('company_id', companyId)
.single()
if (fetchError || !original) {
throw new Error('Original journal entry not found')
}
if (original.status !== 'posted') {
throw new Error('Can only correct posted entries')
}
const originalLines = (original.lines as JournalEntryLine[]) || []
// ===== Step 1: Create storno (reversal) entry =====
const reversalVoucherNumber = await getNextVoucherNumber(
supabase,
companyId,
original.fiscal_period_id,
original.voucher_series || 'A'
)
const { data: reversalEntry, error: reversalError } = await supabase
.from('journal_entries')
.insert({
company_id: companyId,
user_id: userId,
fiscal_period_id: original.fiscal_period_id,
voucher_number: reversalVoucherNumber,
voucher_series: original.voucher_series || 'A',
entry_date: getSwedishLocalDate(),
description: `Storno: ${original.description}`,
source_type: 'storno',
reverses_id: originalEntryId,
status: 'draft',
})
.select()
.single()
if (reversalError || !reversalEntry) {
throw new Error(`Failed to create reversal entry: ${reversalError?.message}`)
}
// Insert reversed lines (swap debit and credit)
const reversalLineInserts = originalLines.map((line, index) => ({
journal_entry_id: reversalEntry.id,
account_number: line.account_number,
account_id: line.account_id || null,
debit_amount: Math.round((Number(line.credit_amount) || 0) * 100) / 100,
credit_amount: Math.round((Number(line.debit_amount) || 0) * 100) / 100,
currency: line.currency || 'SEK',
amount_in_currency: line.amount_in_currency ? -Number(line.amount_in_currency) : null,
exchange_rate: line.exchange_rate || null,
line_description: `Storno: ${line.line_description || ''}`,
tax_code: line.tax_code || null,
cost_center: line.cost_center || null,
project: line.project || null,
sort_order: index,
}))
const { error: reversalLinesError } = await supabase
.from('journal_entry_lines')
.insert(reversalLineInserts)
if (reversalLinesError) {
await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
throw new Error(`Failed to create reversal lines: ${reversalLinesError.message}`)
}
// Post the reversal entry
const { error: postReversalError } = await supabase
.from('journal_entries')
.update({ status: 'posted' })
.eq('id', reversalEntry.id)
if (postReversalError) {
throw new Error(`Failed to post reversal entry: ${postReversalError.message}`)
}
// Mark original as reversed
await supabase
.from('journal_entries')
.update({
status: 'reversed',
reversed_by_id: reversalEntry.id,
})
.eq('id', originalEntryId)
// ===== Step 2: Create corrected entry =====
// If anything in this step fails, we must roll back the reversal from step 1
// to avoid leaving the ledger in an inconsistent state.
async function rollbackReversal() {
// Restore original entry to 'posted' status
await supabase
.from('journal_entries')
.update({ status: 'posted', reversed_by_id: null })
.eq('id', originalEntryId)
// Delete the reversal entry (it was just created, safe to remove since
// the DB trigger allows deleting draft entries and we need to clean up)
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
}
let correctedEntry: typeof reversalEntry
try {
const correctedVoucherNumber = await getNextVoucherNumber(
supabase,
companyId,
original.fiscal_period_id,
original.voucher_series || 'A'
)
// Resolve account IDs for corrected lines
const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))]
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('id, account_number')
.eq('company_id', companyId)
.in('account_number', accountNumbers)
const accountIdMap = new Map<string, string>()
for (const account of accounts || []) {
accountIdMap.set(account.account_number, account.id)
}
const { data: newEntry, error: correctedError } = await supabase
.from('journal_entries')
.insert({
company_id: companyId,
user_id: userId,
fiscal_period_id: original.fiscal_period_id,
voucher_number: correctedVoucherNumber,
voucher_series: original.voucher_series || 'A',
entry_date: getSwedishLocalDate(),
description: `Rättelse: ${original.description}`,
source_type: 'correction',
correction_of_id: originalEntryId,
status: 'draft',
})
.select()
.single()
if (correctedError || !newEntry) {
throw new Error(`Failed to create corrected entry: ${correctedError?.message}`)
}
correctedEntry = newEntry
// Insert corrected lines
const correctedLineInserts = correctedLines.map((line, index) => ({
journal_entry_id: correctedEntry.id,
account_number: line.account_number,
account_id: accountIdMap.get(line.account_number) || null,
debit_amount: Math.round((line.debit_amount || 0) * 100) / 100,
credit_amount: Math.round((line.credit_amount || 0) * 100) / 100,
currency: line.currency || 'SEK',
amount_in_currency: line.amount_in_currency
? Math.round(line.amount_in_currency * 100) / 100
: null,
exchange_rate: line.exchange_rate || null,
line_description: line.line_description || null,
tax_code: line.tax_code || null,
cost_center: line.cost_center || null,
project: line.project || null,
sort_order: index,
}))
const { error: correctedLinesError } = await supabase
.from('journal_entry_lines')
.insert(correctedLineInserts)
if (correctedLinesError) {
await supabase.from('journal_entries').delete().eq('id', correctedEntry.id)
throw new Error(`Failed to create corrected lines: ${correctedLinesError.message}`)
}
// Post the corrected entry
const { error: postCorrectedError } = await supabase
.from('journal_entries')
.update({ status: 'posted' })
.eq('id', correctedEntry.id)
if (postCorrectedError) {
throw new Error(`Failed to post corrected entry: ${postCorrectedError.message}`)
}
} catch (err) {
// Roll back the reversal to restore ledger consistency
await rollbackReversal()
throw err
}
// ===== Step 3: Fetch complete entries =====
const { data: finalReversal } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', reversalEntry.id)
.single()
const { data: finalCorrected } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', correctedEntry.id)
.single()
const result = {
reversal: finalReversal as JournalEntry,
corrected: finalCorrected as JournalEntry,
}
await eventBus.emit({
type: 'journal_entry.corrected',
payload: {
original: original as JournalEntry,
storno: result.reversal,
corrected: result.corrected,
companyId,
userId,
},
})
return result
}