Files
accounted/lib/reports/__tests__/general-ledger.test.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

252 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — table-keyed result queues
// ============================================================
type MockResult = { data?: unknown; error?: unknown }
let mockResults: Record<string, MockResult[]>
function makeBuilder(tableName: string) {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'lt', 'neq', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
const consume = (): MockResult => {
const queue = mockResults[tableName]
if (!queue || queue.length === 0) return { data: null, error: null }
return queue.shift()!
}
b.single = vi.fn().mockImplementation(async () => consume())
b.then = (resolve: (v: unknown) => void) => resolve(consume())
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation((table: string) => makeBuilder(table)),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
import { generateGeneralLedger } from '../general-ledger'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
mockResults = {}
supabase = makeClient()
})
describe('generateGeneralLedger', () => {
it('returns empty report when no fiscal period found', async () => {
mockResults = {
fiscal_periods: [{ data: null, error: null }],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1')
expect(report.accounts).toEqual([])
expect(report.period).toEqual({ start: '', end: '' })
})
it('returns empty report when no entries in period', async () => {
mockResults = {
fiscal_periods: [
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
],
journal_entry_lines: [
// prior lines (from getOpeningBalances fallback) — empty
{ data: [], error: null },
// period lines — empty
{ data: [], error: null },
],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1')
expect(report.accounts).toEqual([])
expect(report.period).toEqual({ start: '2024-01-01', end: '2024-12-31' })
})
it('groups lines by account with correct totals and running balance', async () => {
mockResults = {
fiscal_periods: [
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
],
journal_entry_lines: [
// prior lines — empty (first year)
{ data: [], error: null },
// period lines (joined with entry data)
{
data: [
{ account_number: '1510', debit_amount: 1250, credit_amount: 0, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale', source_type: 'invoice' } },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale', source_type: 'invoice' } },
{ account_number: '2611', debit_amount: 0, credit_amount: 250, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale', source_type: 'invoice' } },
{ account_number: '1930', debit_amount: 1250, credit_amount: 0, journal_entries: { entry_date: '2024-02-10', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction' } },
{ account_number: '1510', debit_amount: 0, credit_amount: 1250, journal_entries: { entry_date: '2024-02-10', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction' } },
],
error: null,
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1510', account_name: 'Kundfordringar' },
{ account_number: '1930', account_name: 'Företagskonto' },
{ account_number: '2611', account_name: 'Utgående moms 25%' },
{ account_number: '3001', account_name: 'Försäljning 25%' },
],
error: null,
},
],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1')
expect(report.accounts).toHaveLength(4)
expect(report.accounts.map((a) => a.account_number)).toEqual(['1510', '1930', '2611', '3001'])
// Account 1510: debit 1250, credit 1250 → closing 0
const acc1510 = report.accounts.find((a) => a.account_number === '1510')!
expect(acc1510.total_debit).toBe(1250)
expect(acc1510.total_credit).toBe(1250)
expect(acc1510.closing_balance).toBe(0)
expect(acc1510.lines).toHaveLength(2)
expect(acc1510.lines[0].balance).toBe(1250)
expect(acc1510.lines[1].balance).toBe(0)
// Account 1930: debit 1250, credit 0 → closing 1250
const acc1930 = report.accounts.find((a) => a.account_number === '1930')!
expect(acc1930.total_debit).toBe(1250)
expect(acc1930.total_credit).toBe(0)
expect(acc1930.closing_balance).toBe(1250)
})
it('computes opening balance from prior period entries', async () => {
mockResults = {
fiscal_periods: [
{ data: { period_start: '2025-01-01', period_end: '2025-12-31', opening_balance_entry_id: null }, error: null },
],
journal_entry_lines: [
// prior lines (from getOpeningBalances fallback)
{
data: [
{ account_number: '1930', debit_amount: 10000, credit_amount: 0 },
],
error: null,
},
// period lines
{
data: [
{ account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entries: { entry_date: '2025-03-01', voucher_number: 1, voucher_series: 'A', description: 'Purchase', source_type: 'manual' } },
{ account_number: '5410', debit_amount: 500, credit_amount: 0, journal_entries: { entry_date: '2025-03-01', voucher_number: 1, voucher_series: 'A', description: 'Purchase', source_type: 'manual' } },
],
error: null,
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Företagskonto' },
{ account_number: '5410', account_name: 'Förbrukningsinventarier' },
],
error: null,
},
],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-2')
const acc1930 = report.accounts.find((a) => a.account_number === '1930')!
expect(acc1930.opening_balance).toBe(10000)
expect(acc1930.closing_balance).toBe(9500) // 10000 - 500
expect(acc1930.lines[0].balance).toBe(9500)
})
it('filters accounts by account_from and account_to', async () => {
mockResults = {
fiscal_periods: [
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
],
journal_entry_lines: [
// prior lines — empty
{ data: [], error: null },
// period lines across multiple accounts
{
data: [
{ account_number: '1510', debit_amount: 1000, credit_amount: 0, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual' } },
{ account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual' } },
{ account_number: '3001', debit_amount: 0, credit_amount: 500, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual' } },
],
error: null,
},
],
chart_of_accounts: [
{ data: [], error: null },
],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1', '1500', '1999')
// Only accounts in 15001999 range
expect(report.accounts.map((a) => a.account_number)).toEqual(['1510', '1930'])
})
it('sorts lines within account by date then voucher number', async () => {
mockResults = {
fiscal_periods: [
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
],
journal_entry_lines: [
// prior lines — empty
{ data: [], error: null },
// period lines — out of order
{
data: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entries: { entry_date: '2024-01-10', voucher_number: 2, voucher_series: 'A', description: 'Second', source_type: 'manual' } },
{ account_number: '1930', debit_amount: 200, credit_amount: 0, journal_entries: { entry_date: '2024-01-10', voucher_number: 1, voucher_series: 'A', description: 'First', source_type: 'manual' } },
{ account_number: '1930', debit_amount: 300, credit_amount: 0, journal_entries: { entry_date: '2024-01-05', voucher_number: 3, voucher_series: 'A', description: 'Earlier date', source_type: 'manual' } },
],
error: null,
},
],
chart_of_accounts: [
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null },
],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1')
const acc = report.accounts[0]
// e3 (Jan 5) first, then e1 (Jan 10, #1), then e2 (Jan 10, #2)
expect(acc.lines[0].description).toBe('Earlier date')
expect(acc.lines[1].description).toBe('First')
expect(acc.lines[2].description).toBe('Second')
})
it('uses Math.round for monetary precision', async () => {
mockResults = {
fiscal_periods: [
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
],
journal_entry_lines: [
{ data: [], error: null },
{
data: [
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Precision', source_type: 'manual' } },
],
error: null,
},
],
chart_of_accounts: [
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null },
],
}
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1')
const acc = report.accounts[0]
expect(acc.total_debit).toBe(33.33)
expect(acc.closing_balance).toBe(33.33)
})
})