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
+2 -1
View File
@@ -117,7 +117,7 @@ export function extractBearerToken(request: Request): string | null {
*/
export async function validateApiKey(
key: string
): Promise<{ userId: string; scopes: ApiKeyScope[] } | { error: string; status: number }> {
): Promise<{ userId: string; companyId: string; scopes: ApiKeyScope[] } | { error: string; status: number }> {
if (!key.startsWith(KEY_PREFIX)) {
return { error: 'Invalid API key format', status: 401 }
}
@@ -141,6 +141,7 @@ export async function validateApiKey(
return {
userId: row.user_id,
companyId: row.company_id,
scopes: validateScopes(row.scopes) ?? DEFAULT_SCOPES,
}
}
+31
View File
@@ -0,0 +1,31 @@
import crypto from 'crypto'
const INVITE_PREFIX = 'gnubok_inv_'
const INVITE_TTL_DAYS = 7
/**
* Generate an invite token and its SHA-256 hash.
* The raw token is sent via email; only the hash is stored in DB.
*/
export function generateInviteToken(): { token: string; hash: string } {
const raw = crypto.randomBytes(32).toString('base64url')
const token = `${INVITE_PREFIX}${raw}`
const hash = crypto.createHash('sha256').update(token).digest('hex')
return { token, hash }
}
/**
* Hash a raw invite token for lookup.
*/
export function hashInviteToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex')
}
/**
* Get the expiration date for a new invite.
*/
export function getInviteExpiry(): Date {
const d = new Date()
d.setDate(d.getDate() + INVITE_TTL_DAYS)
return d
}
@@ -10,7 +10,7 @@ vi.mock('@/lib/currency/riksbanken', () => ({
// Mock engine
vi.mock('../engine', () => ({
createJournalEntry: vi.fn().mockImplementation(
async (_supabase: unknown, _userId: string, input: CreateJournalEntryInput) => ({
async (_supabase: unknown, _companyId: string, _userId: string, input: CreateJournalEntryInput) => ({
id: 'entry-1',
...input,
lines: input.lines,
@@ -106,23 +106,23 @@ function buildFilterChain(data: unknown[]) {
})
chain.eq = vi.fn().mockImplementation((col: string, val: unknown) => {
filtered = filtered.filter((row: Record<string, unknown>) => row[col] === val)
filtered = filtered.filter((row) => (row as Record<string, unknown>)[col] === val)
return chain
})
chain.neq = vi.fn().mockImplementation((col: string, val: unknown) => {
filtered = filtered.filter((row: Record<string, unknown>) => row[col] !== val)
filtered = filtered.filter((row) => (row as Record<string, unknown>)[col] !== val)
return chain
})
chain.in = vi.fn().mockImplementation((col: string, vals: unknown[]) => {
filtered = filtered.filter((row: Record<string, unknown>) => vals.includes(row[col]))
filtered = filtered.filter((row) => vals.includes((row as Record<string, unknown>)[col]))
return chain
})
chain.not = vi.fn().mockImplementation((col: string, op: string, _val: unknown) => {
if (op === 'is') {
filtered = filtered.filter((row: Record<string, unknown>) => row[col] != null)
filtered = filtered.filter((row) => (row as Record<string, unknown>)[col] != null)
}
return chain
})
@@ -190,7 +190,7 @@ describe('currency-revaluation', () => {
const supabase = createMockSupabase({ invoices: [eurInvoice, sekInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyReceivables(supabase as any, 'user-1')
const result = await getOpenForeignCurrencyReceivables(supabase as any, 'company-1')
expect(result).toHaveLength(1)
expect(result[0].currency).toBe('EUR')
@@ -206,7 +206,7 @@ describe('currency-revaluation', () => {
const supabase = createMockSupabase({ invoices: [paidEurInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyReceivables(supabase as any, 'user-1')
const result = await getOpenForeignCurrencyReceivables(supabase as any, 'company-1')
expect(result).toHaveLength(0)
})
@@ -221,7 +221,7 @@ describe('currency-revaluation', () => {
const supabase = createMockSupabase({ invoices: [noRateInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyReceivables(supabase as any, 'user-1')
const result = await getOpenForeignCurrencyReceivables(supabase as any, 'company-1')
expect(result).toHaveLength(0)
})
@@ -243,7 +243,7 @@ describe('currency-revaluation', () => {
const supabase = createMockSupabase({ supplierInvoices: [eurSI, sekSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyPayables(supabase as any, 'user-1')
const result = await getOpenForeignCurrencyPayables(supabase as any, 'company-1')
expect(result).toHaveLength(1)
expect(result[0].currency).toBe('EUR')
@@ -259,7 +259,7 @@ describe('currency-revaluation', () => {
const supabase = createMockSupabase({ supplierInvoices: [partialSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyPayables(supabase as any, 'user-1')
const result = await getOpenForeignCurrencyPayables(supabase as any, 'company-1')
expect(result).toHaveLength(1)
expect(result[0].remaining_amount).toBe(2000)
@@ -274,7 +274,7 @@ describe('currency-revaluation', () => {
const supabase = createMockSupabase({ supplierInvoices: [paidSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await getOpenForeignCurrencyPayables(supabase as any, 'user-1')
const result = await getOpenForeignCurrencyPayables(supabase as any, 'company-1')
expect(result).toHaveLength(0)
})
@@ -299,7 +299,7 @@ describe('currency-revaluation', () => {
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
expect(preview.items).toHaveLength(0)
expect(preview.lines).toHaveLength(0)
@@ -320,7 +320,7 @@ describe('currency-revaluation', () => {
const supabase = createMockSupabase({ invoices: [eurInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
expect(preview.items).toHaveLength(1)
expect(preview.items[0].type).toBe('receivable')
@@ -353,7 +353,7 @@ describe('currency-revaluation', () => {
const supabase = createMockSupabase({ invoices: [eurInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
expect(preview.items[0].difference_sek).toBe(-500) // 1000 * (11.5 - 12.0)
@@ -384,7 +384,7 @@ describe('currency-revaluation', () => {
const supabase = createMockSupabase({ supplierInvoices: [eurSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
expect(preview.items[0].type).toBe('payable')
expect(preview.items[0].difference_sek).toBe(1000) // 2000 * (11.5 - 11.0)
@@ -412,7 +412,7 @@ describe('currency-revaluation', () => {
const supabase = createMockSupabase({ supplierInvoices: [eurSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
expect(preview.items[0].difference_sek).toBe(-1000) // 2000 * (11.5 - 12.0)
@@ -447,7 +447,7 @@ describe('currency-revaluation', () => {
const supabase = createMockSupabase({ invoices: [eurInvoice, usdInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
expect(preview.items).toHaveLength(2)
// EUR: 1000 * (11.5 - 11.0) = 500
@@ -481,7 +481,7 @@ describe('currency-revaluation', () => {
supplierInvoices: [lossSI],
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
// Receivable gain: 1000 * 0.5 = 500 → Debit 1510, Credit 3960
// Payable loss: 2000 * 0.5 = 1000 → Debit 7960, Credit 2440
@@ -510,7 +510,7 @@ describe('currency-revaluation', () => {
const supabase = createMockSupabase({ supplierInvoices: [partialSI] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
// Only remaining 5000 EUR is revalued, not full 10000
expect(preview.items[0].amount_in_currency).toBe(5000)
@@ -532,7 +532,7 @@ describe('currency-revaluation', () => {
const supabase = createMockSupabase({ invoices: [eurInvoice] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
expect(preview.items).toHaveLength(0)
expect(preview.lines).toHaveLength(0)
@@ -563,7 +563,7 @@ describe('currency-revaluation', () => {
supplierInvoices: [gbpSI],
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preview = await previewCurrencyRevaluation(supabase as any, 'user-1', '2024-12-31')
const preview = await previewCurrencyRevaluation(supabase as any, 'company-1', '2024-12-31')
const totalDebit = preview.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = preview.lines.reduce((sum, l) => sum + l.credit_amount, 0)
@@ -592,7 +592,7 @@ describe('currency-revaluation', () => {
mockRates({} as Record<Currency, number>)
const result = await executeCurrencyRevaluation(supabase, 'user-1', '2024-12-31', 'period-1')
const result = await executeCurrencyRevaluation(supabase, 'company-1', '2024-12-31', 'period-1')
expect(result).toBeNull()
expect(mockedCreateEntry).not.toHaveBeenCalled()
@@ -614,16 +614,16 @@ describe('currency-revaluation', () => {
existingRevaluation: false,
})
const result = await executeCurrencyRevaluation(supabase, 'user-1', '2024-12-31', 'period-1')
const result = await executeCurrencyRevaluation(supabase, 'company-1', '2024-12-31', 'period-1')
expect(result).not.toBeNull()
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const callArgs = mockedCreateEntry.mock.calls[0]
expect(callArgs[2].source_type).toBe('currency_revaluation')
expect(callArgs[2].fiscal_period_id).toBe('period-1')
expect(callArgs[2].entry_date).toBe('2024-12-31')
expect(callArgs[2].description).toContain('Omvärdering utländsk valuta')
expect(callArgs[3].source_type).toBe('currency_revaluation')
expect(callArgs[3].fiscal_period_id).toBe('period-1')
expect(callArgs[3].entry_date).toBe('2024-12-31')
expect(callArgs[3].description).toContain('Omvärdering utländsk valuta')
})
it('throws when revaluation already exists for period (idempotency)', async () => {
@@ -632,7 +632,7 @@ describe('currency-revaluation', () => {
})
await expect(
executeCurrencyRevaluation(supabase, 'user-1', '2024-12-31', 'period-1')
executeCurrencyRevaluation(supabase, 'company-1', '2024-12-31', 'period-1')
).rejects.toThrow('Currency revaluation already exists for this period')
expect(mockedCreateEntry).not.toHaveBeenCalled()
@@ -654,7 +654,7 @@ describe('currency-revaluation', () => {
existingRevaluation: false,
})
const result = await executeCurrencyRevaluation(supabase, 'user-1', '2024-12-31', 'period-1')
const result = await executeCurrencyRevaluation(supabase, 'company-1', '2024-12-31', 'period-1')
expect(result).not.toBeNull()
expect(result!.entry).toBeDefined()
+1 -1
View File
@@ -141,7 +141,7 @@ describe('createDraftEntry — cancelled status on line-insert failure', () => {
}
await expect(
createDraftEntry(supabase as never, 'user-1', {
createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2024-01-01',
description: 'Test',
@@ -6,7 +6,7 @@ import type { Invoice, InvoiceItem, CreateJournalEntryInput } from '@/types'
vi.mock('../engine', () => ({
findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
createJournalEntry: vi.fn().mockImplementation(
async (_supabase: unknown, _userId: string, input: CreateJournalEntryInput) => ({
async (_supabase: unknown, _companyId: string, _userId: string, input: CreateJournalEntryInput) => ({
id: 'entry-1',
...input,
lines: input.lines,
@@ -169,10 +169,10 @@ describe('createInvoiceJournalEntry — per-line VAT', () => {
],
})
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
// Should have 3 lines: 1510 debit, 3001 credit, 2611 credit
expect(input.lines).toHaveLength(3)
@@ -208,10 +208,10 @@ describe('createInvoiceJournalEntry — per-line VAT', () => {
invoice.vat_amount = 198
invoice.total = 1198
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
// Should have 5 lines: 1510, 3001(25%), 2611(25%), 3002(12%), 2621(12%)
expect(input.lines).toHaveLength(5)
@@ -249,10 +249,10 @@ describe('createInvoiceJournalEntry — per-line VAT', () => {
],
})
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
// Should have 2 lines: 1510 debit, 3308 credit (no VAT)
expect(input.lines).toHaveLength(2)
@@ -286,9 +286,9 @@ describe('createInvoiceJournalEntry — per-line VAT', () => {
invoice.vat_amount = 378
invoice.total = 2378
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
@@ -316,10 +316,10 @@ describe('createCreditNoteJournalEntry — per-line VAT', () => {
],
})
await createCreditNoteJournalEntry(null as never, 'user-1', creditNote)
await createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
// Revenue and VAT lines should be debits (reversed)
const debit3001 = input.lines.find((l) => l.account_number === '3001')
@@ -364,10 +364,10 @@ describe('createInvoiceCashEntry — per-line VAT', () => {
],
})
await createInvoiceCashEntry(null as never, 'user-1', invoice, '2024-07-01')
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01')
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
// Debit 1930 (bank account) instead of 1510
const debit1930 = input.lines.find((l) => l.account_number === '1930')
@@ -412,10 +412,10 @@ describe('createInvoiceJournalEntry — EUR foreign currency', () => {
],
})
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
// All amounts should be in SEK
const debit1510 = input.lines.find((l) => l.account_number === '1510')
@@ -455,9 +455,9 @@ describe('createInvoiceJournalEntry — EUR foreign currency', () => {
],
})
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
// Revenue should be computed via exchange rate
const credit3305 = input.lines.find((l) => l.account_number === '3305')
@@ -478,9 +478,9 @@ describe('createInvoiceJournalEntry — EUR foreign currency', () => {
],
})
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(1000)
@@ -500,9 +500,9 @@ describe('BFL-compliant descriptions with counterparty names', () => {
items: [makeItem()],
})
await createInvoiceJournalEntry(null as never, 'user-1', invoice, 'enskild_firma', 'Foretag AB')
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice, 'enskild_firma', 'Foretag AB')
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Kundfaktura 1001, Foretag AB')
})
@@ -511,27 +511,27 @@ describe('BFL-compliant descriptions with counterparty names', () => {
items: [makeItem()],
})
await createInvoiceJournalEntry(null as never, 'user-1', invoice, 'enskild_firma')
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice, 'enskild_firma')
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Kundfaktura 1001')
})
it('createInvoicePaymentJournalEntry includes customer name', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15', undefined, 'Foretag AB')
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15', undefined, 'Foretag AB')
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Inbetalning kundfaktura 1001, Foretag AB')
})
it('createInvoicePaymentJournalEntry falls back without customer name', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15')
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Inbetalning kundfaktura 1001')
})
@@ -544,9 +544,9 @@ describe('BFL-compliant descriptions with counterparty names', () => {
items: [makeItem({ quantity: -1, line_total: -1000, vat_amount: -250 })],
})
await createCreditNoteJournalEntry(null as never, 'user-1', creditNote, 'enskild_firma', 'Foretag AB')
await createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote, 'enskild_firma', 'Foretag AB')
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Kreditfaktura KR-1001, Foretag AB')
})
@@ -555,9 +555,9 @@ describe('BFL-compliant descriptions with counterparty names', () => {
items: [makeItem()],
})
await createInvoiceCashEntry(null as never, 'user-1', invoice, '2024-07-01', 'enskild_firma', 'Foretag AB')
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01', 'enskild_firma', 'Foretag AB')
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Kontantbetalning kundfaktura 1001, Foretag AB')
})
@@ -566,9 +566,9 @@ describe('BFL-compliant descriptions with counterparty names', () => {
items: [makeItem()],
})
await createInvoiceCashEntry(null as never, 'user-1', invoice, '2024-07-01', 'enskild_firma')
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01', 'enskild_firma')
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Kontantbetalning kundfaktura 1001')
})
})
@@ -581,9 +581,9 @@ describe('createInvoicePaymentJournalEntry — exchange rate difference', () =>
it('SEK payment creates simple 2-line entry', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15')
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
const debit1930 = input.lines.find((l) => l.account_number === '1930')
@@ -602,9 +602,9 @@ describe('createInvoicePaymentJournalEntry — exchange rate difference', () =>
})
// Gain of 200 SEK (received more than booked)
await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15', 200)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15', 200)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(3)
// Debit 1930: actual SEK received = 11500 + 200 = 11700
@@ -634,9 +634,9 @@ describe('createInvoicePaymentJournalEntry — exchange rate difference', () =>
})
// Loss of 300 SEK (received less than booked)
await createInvoicePaymentJournalEntry(null as never, 'user-1', invoice, '2024-07-15', -300)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15', -300)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(3)
// Debit 1930: actual SEK received = 11500 + (-300) = 11200
@@ -6,7 +6,7 @@ import { makeSupplierInvoice } from '@/tests/helpers'
vi.mock('../engine', () => ({
findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
createJournalEntry: vi.fn().mockImplementation(
async (_supabase: unknown, _userId: string, input: CreateJournalEntryInput) => ({
async (_supabase: unknown, _companyId: string, _userId: string, input: CreateJournalEntryInput) => ({
id: 'entry-1',
...input,
lines: input.lines,
@@ -113,7 +113,7 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
const items = [makeItem()]
const result = await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
expect(result).toBeNull()
@@ -129,11 +129,11 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const debit6200 = findByAccount(input.lines, '6200')
expect(debit6200).toHaveLength(1)
@@ -159,10 +159,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
const items = [makeItem({ line_total: 5000, account_number: '5410', vat_rate: 0 })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const debit5410 = findByAccount(input.lines, '5410')
expect(debit5410).toHaveLength(1)
@@ -186,10 +186,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
const items = [makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0.25 })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'eu_business'
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const debit6540 = findByAccount(input.lines, '6540')
expect(debit6540[0].debit_amount).toBe(10000)
@@ -220,10 +220,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
const items = [makeItem({ line_total: 5000, account_number: '6540', vat_rate: 0.12 })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'eu_business'
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const debit2645 = findByAccount(input.lines, '2645')
expect(debit2645[0].debit_amount).toBe(600) // 5000 * 0.12
@@ -247,10 +247,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const debit5410 = findByAccount(input.lines, '5410')
expect(debit5410[0].debit_amount).toBe(3000)
@@ -276,10 +276,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const lines6200 = findByAccount(input.lines, '6200')
expect(lines6200).toHaveLength(1)
@@ -301,19 +301,19 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const vat2641 = findByAccount(input.lines, '2641')
expect(vat2641).toHaveLength(3)
// 25%: 10000 * 0.25 = 2500
expect(vat2641.find((l) => l.line_description.includes('25%'))?.debit_amount).toBe(2500)
expect(vat2641.find((l) => l.line_description?.includes('25%'))?.debit_amount).toBe(2500)
// 12%: 5000 * 0.12 = 600
expect(vat2641.find((l) => l.line_description.includes('12%'))?.debit_amount).toBe(600)
expect(vat2641.find((l) => l.line_description?.includes('12%'))?.debit_amount).toBe(600)
// 6%: 3000 * 0.06 = 180
expect(vat2641.find((l) => l.line_description.includes('6%'))?.debit_amount).toBe(180)
expect(vat2641.find((l) => l.line_description?.includes('6%'))?.debit_amount).toBe(180)
assertBalanced(input)
})
@@ -329,10 +329,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
const items = [makeItem({ line_total: 800, account_number: '6200', vat_rate: 0 })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const credit2440 = findByAccount(input.lines, '2440')[0]
expect(credit2440.currency).toBe('EUR')
expect(credit2440.amount_in_currency).toBe(800)
@@ -344,10 +344,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
const items = [makeItem()]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.source_type).toBe('supplier_invoice_registered')
expect(input.source_id).toBe('si-xyz')
})
@@ -360,10 +360,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
const items = [makeItem()]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toContain('LF-999')
expect(input.description).toContain('42')
})
@@ -376,10 +376,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
const items = [makeItem()]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'swedish_business', 'Leverantör AB'
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business', 'Leverantör AB'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Leverantörsfaktura LF-100, Leverantör AB (ankomst 5)')
})
@@ -391,10 +391,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
const items = [makeItem()]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Leverantörsfaktura LF-100 (ankomst 5)')
})
@@ -408,10 +408,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
const items = [makeItem({ line_total: 5000, vat_rate: 0.25, account_number: '6540' })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'non_eu_business'
null as never, 'company-1', 'user-1', invoice, items, 'non_eu_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2645')).toHaveLength(1)
expect(findByAccount(input.lines, '2614')).toHaveLength(1)
@@ -433,10 +433,10 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'user-1', invoice, items, 'eu_business'
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const vat2645 = findByAccount(input.lines, '2645')
expect(vat2645).toHaveLength(2)
@@ -465,7 +465,7 @@ describe('createSupplierInvoicePaymentEntry', () => {
const invoice = makeSupplierInvoice()
const result = await createSupplierInvoicePaymentEntry(
null as never, 'user-1', invoice, 10000, '2024-07-01'
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01'
)
expect(result).toBeNull()
@@ -476,10 +476,10 @@ describe('createSupplierInvoicePaymentEntry', () => {
const invoice = makeSupplierInvoice({ total: 10000 })
await createSupplierInvoicePaymentEntry(
null as never, 'user-1', invoice, 10000, '2024-07-01'
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
const debit2440 = findByAccount(input.lines, '2440')[0]
@@ -496,10 +496,10 @@ describe('createSupplierInvoicePaymentEntry', () => {
// paymentAmount = original SEK amount, exchangeRateDifference > 0 = gain
await createSupplierInvoicePaymentEntry(
null as never, 'user-1', invoice, 11500, '2024-07-15', 500
null as never, 'company-1', 'user-1', invoice, 11500, '2024-07-15', 500
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(3)
const debit2440 = findByAccount(input.lines, '2440')[0]
@@ -519,10 +519,10 @@ describe('createSupplierInvoicePaymentEntry', () => {
// exchangeRateDifference < 0 = loss
await createSupplierInvoicePaymentEntry(
null as never, 'user-1', invoice, 11500, '2024-07-15', -300
null as never, 'company-1', 'user-1', invoice, 11500, '2024-07-15', -300
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(3)
const debit2440 = findByAccount(input.lines, '2440')[0]
@@ -541,10 +541,10 @@ describe('createSupplierInvoicePaymentEntry', () => {
const invoice = makeSupplierInvoice()
await createSupplierInvoicePaymentEntry(
null as never, 'user-1', invoice, 10000, '2024-07-01', 0
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01', 0
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
expect(findByAccount(input.lines, '3960')).toHaveLength(0)
@@ -557,10 +557,10 @@ describe('createSupplierInvoicePaymentEntry', () => {
const invoice = makeSupplierInvoice()
await createSupplierInvoicePaymentEntry(
null as never, 'user-1', invoice, 10000.555, '2024-07-01'
null as never, 'company-1', 'user-1', invoice, 10000.555, '2024-07-01'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
for (const line of input.lines) {
if (line.debit_amount > 0) {
expect(line.debit_amount).toBe(Math.round(10000.555 * 100) / 100)
@@ -575,10 +575,10 @@ describe('createSupplierInvoicePaymentEntry', () => {
const invoice = makeSupplierInvoice({ id: 'si-pay-1' })
await createSupplierInvoicePaymentEntry(
null as never, 'user-1', invoice, 10000, '2024-07-01'
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.source_type).toBe('supplier_invoice_paid')
expect(input.source_id).toBe('si-pay-1')
})
@@ -590,10 +590,10 @@ describe('createSupplierInvoicePaymentEntry', () => {
})
await createSupplierInvoicePaymentEntry(
null as never, 'user-1', invoice, 10000, '2024-07-01', undefined, 'Leverantör AB'
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01', undefined, 'Leverantör AB'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Utbetalning leverantörsfaktura LF-200, Leverantör AB (ankomst 10)')
})
@@ -601,10 +601,10 @@ describe('createSupplierInvoicePaymentEntry', () => {
const invoice = makeSupplierInvoice({ invoice_date: '2024-06-01' })
await createSupplierInvoicePaymentEntry(
null as never, 'user-1', invoice, 10000, '2024-08-15'
null as never, 'company-1', 'user-1', invoice, 10000, '2024-08-15'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.entry_date).toBe('2024-08-15')
})
})
@@ -625,7 +625,7 @@ describe('createSupplierInvoiceCashEntry', () => {
const items = [makeItem()]
const result = await createSupplierInvoiceCashEntry(
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business'
)
expect(result).toBeNull()
@@ -641,10 +641,10 @@ describe('createSupplierInvoiceCashEntry', () => {
const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
await createSupplierInvoiceCashEntry(
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '6200')[0].debit_amount).toBe(8000)
expect(findByAccount(input.lines, '2641')[0].debit_amount).toBe(2000)
@@ -665,10 +665,10 @@ describe('createSupplierInvoiceCashEntry', () => {
const items = [makeItem({ line_total: 5000, account_number: '5410', vat_rate: 0 })]
await createSupplierInvoiceCashEntry(
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '5410')[0].debit_amount).toBe(5000)
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(5000)
@@ -687,10 +687,10 @@ describe('createSupplierInvoiceCashEntry', () => {
const items = [makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0.25 })]
await createSupplierInvoiceCashEntry(
null as never, 'user-1', invoice, items, '2024-07-01', 'eu_business'
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(2500)
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(2500)
@@ -709,10 +709,10 @@ describe('createSupplierInvoiceCashEntry', () => {
const items = [makeItem()]
await createSupplierInvoiceCashEntry(
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2440')).toHaveLength(0)
})
@@ -724,14 +724,14 @@ describe('createSupplierInvoiceCashEntry', () => {
]
await createSupplierInvoiceCashEntry(
null as never, 'user-1', invoice, items, '2024-06-01', 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, '2024-06-01', 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const vat2641 = findByAccount(input.lines, '2641')
expect(vat2641).toHaveLength(2)
expect(vat2641.find((l) => l.line_description.includes('25%'))?.debit_amount).toBe(2500)
expect(vat2641.find((l) => l.line_description.includes('6%'))?.debit_amount).toBe(180)
expect(vat2641.find((l) => l.line_description?.includes('25%'))?.debit_amount).toBe(2500)
expect(vat2641.find((l) => l.line_description?.includes('6%'))?.debit_amount).toBe(180)
assertBalanced(input)
})
@@ -741,10 +741,10 @@ describe('createSupplierInvoiceCashEntry', () => {
const items = [makeItem()]
await createSupplierInvoiceCashEntry(
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.source_type).toBe('supplier_invoice_cash_payment')
expect(input.source_id).toBe('si-cash-1')
})
@@ -754,10 +754,10 @@ describe('createSupplierInvoiceCashEntry', () => {
const items = [makeItem()]
await createSupplierInvoiceCashEntry(
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business', 'Leverantör AB'
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business', 'Leverantör AB'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Kontantbetalning leverantörsfaktura LF-300, Leverantör AB')
})
@@ -766,10 +766,10 @@ describe('createSupplierInvoiceCashEntry', () => {
const items = [makeItem()]
await createSupplierInvoiceCashEntry(
null as never, 'user-1', invoice, items, '2024-07-01', 'swedish_business'
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Kontantbetalning leverantörsfaktura LF-300')
})
})
@@ -790,7 +790,7 @@ describe('createSupplierCreditNoteEntry', () => {
const items = [makeItem()]
const result = await createSupplierCreditNoteEntry(
null as never, 'user-1', creditNote, items, 'swedish_business'
null as never, 'company-1', 'user-1', creditNote, items, 'swedish_business'
)
expect(result).toBeNull()
@@ -807,10 +807,10 @@ describe('createSupplierCreditNoteEntry', () => {
const items = [makeItem({ line_total: -8000, account_number: '6200', vat_rate: 0.25 })]
await createSupplierCreditNoteEntry(
null as never, 'user-1', creditNote, items, 'swedish_business'
null as never, 'company-1', 'user-1', creditNote, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const debit2440 = findByAccount(input.lines, '2440')[0]
expect(debit2440.debit_amount).toBe(10000) // abs
@@ -837,10 +837,10 @@ describe('createSupplierCreditNoteEntry', () => {
const items = [makeItem({ line_total: -5000, account_number: '6200', vat_rate: 0 })]
await createSupplierCreditNoteEntry(
null as never, 'user-1', creditNote, items, 'swedish_business'
null as never, 'company-1', 'user-1', creditNote, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '2440')[0].debit_amount).toBe(5000)
expect(findByAccount(input.lines, '6200')[0].credit_amount).toBe(5000)
@@ -860,10 +860,10 @@ describe('createSupplierCreditNoteEntry', () => {
const items = [makeItem({ line_total: -10000, account_number: '6540', vat_rate: 0.25 })]
await createSupplierCreditNoteEntry(
null as never, 'user-1', creditNote, items, 'eu_business'
null as never, 'company-1', 'user-1', creditNote, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
// Reversed fiktiv moms
const credit2645 = findByAccount(input.lines, '2645')[0]
@@ -892,10 +892,10 @@ describe('createSupplierCreditNoteEntry', () => {
const items = [makeItem({ line_total: -7500, account_number: '6200', vat_rate: 0 })]
await createSupplierCreditNoteEntry(
null as never, 'user-1', creditNote, items, 'swedish_business'
null as never, 'company-1', 'user-1', creditNote, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
for (const line of input.lines) {
expect(line.debit_amount).toBeGreaterThanOrEqual(0)
expect(line.credit_amount).toBeGreaterThanOrEqual(0)
@@ -911,10 +911,10 @@ describe('createSupplierCreditNoteEntry', () => {
const items = [makeItem({ line_total: -8000, account_number: '6200', vat_rate: 0.25 })]
await createSupplierCreditNoteEntry(
null as never, 'user-1', creditNote, items, 'swedish_business'
null as never, 'company-1', 'user-1', creditNote, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines[0].account_number).toBe('2440')
})
@@ -929,10 +929,10 @@ describe('createSupplierCreditNoteEntry', () => {
const items = [makeItem({ line_total: -8000, account_number: '6200', vat_rate: 0.25 })]
await createSupplierCreditNoteEntry(
null as never, 'user-1', creditNote, items, 'swedish_business', 'Leverantör AB'
null as never, 'company-1', 'user-1', creditNote, items, 'swedish_business', 'Leverantör AB'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.description).toBe('Kreditfaktura leverantör LF-400, Leverantör AB (ankomst 7)')
})
@@ -941,10 +941,10 @@ describe('createSupplierCreditNoteEntry', () => {
const items = [makeItem()]
await createSupplierCreditNoteEntry(
null as never, 'user-1', creditNote, items, 'swedish_business'
null as never, 'company-1', 'user-1', creditNote, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.source_type).toBe('supplier_credit_note')
expect(input.source_id).toBe('si-cn-1')
})
@@ -962,10 +962,10 @@ describe('createSupplierCreditNoteEntry', () => {
]
await createSupplierCreditNoteEntry(
null as never, 'user-1', creditNote, items, 'eu_business'
null as never, 'company-1', 'user-1', creditNote, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
// 2645 credit lines: 2 (one per rate)
const vat2645 = findByAccount(input.lines, '2645')
@@ -6,7 +6,7 @@ import type { CreateJournalEntryInput, MappingResult, VatJournalLine } from '@/t
vi.mock('../engine', () => ({
findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
createJournalEntry: vi.fn().mockImplementation(
async (_supabase: unknown, _userId: string, input: CreateJournalEntryInput) => ({
async (_supabase: unknown, _companyId: string, _userId: string, input: CreateJournalEntryInput) => ({
id: 'entry-1',
...input,
lines: input.lines,
@@ -90,7 +90,7 @@ function makeMappingResult(overrides: Partial<MappingResult> = {}): MappingResul
rule: null,
debit_account: '5410',
credit_account: '1930',
risk_level: 'low',
risk_level: 'LOW',
confidence: 0.95,
requires_review: false,
default_private: false,
@@ -121,7 +121,7 @@ describe('createTransactionJournalEntry', () => {
const mapping = makeMappingResult({ debit_account: '' })
await expect(
createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
).rejects.toThrow('Invalid mapping result')
})
@@ -130,7 +130,7 @@ describe('createTransactionJournalEntry', () => {
const mapping = makeMappingResult({ credit_account: '' })
await expect(
createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
).rejects.toThrow('Invalid mapping result')
})
@@ -141,7 +141,7 @@ describe('createTransactionJournalEntry', () => {
const tx = makeTransaction()
const mapping = makeMappingResult()
const result = await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
const result = await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
expect(result).toBeNull()
expect(mockedCreateEntry).not.toHaveBeenCalled()
@@ -157,10 +157,10 @@ describe('createTransactionJournalEntry', () => {
default_private: true,
})
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
@@ -184,9 +184,9 @@ describe('createTransactionJournalEntry', () => {
default_private: true,
})
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
const debit2893 = input.lines.find(l => l.account_number === '2893')
@@ -208,9 +208,9 @@ describe('createTransactionJournalEntry', () => {
vat_lines: [],
})
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
const debit5410 = input.lines.find(l => l.account_number === '5410')
@@ -233,9 +233,9 @@ describe('createTransactionJournalEntry', () => {
vat_lines: vatLines,
})
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(3)
const debit2641 = input.lines.find(l => l.account_number === '2641')
@@ -261,9 +261,9 @@ describe('createTransactionJournalEntry', () => {
vat_lines: vatLines,
})
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const debit5410 = input.lines.find(l => l.account_number === '5410')
// net = Math.round((997.50 - 199.50) * 100) / 100 = 798
expect(debit5410?.debit_amount).toBe(Math.round((997.50 - 199.50) * 100) / 100)
@@ -283,9 +283,9 @@ describe('createTransactionJournalEntry', () => {
vat_lines: vatLines,
})
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const debit2645 = input.lines.find(l => l.account_number === '2645')
expect(debit2645?.debit_amount).toBe(1250)
@@ -313,9 +313,9 @@ describe('createTransactionJournalEntry', () => {
vat_lines: [],
})
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
const debit1930 = input.lines.find(l => l.account_number === '1930')
@@ -338,9 +338,9 @@ describe('createTransactionJournalEntry', () => {
vat_lines: vatLines,
})
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const debit1930 = input.lines.find(l => l.account_number === '1930')
expect(debit1930?.debit_amount).toBe(12500)
@@ -365,9 +365,9 @@ describe('createTransactionJournalEntry', () => {
vat_lines: vatLines,
})
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const credit3001 = input.lines.find(l => l.account_number === '3001')
// net = Math.round((333.33 - 66.67) * 100) / 100 = 266.66
expect(credit3001?.credit_amount).toBe(Math.round((333.33 - 66.67) * 100) / 100)
@@ -391,9 +391,9 @@ describe('createTransactionJournalEntry', () => {
vat_lines: [],
})
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
const credit1930 = input.lines.find(l => l.account_number === '1930')
expect(credit1930?.currency).toBe('EUR')
@@ -412,9 +412,9 @@ describe('createTransactionJournalEntry', () => {
const tx = makeTransaction({ amount: -500, currency: 'SEK' })
const mapping = makeMappingResult()
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
for (const line of input.lines) {
expect(line.currency).toBeUndefined()
expect(line.amount_in_currency).toBeUndefined()
@@ -428,9 +428,9 @@ describe('createTransactionJournalEntry', () => {
const tx = makeTransaction({ id: 'tx-abc-123', amount: -100 })
const mapping = makeMappingResult()
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.source_type).toBe('bank_transaction')
expect(input.source_id).toBe('tx-abc-123')
})
@@ -439,9 +439,9 @@ describe('createTransactionJournalEntry', () => {
const tx = makeTransaction({ date: '2024-09-15', amount: -100 })
const mapping = makeMappingResult()
await createTransactionJournalEntry(null as never, 'user-1', tx, mapping)
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][2]
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.entry_date).toBe('2024-09-15')
})
})
+12 -12
View File
@@ -127,10 +127,10 @@ export interface CounterpartyTemplateMatch {
*/
export async function findCounterpartyTemplate(
supabase: SupabaseClient,
userId: string,
companyId: string,
transaction: Transaction
): Promise<CounterpartyTemplateMatch | null> {
const results = await findCounterpartyTemplatesBatch(supabase, userId, [transaction])
const results = await findCounterpartyTemplatesBatch(supabase, companyId, [transaction])
return results.get(transaction.id) ?? null
}
@@ -140,7 +140,7 @@ export async function findCounterpartyTemplate(
*/
export async function findCounterpartyTemplatesBatch(
supabase: SupabaseClient,
userId: string,
companyId: string,
transactions: Transaction[]
): Promise<Map<string, CounterpartyTemplateMatch>> {
const result = new Map<string, CounterpartyTemplateMatch>()
@@ -148,7 +148,7 @@ export async function findCounterpartyTemplatesBatch(
const { data: allTemplates } = await supabase
.from('categorization_templates')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('is_active', true)
if (!allTemplates || allTemplates.length === 0) return result
@@ -397,7 +397,7 @@ export interface TemplateUpsertParams {
*/
export async function insertOrUpdateTemplate(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: TemplateUpsertParams,
existingTemplate?: CategorizationTemplate | null
): Promise<void> {
@@ -407,7 +407,7 @@ export async function insertOrUpdateTemplate(
const { data } = await supabase
.from('categorization_templates')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('counterparty_name', params.counterpartyName)
.maybeSingle()
existing = data as CategorizationTemplate | null
@@ -469,7 +469,7 @@ export async function insertOrUpdateTemplate(
await supabase
.from('categorization_templates')
.insert({
user_id: userId,
company_id: companyId,
counterparty_name: params.counterpartyName,
counterparty_aliases: params.aliases,
debit_account: params.debitAccount,
@@ -492,7 +492,7 @@ export async function insertOrUpdateTemplate(
*/
export async function upsertCounterpartyTemplate(
supabase: SupabaseClient,
userId: string,
companyId: string,
transaction: Transaction,
mappingResult: MappingResult,
source: CategorizationTemplateSource
@@ -505,7 +505,7 @@ export async function upsertCounterpartyTemplate(
const category = transaction.category !== 'uncategorized' ? transaction.category : null
await insertOrUpdateTemplate(supabase, userId, {
await insertOrUpdateTemplate(supabase, companyId, {
counterpartyName: normalized,
aliases: [rawName.toLowerCase()],
debitAccount: mappingResult.debit_account,
@@ -775,7 +775,7 @@ function averageLinePatterns(voucherPatterns: VoucherLinePattern[]): LinePattern
*/
export async function populateTemplatesFromSieVouchers(
supabase: SupabaseClient,
userId: string,
companyId: string,
vouchers: SIEVoucher[],
options?: { recencyMonths?: number }
): Promise<number> {
@@ -883,7 +883,7 @@ export async function populateTemplatesFromSieVouchers(
const { data: existingTemplates } = await supabase
.from('categorization_templates')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('is_active', true)
const templateMap = new Map<string, CategorizationTemplate>()
@@ -924,7 +924,7 @@ export async function populateTemplatesFromSieVouchers(
const vatAccount = firstVat?.account ?? null
const vatTreatment = vatAccount ? (VAT_ACCOUNT_TREATMENT[vatAccount] ?? null) : null
await insertOrUpdateTemplate(supabase, userId, {
await insertOrUpdateTemplate(supabase, companyId, {
counterpartyName: item.normalizedName,
aliases: item.aliases,
debitAccount,
+13 -12
View File
@@ -18,12 +18,12 @@ import type {
*/
export async function getOpenForeignCurrencyReceivables(
supabase: SupabaseClient,
userId: string
companyId: string
): Promise<Invoice[]> {
const { data, error } = await supabase
.from('invoices')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
@@ -42,12 +42,12 @@ export async function getOpenForeignCurrencyReceivables(
*/
export async function getOpenForeignCurrencyPayables(
supabase: SupabaseClient,
userId: string
companyId: string
): Promise<SupplierInvoice[]> {
const { data, error } = await supabase
.from('supplier_invoices')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
@@ -73,12 +73,12 @@ export async function getOpenForeignCurrencyPayables(
*/
export async function previewCurrencyRevaluation(
supabase: SupabaseClient,
userId: string,
companyId: string,
closingDate: string
): Promise<CurrencyRevaluationPreview> {
const [receivables, payables] = await Promise.all([
getOpenForeignCurrencyReceivables(supabase, userId),
getOpenForeignCurrencyPayables(supabase, userId),
getOpenForeignCurrencyReceivables(supabase, companyId),
getOpenForeignCurrencyPayables(supabase, companyId),
])
// Collect distinct currencies
@@ -275,15 +275,16 @@ export async function previewCurrencyRevaluation(
*/
export async function executeCurrencyRevaluation(
supabase: SupabaseClient,
userId: string,
companyId: string,
closingDate: string,
fiscalPeriodId: string
fiscalPeriodId: string,
userId?: string
): Promise<CurrencyRevaluationResult | null> {
// Idempotency check: prevent double revaluation
const { count, error: checkError } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('source_type', 'currency_revaluation')
.eq('status', 'posted')
@@ -296,13 +297,13 @@ export async function executeCurrencyRevaluation(
throw new Error('Currency revaluation already exists for this period')
}
const preview = await previewCurrencyRevaluation(supabase, userId, closingDate)
const preview = await previewCurrencyRevaluation(supabase, companyId, closingDate)
if (preview.items.length === 0 || preview.lines.length === 0) {
return null
}
const entry = await createJournalEntry(supabase, userId, {
const entry = await createJournalEntry(supabase, companyId, userId ?? companyId, {
fiscal_period_id: fiscalPeriodId,
entry_date: closingDate,
description: `Omvärdering utländsk valuta ${closingDate}`,
+25 -19
View File
@@ -30,18 +30,18 @@ export function validateBalance(lines: CreateJournalEntryLineInput[]): {
}
/**
* Get the next voucher number for a user/period/series
* Get the next voucher number for a company/period/series
* Uses the concurrent-safe INSERT ON CONFLICT implementation in the database
*/
export async function getNextVoucherNumber(
supabase: SupabaseClient,
userId: string,
companyId: string,
fiscalPeriodId: string,
series: string = 'A'
): Promise<number> {
const { data, error } = await supabase.rpc('next_voucher_number', {
p_user_id: userId,
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_series: series,
})
@@ -54,11 +54,11 @@ export async function getNextVoucherNumber(
}
/**
* Resolve account IDs from account numbers for a user
* Resolve account IDs from account numbers for a company
*/
async function resolveAccountIds(
supabase: SupabaseClient,
userId: string,
companyId: string,
lines: CreateJournalEntryLineInput[]
): Promise<Map<string, string>> {
const accountNumbers = [...new Set(lines.map((l) => l.account_number))]
@@ -66,7 +66,7 @@ async function resolveAccountIds(
const { data: accounts, error } = await supabase
.from('chart_of_accounts')
.select('id, account_number')
.eq('user_id', userId)
.eq('company_id', companyId)
.in('account_number', accountNumbers)
if (error) {
@@ -86,7 +86,7 @@ async function resolveAccountIds(
*/
export async function findFiscalPeriod(
supabase: SupabaseClient,
userId: string,
companyId: string,
date: string
): Promise<string | null> {
@@ -95,7 +95,7 @@ export async function findFiscalPeriod(
const { data, error } = 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)
@@ -141,6 +141,7 @@ function buildLineInserts(
*/
export async function createDraftEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: CreateJournalEntryInput
): Promise<JournalEntry> {
@@ -153,12 +154,13 @@ export async function createDraftEntry(
}
// Resolve account IDs
const accountIdMap = await resolveAccountIds(supabase, userId, input.lines)
const accountIdMap = await resolveAccountIds(supabase, companyId, input.lines)
// Insert journal entry header as draft (voucher_number = 0, will be assigned on commit)
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.insert({
company_id: companyId,
user_id: userId,
fiscal_period_id: input.fiscal_period_id,
voucher_number: 0,
@@ -199,7 +201,7 @@ export async function createDraftEntry(
await eventBus.emit({
type: 'journal_entry.drafted',
payload: { entry: result, userId },
payload: { entry: result, userId, companyId },
})
return result
@@ -211,6 +213,7 @@ export async function createDraftEntry(
*/
export async function commitEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
entryId: string
): Promise<JournalEntry> {
@@ -220,7 +223,7 @@ export async function commitEntry(
.from('journal_entries')
.select('*')
.eq('id', entryId)
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('status', 'draft')
.single()
@@ -231,7 +234,7 @@ export async function commitEntry(
// Assign voucher number
const voucherNumber = await getNextVoucherNumber(
supabase,
userId,
companyId,
entry.fiscal_period_id,
entry.voucher_series || 'A'
)
@@ -261,7 +264,7 @@ export async function commitEntry(
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: result, userId },
payload: { entry: result, userId, companyId },
})
return result
@@ -275,11 +278,12 @@ export async function commitEntry(
*/
export async function createJournalEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: CreateJournalEntryInput
): Promise<JournalEntry> {
const draft = await createDraftEntry(supabase, userId, input)
return commitEntry(supabase, userId, draft.id)
const draft = await createDraftEntry(supabase, companyId, userId, input)
return commitEntry(supabase, companyId, userId, draft.id)
}
/**
@@ -296,6 +300,7 @@ export function getSwedishLocalDate(): string {
*/
export async function reverseEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
entryId: string,
reversalDate?: string
@@ -306,7 +311,7 @@ export async function reverseEntry(
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', entryId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (error || !original) {
@@ -340,18 +345,19 @@ export async function reverseEntry(
// Get voucher number for the reversal
const voucherNumber = await getNextVoucherNumber(
supabase,
userId,
companyId,
original.fiscal_period_id,
original.voucher_series || 'A'
)
// Resolve account IDs
const accountIdMap = await resolveAccountIds(supabase, userId, reversedLines)
const accountIdMap = await resolveAccountIds(supabase, companyId, reversedLines)
// Create reversal entry with reverses_id link
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: voucherNumber,
@@ -425,7 +431,7 @@ export async function reverseEntry(
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: result, userId },
payload: { entry: result, userId, companyId },
})
return result
@@ -56,11 +56,13 @@ describe('Supplier Invoice Core Handler', () => {
inboxItem: { id: 'inbox-1' } as never,
supplierInvoice: invoice,
userId: 'user-1',
companyId: 'company-1',
},
})
expect(mockCreateEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
invoice,
expect.any(Array),
@@ -84,6 +86,7 @@ describe('Supplier Invoice Core Handler', () => {
inboxItem: { id: 'inbox-2' } as never,
supplierInvoice: invoice,
userId: 'user-1',
companyId: 'company-1',
},
})
@@ -111,6 +114,7 @@ describe('Supplier Invoice Core Handler', () => {
inboxItem: { id: 'inbox-3' } as never,
supplierInvoice: invoice,
userId: 'user-1',
companyId: 'company-1',
},
})
@@ -17,7 +17,7 @@ const log = createLogger('supplier-invoice-handler')
async function handleSupplierInvoiceConfirmed(
payload: EventPayload<'supplier_invoice.confirmed'>
): Promise<void> {
const { supplierInvoice, userId } = payload
const { supplierInvoice, userId, companyId } = payload
const supabase = await createClient()
@@ -25,7 +25,7 @@ async function handleSupplierInvoiceConfirmed(
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('user_id', userId)
.eq('company_id', userId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
@@ -55,6 +55,7 @@ async function handleSupplierInvoiceConfirmed(
try {
const journalEntry = await createSupplierInvoiceRegistrationEntry(
supabase,
companyId,
userId,
supplierInvoice,
items as SupplierInvoiceItem[],
+12 -8
View File
@@ -152,12 +152,13 @@ function generatePerRateLines(
*/
export async function createInvoiceJournalEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: Invoice,
entityType: EntityType = 'enskild_firma',
customerName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, invoice.invoice_date)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
return null
@@ -232,7 +233,7 @@ export async function createInvoiceJournalEntry(
lines,
}
return createJournalEntry(supabase, userId, input)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
@@ -243,6 +244,7 @@ export async function createInvoiceJournalEntry(
*/
export async function createInvoicePaymentJournalEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: Invoice,
paymentDate: string,
@@ -250,7 +252,7 @@ export async function createInvoicePaymentJournalEntry(
customerName?: string,
paymentAmount?: number
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
return null
@@ -337,7 +339,7 @@ export async function createInvoicePaymentJournalEntry(
lines,
}
return createJournalEntry(supabase, userId, input)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
@@ -350,12 +352,13 @@ export async function createInvoicePaymentJournalEntry(
*/
export async function createCreditNoteJournalEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
creditNote: Invoice,
entityType: EntityType = 'enskild_firma',
customerName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, creditNote.invoice_date)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, creditNote.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for credit note date:', creditNote.invoice_date)
return null
@@ -424,7 +427,7 @@ export async function createCreditNoteJournalEntry(
lines,
}
return createJournalEntry(supabase, userId, input)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
@@ -437,13 +440,14 @@ export async function createCreditNoteJournalEntry(
*/
export async function createInvoiceCashEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: Invoice,
paymentDate: string,
entityType: EntityType = 'enskild_firma',
customerName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
return null
@@ -504,7 +508,7 @@ export async function createInvoiceCashEntry(
lines,
}
return createJournalEntry(supabase, userId, input)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
+10 -10
View File
@@ -48,7 +48,7 @@ function getCapitalizationThreshold(year: number): number {
*/
export async function evaluateMappingRules(
supabase: SupabaseClient,
userId: string,
companyId: string,
transaction: Transaction,
entityType?: EntityType
): Promise<MappingResult> {
@@ -57,12 +57,12 @@ export async function evaluateMappingRules(
.from('mapping_rules')
.select('*')
.eq('is_active', true)
.or(`user_id.eq.${userId},user_id.is.null`)
.or(`company_id.eq.${companyId},company_id.is.null`)
.order('priority', { ascending: true })
if (error || !rules || rules.length === 0) {
// Try counterparty templates before static template fallback
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, userId, transaction, entityType)
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, companyId, transaction, entityType)
if (counterpartyResult) return counterpartyResult
const templateResult = evaluateTemplateRules(transaction, entityType)
@@ -78,7 +78,7 @@ export async function evaluateMappingRules(
}
// Try counterparty templates before static template fallback
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, userId, transaction, entityType)
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, companyId, transaction, entityType)
if (counterpartyResult) return counterpartyResult
// Try template-based matching before default fallback
@@ -117,12 +117,12 @@ function evaluateTemplateRules(
*/
async function evaluateCounterpartyTemplates(
supabase: SupabaseClient,
userId: string,
companyId: string,
transaction: Transaction,
entityType?: EntityType
): Promise<MappingResult | null> {
try {
const match = await findCounterpartyTemplate(supabase, userId, transaction)
const match = await findCounterpartyTemplate(supabase, companyId, transaction)
if (!match) return null
const threshold = match.template.source === 'auto_learned' ? 0.6 : 0.4
@@ -291,7 +291,7 @@ function getDefaultResult(transaction: Transaction): MappingResult {
*/
export async function saveUserMappingRule(
supabase: SupabaseClient,
userId: string,
companyId: string,
merchantName: string,
debitAccount: string,
creditAccount: string,
@@ -307,12 +307,12 @@ export async function saveUserMappingRule(
await supabase
.from('mapping_rules')
.delete()
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('merchant_pattern', escapedMerchant)
.eq('source', 'user_description')
const { error } = await supabase.from('mapping_rules').insert({
user_id: userId,
company_id: companyId,
rule_name: `Described: ${merchantName}`,
rule_type: 'merchant_name',
priority: 5,
@@ -333,7 +333,7 @@ export async function saveUserMappingRule(
}
} else {
const { error } = await supabase.from('mapping_rules').insert({
user_id: userId,
company_id: companyId,
rule_name: `Learned: ${merchantName}`,
rule_type: 'merchant_name',
priority: 10,
+12 -8
View File
@@ -45,13 +45,14 @@ function buildSupplierDescription(
*/
export async function createSupplierInvoiceRegistrationEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
supplierType: string,
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, invoice.invoice_date)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
return null
@@ -128,7 +129,7 @@ export async function createSupplierInvoiceRegistrationEntry(
lines,
}
return createJournalEntry(supabase, userId, input)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
@@ -144,6 +145,7 @@ export async function createSupplierInvoiceRegistrationEntry(
*/
export async function createSupplierInvoicePaymentEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: SupplierInvoice,
paymentAmount: number,
@@ -151,7 +153,7 @@ export async function createSupplierInvoicePaymentEntry(
exchangeRateDifference?: number,
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
return null
@@ -225,7 +227,7 @@ export async function createSupplierInvoicePaymentEntry(
lines,
}
return createJournalEntry(supabase, userId, input)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
@@ -238,6 +240,7 @@ export async function createSupplierInvoicePaymentEntry(
*/
export async function createSupplierInvoiceCashEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
@@ -245,7 +248,7 @@ export async function createSupplierInvoiceCashEntry(
supplierType: string,
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
return null
@@ -318,7 +321,7 @@ export async function createSupplierInvoiceCashEntry(
lines,
}
return createJournalEntry(supabase, userId, input)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
@@ -330,13 +333,14 @@ export async function createSupplierInvoiceCashEntry(
*/
export async function createSupplierCreditNoteEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
creditNote: SupplierInvoice,
items: SupplierInvoiceItem[],
supplierType: string,
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, creditNote.invoice_date)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, creditNote.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for credit note date:', creditNote.invoice_date)
return null
@@ -427,7 +431,7 @@ export async function createSupplierCreditNoteEntry(
lines,
}
return createJournalEntry(supabase, userId, input)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
+3 -2
View File
@@ -41,6 +41,7 @@ const log = createLogger('transaction-entries')
*/
export async function createTransactionJournalEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
transaction: Transaction,
mappingResult: MappingResult
@@ -51,7 +52,7 @@ export async function createTransactionJournalEntry(
)
}
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, transaction.date)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, transaction.date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for transaction date:', transaction.date)
return null
@@ -240,7 +241,7 @@ export async function createTransactionJournalEntry(
lines,
}
return createJournalEntry(supabase, userId, input)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
+22
View File
@@ -0,0 +1,22 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { setActiveCompany } from '@/lib/company/context'
import { revalidatePath } from 'next/cache'
export async function switchCompany(companyId: string): Promise<{ error?: string }> {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { error: 'Unauthorized' }
}
try {
await setActiveCompany(supabase, user.id, companyId)
revalidatePath('/')
return {}
} catch {
return { error: 'Du har inte tillgång till detta företag.' }
}
}
+143
View File
@@ -0,0 +1,143 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { cookies } from 'next/headers'
const COMPANY_COOKIE = 'gnubok-company-id'
/**
* Get the active company ID for the authenticated user.
* Resolution order: cookie → user_preferences → first owned company.
* Returns null if user has no companies.
*/
export async function getActiveCompanyId(
supabase: SupabaseClient,
userId: string
): Promise<string | null> {
// 1. Try cookie
const cookieStore = await cookies()
const cookieValue = cookieStore.get(COMPANY_COOKIE)?.value
if (cookieValue) {
// Validate the user is a member of this company
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('company_id', cookieValue)
.eq('user_id', userId)
.single()
if (membership) return membership.company_id
}
// 2. Try user_preferences
const { data: prefs } = await supabase
.from('user_preferences')
.select('active_company_id')
.eq('user_id', userId)
.single()
if (prefs?.active_company_id) {
// Validate membership
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('company_id', prefs.active_company_id)
.eq('user_id', userId)
.single()
if (membership) return membership.company_id
}
// 3. Fallback: first company where user is owner (or any member)
const { data: firstCompany } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', userId)
.order('created_at', { ascending: true })
.limit(1)
.single()
return firstCompany?.company_id ?? null
}
/**
* Get all companies the user is a member of, with their roles.
*/
export async function getUserCompanies(
supabase: SupabaseClient,
userId: string
) {
const { data, error } = await supabase
.from('company_members')
.select(`
company_id,
role,
joined_at,
companies:company_id (
id,
name,
org_number,
entity_type,
archived_at,
created_at
)
`)
.eq('user_id', userId)
.order('joined_at', { ascending: true })
if (error) throw error
return data ?? []
}
/**
* Set the active company for the user (updates cookie + user_preferences).
*/
export async function setActiveCompany(
supabase: SupabaseClient,
userId: string,
companyId: string
): Promise<void> {
// Validate membership
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('company_id', companyId)
.eq('user_id', userId)
.single()
if (!membership) {
throw new Error('User is not a member of this company')
}
// Update user_preferences
await supabase
.from('user_preferences')
.upsert(
{ user_id: userId, active_company_id: companyId },
{ onConflict: 'user_id' }
)
// Set cookie
const cookieStore = await cookies()
cookieStore.set(COMPANY_COOKIE, companyId, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365, // 1 year
})
}
/**
* Get the active company ID for API routes.
* Throws if no company context can be resolved.
*/
export async function requireCompanyId(
supabase: SupabaseClient,
userId: string
): Promise<string> {
const companyId = await getActiveCompanyId(supabase, userId)
if (!companyId) {
throw new Error('No company context')
}
return companyId
}
+5 -5
View File
@@ -33,7 +33,7 @@ export async function getAuditLog(
let query = supabase
.from('audit_log')
.select('*', { count: 'exact' })
.eq('user_id', userId)
.eq('company_id', userId)
.order('created_at', { ascending: false })
.range(offset, offset + pageSize - 1)
@@ -78,7 +78,7 @@ export async function getEntityHistory(
const { data, error } = await supabase
.from('audit_log')
.select('*')
.eq('user_id', userId)
.eq('company_id', userId)
.eq('table_name', tableName)
.eq('record_id', recordId)
.order('created_at', { ascending: true })
@@ -105,7 +105,7 @@ export async function getCorrectionChain(
.from('journal_entries')
.select('id, reverses_id, reversed_by_id, correction_of_id')
.eq('id', journalEntryId)
.eq('user_id', userId)
.eq('company_id', userId)
.single()
if (entryError || !entry) {
@@ -122,7 +122,7 @@ export async function getCorrectionChain(
const { data: referencing } = await supabase
.from('journal_entries')
.select('id')
.eq('user_id', userId)
.eq('company_id', userId)
.or(`reverses_id.eq.${journalEntryId},reversed_by_id.eq.${journalEntryId},correction_of_id.eq.${journalEntryId}`)
for (const ref of referencing || []) {
@@ -133,7 +133,7 @@ export async function getCorrectionChain(
const { data, error } = await supabase
.from('audit_log')
.select('*')
.eq('user_id', userId)
.eq('company_id', userId)
.eq('table_name', 'journal_entries')
.in('record_id', Array.from(relatedIds))
.order('created_at', { ascending: true })
@@ -53,7 +53,7 @@ describe('lockPeriod', () => {
eventBus.on('period.locked', handler)
const supabase = makeClient()
const result = await lockPeriod(supabase as never, 'user-1', 'fp-1')
const result = await lockPeriod(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.locked_at).toBeTruthy()
expect(handler).toHaveBeenCalledOnce()
@@ -69,7 +69,7 @@ describe('lockPeriod', () => {
results = [{ data: period, error: null }]
const supabase = makeClient()
await expect(lockPeriod(supabase as never, 'user-1', 'fp-1')).rejects.toThrow('already locked')
await expect(lockPeriod(supabase as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow('already locked')
})
})
@@ -89,7 +89,7 @@ describe('closePeriod', () => {
]
const supabase = makeClient()
const result = await closePeriod(supabase as never, 'user-1', 'fp-1')
const result = await closePeriod(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.is_closed).toBe(true)
})
@@ -104,7 +104,7 @@ describe('closePeriod', () => {
results = [{ data: period, error: null }]
const supabase = makeClient()
await expect(closePeriod(supabase as never, 'user-1', 'fp-1')).rejects.toThrow('must be locked')
await expect(closePeriod(supabase as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow('must be locked')
})
it('rejects if no closing_entry_id', async () => {
@@ -118,7 +118,7 @@ describe('closePeriod', () => {
results = [{ data: period, error: null }]
const supabase = makeClient()
await expect(closePeriod(supabase as never, 'user-1', 'fp-1')).rejects.toThrow(
await expect(closePeriod(supabase as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow(
'Year-end closing must be executed'
)
})
@@ -147,7 +147,7 @@ describe('createNextPeriod', () => {
]
const supabase = makeClient()
const result = await createNextPeriod(supabase as never, 'user-1', 'fp-2024')
const result = await createNextPeriod(supabase as never, 'company-1', 'user-1', 'fp-2024')
expect(result.period_start).toBe('2025-01-01')
expect(result.period_end).toBe('2025-12-31')
expect(result.previous_period_id).toBe('fp-2024')
@@ -175,7 +175,7 @@ describe('createNextPeriod', () => {
]
const supabase = makeClient()
const result = await createNextPeriod(supabase as never, 'user-1', 'fp-2024')
const result = await createNextPeriod(supabase as never, 'company-1', 'user-1', 'fp-2024')
expect(result.period_start).toBe('2024-07-01')
expect(result.period_end).toBe('2025-06-30')
})
@@ -98,7 +98,7 @@ describe('correctEntry', () => {
it('creates reversal with swapped debit/credit lines', async () => {
setupResults()
const supabase = makeClient()
const result = await correctEntry(supabase as never, 'user-1', 'orig-1', correctedLines)
const result = await correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines)
expect(result.reversal).toBeDefined()
expect(result.reversal.reverses_id).toBe('orig-1')
})
@@ -106,7 +106,7 @@ describe('correctEntry', () => {
it('links original ↔ reversal ↔ corrected via IDs', async () => {
setupResults()
const supabase = makeClient()
const result = await correctEntry(supabase as never, 'user-1', 'orig-1', correctedLines)
const result = await correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines)
expect(result.reversal.id).toBe('reversal-1')
expect(result.corrected.id).toBe('corrected-1')
expect(result.corrected.correction_of_id).toBe('orig-1')
@@ -121,7 +121,7 @@ describe('correctEntry', () => {
const supabase = makeClient()
await expect(
correctEntry(supabase as never, 'user-1', 'orig-1', [
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', [
{ account_number: '5420', debit_amount: 1200, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
])
@@ -135,11 +135,11 @@ describe('correctEntry', () => {
eventBus.on('journal_entry.corrected', handler)
const supabase = makeClient()
await correctEntry(supabase as never, 'user-1', 'orig-1', correctedLines)
await correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines)
expect(handler).toHaveBeenCalledOnce()
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-1' })
expect.objectContaining({ userId: 'user-1', companyId: 'company-1' })
)
})
})
@@ -95,7 +95,7 @@ describe('validateYearEndReadiness', () => {
} as never)
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'user-1', 'fp-1')
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
expect(result.errors.some((e: string) => e.includes('draft'))).toBe(true)
})
@@ -120,7 +120,7 @@ describe('validateYearEndReadiness', () => {
} as never)
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'user-1', 'fp-1')
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
expect(result.trialBalanceBalanced).toBe(false)
expect(result.errors.some((e: string) => e.includes('Trial balance'))).toBe(true)
@@ -156,7 +156,7 @@ describe('validateYearEndReadiness', () => {
totalCredit: 10000,
} as never)
const result = await validateYearEndReadiness(supabase as never, 'user-1', 'fp-1')
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.warnings.some((w: string) => w.includes('gap'))).toBe(true)
expect(result.voucherGaps).toHaveLength(1)
})
@@ -187,7 +187,7 @@ describe('previewYearEndClosing', () => {
} as never)
const supabase = makeClient()
const preview = await previewYearEndClosing(supabase as never, 'user-1', 'fp-1')
const preview = await previewYearEndClosing(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(preview.netResult).toBe(150000)
expect(preview.closingAccount).toBe('2099')
@@ -215,7 +215,7 @@ describe('previewYearEndClosing', () => {
} as never)
const supabase = makeClient()
const preview = await previewYearEndClosing(supabase as never, 'user-1', 'fp-1')
const preview = await previewYearEndClosing(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(preview.closingAccount).toBe('2010')
expect(preview.closingAccountName).toBe('Eget kapital')
+17 -12
View File
@@ -5,10 +5,11 @@ import type { FiscalPeriod, PeriodStatus } from '@/types'
/**
* Lock a fiscal period — prevents new journal entries from being posted.
* Requires: period exists, belongs to user, not already locked/closed.
* Requires: period exists, belongs to company, not already locked/closed.
*/
export async function lockPeriod(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<FiscalPeriod> {
@@ -18,7 +19,7 @@ export async function lockPeriod(
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (fetchError || !period) {
@@ -37,7 +38,7 @@ export async function lockPeriod(
const { count: unbookedCount } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('is_business', true)
.gte('date', period.period_start)
@@ -53,7 +54,7 @@ export async function lockPeriod(
.from('fiscal_periods')
.update({ locked_at: new Date().toISOString() })
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.select()
.single()
@@ -65,7 +66,7 @@ export async function lockPeriod(
await eventBus.emit({
type: 'period.locked',
payload: { period: result, userId },
payload: { period: result, companyId, userId },
})
return result
@@ -77,6 +78,7 @@ export async function lockPeriod(
*/
export async function closePeriod(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<FiscalPeriod> {
@@ -85,7 +87,7 @@ export async function closePeriod(
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (fetchError || !period) {
@@ -111,7 +113,7 @@ export async function closePeriod(
closed_at: new Date().toISOString(),
})
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.select()
.single()
@@ -129,6 +131,7 @@ export async function closePeriod(
*/
export async function createNextPeriod(
supabase: SupabaseClient,
companyId: string,
userId: string,
currentPeriodId: string
): Promise<FiscalPeriod> {
@@ -137,7 +140,7 @@ export async function createNextPeriod(
.from('fiscal_periods')
.select('*')
.eq('id', currentPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (fetchError || !current) {
@@ -177,7 +180,7 @@ export async function createNextPeriod(
const { data: overlapping } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.eq('company_id', companyId)
.lte('period_start', nextEndStr)
.gte('period_end', nextStartStr)
.limit(1)
@@ -194,6 +197,7 @@ export async function createNextPeriod(
const { data: newPeriod, error: insertError } = await supabase
.from('fiscal_periods')
.insert({
company_id: companyId,
user_id: userId,
name,
period_start: nextStartStr,
@@ -215,6 +219,7 @@ export async function createNextPeriod(
*/
export async function getPeriodStatus(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<PeriodStatus> {
@@ -223,7 +228,7 @@ export async function getPeriodStatus(
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (fetchError || !period) {
@@ -234,7 +239,7 @@ export async function getPeriodStatus(
const { count: draftCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'draft')
@@ -245,7 +250,7 @@ export async function getPeriodStatus(
const { data: nextPeriod } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('previous_period_id', fiscalPeriodId)
.maybeSingle()
+8 -4
View File
@@ -24,6 +24,7 @@ import { validateBalance, getNextVoucherNumber, getSwedishLocalDate } from '@/li
*/
export async function correctEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
originalEntryId: string,
correctedLines: CreateJournalEntryLineInput[]
@@ -41,7 +42,7 @@ export async function correctEntry(
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', originalEntryId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (fetchError || !original) {
@@ -57,7 +58,7 @@ export async function correctEntry(
// ===== Step 1: Create storno (reversal) entry =====
const reversalVoucherNumber = await getNextVoucherNumber(
supabase,
userId,
companyId,
original.fiscal_period_id,
original.voucher_series || 'A'
)
@@ -65,6 +66,7 @@ export async function correctEntry(
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,
@@ -147,7 +149,7 @@ export async function correctEntry(
try {
const correctedVoucherNumber = await getNextVoucherNumber(
supabase,
userId,
companyId,
original.fiscal_period_id,
original.voucher_series || 'A'
)
@@ -157,7 +159,7 @@ export async function correctEntry(
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('id, account_number')
.eq('user_id', userId)
.eq('company_id', companyId)
.in('account_number', accountNumbers)
const accountIdMap = new Map<string, string>()
@@ -168,6 +170,7 @@ export async function correctEntry(
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,
@@ -254,6 +257,7 @@ export async function correctEntry(
original: original as JournalEntry,
storno: result.reversal,
corrected: result.corrected,
companyId,
userId,
},
})
+35 -29
View File
@@ -24,6 +24,7 @@ import type {
*/
export async function validateYearEndReadiness(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<YearEndValidation> {
@@ -35,7 +36,7 @@ export async function validateYearEndReadiness(
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (fetchError || !period) {
@@ -63,7 +64,7 @@ export async function validateYearEndReadiness(
const { count: draftCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'draft')
@@ -75,7 +76,7 @@ export async function validateYearEndReadiness(
// Check: voucher continuity
let voucherGaps: VoucherGap[] = []
const { data: gaps, error: gapsError } = await supabase.rpc('detect_voucher_gaps', {
p_user_id: userId,
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_series: 'A',
})
@@ -88,7 +89,7 @@ export async function validateYearEndReadiness(
}
// Check: trial balance is balanced
const trialBalance = await generateTrialBalance(supabase, userId, fiscalPeriodId)
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
const trialBalanceBalanced = trialBalance.isBalanced
if (!trialBalanceBalanced) {
@@ -101,7 +102,7 @@ export async function validateYearEndReadiness(
const { count: entryCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
@@ -113,7 +114,7 @@ export async function validateYearEndReadiness(
const { count: revalCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('source_type', 'currency_revaluation')
.eq('status', 'posted')
@@ -123,7 +124,7 @@ export async function validateYearEndReadiness(
const { count: fxReceivables } = await supabase
.from('invoices')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
@@ -131,7 +132,7 @@ export async function validateYearEndReadiness(
const { count: fxPayables } = await supabase
.from('supplier_invoices')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
@@ -159,6 +160,7 @@ export async function validateYearEndReadiness(
*/
export async function previewYearEndClosing(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<YearEndPreview> {
@@ -167,7 +169,7 @@ export async function previewYearEndClosing(
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
const entityType = settings?.entity_type ?? 'aktiebolag'
@@ -178,11 +180,11 @@ export async function previewYearEndClosing(
: 'Årets resultat'
// Get income statement for net result
const incomeStatement = await generateIncomeStatement(supabase, userId, fiscalPeriodId)
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
const netResult = incomeStatement.net_result
// Get trial balance for individual account balances in class 3-8
const { rows } = await generateTrialBalance(supabase, userId, fiscalPeriodId)
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
const resultAccounts = rows.filter(
(r) => r.account_class >= 3 && r.account_class <= 8
)
@@ -255,14 +257,14 @@ export async function previewYearEndClosing(
.from('fiscal_periods')
.select('period_end')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
let currencyRevaluation = null
if (periodData) {
const revalPreview = await previewCurrencyRevaluation(
supabase,
userId,
companyId,
periodData.period_end
)
if (revalPreview.items.length > 0) {
@@ -293,11 +295,12 @@ export async function previewYearEndClosing(
*/
export async function executeYearEndClosing(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<YearEndResult> {
// 1. Validate readiness
const validation = await validateYearEndReadiness(supabase, userId, fiscalPeriodId)
const validation = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId)
if (!validation.ready) {
throw new Error(`Year-end closing not ready: ${validation.errors.join('; ')}`)
}
@@ -307,7 +310,7 @@ export async function executeYearEndClosing(
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (!period) {
@@ -319,20 +322,21 @@ export async function executeYearEndClosing(
// the closing entry then zeros out.
const revaluationResult = await executeCurrencyRevaluation(
supabase,
userId,
companyId,
period.period_end,
fiscalPeriodId
fiscalPeriodId,
userId
)
// 3. Get closing preview (now includes revaluation effects in trial balance)
const preview = await previewYearEndClosing(supabase, userId, fiscalPeriodId)
const preview = await previewYearEndClosing(supabase, companyId, userId, fiscalPeriodId)
if (preview.closingLines.length === 0) {
throw new Error('No result accounts to close — period has no activity')
}
// 4. Create closing entry via the journal engine
const closingEntry = await createJournalEntry(supabase, userId, {
const closingEntry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: period.period_end,
description: `Årsbokslut ${period.name}`,
@@ -346,24 +350,25 @@ export async function executeYearEndClosing(
.from('fiscal_periods')
.update({ closing_entry_id: closingEntry.id })
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
if (updateError) {
throw new Error(`Failed to set closing_entry_id: ${updateError.message}`)
}
// 6. Lock the period
await lockPeriod(supabase, userId, fiscalPeriodId)
await lockPeriod(supabase, companyId, userId, fiscalPeriodId)
// 7. Close the period
await closePeriod(supabase, userId, fiscalPeriodId)
await closePeriod(supabase, companyId, userId, fiscalPeriodId)
// 8. Create next period
const nextPeriod = await createNextPeriod(supabase, userId, fiscalPeriodId)
const nextPeriod = await createNextPeriod(supabase, companyId, userId, fiscalPeriodId)
// 9. Generate opening balances in next period
const openingBalanceEntry = await generateOpeningBalances(
supabase,
companyId,
userId,
fiscalPeriodId,
nextPeriod.id
@@ -374,13 +379,13 @@ export async function executeYearEndClosing(
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (closedPeriod) {
await eventBus.emit({
type: 'period.year_closed',
payload: { period: closedPeriod as FiscalPeriod, userId },
payload: { period: closedPeriod as FiscalPeriod, companyId, userId },
})
}
@@ -401,6 +406,7 @@ export async function executeYearEndClosing(
*/
export async function generateOpeningBalances(
supabase: SupabaseClient,
companyId: string,
userId: string,
closedPeriodId: string,
nextPeriodId: string
@@ -411,7 +417,7 @@ export async function generateOpeningBalances(
.from('fiscal_periods')
.select('*')
.eq('id', nextPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (!nextPeriod) {
@@ -419,7 +425,7 @@ export async function generateOpeningBalances(
}
// Get trial balance of closed period (includes the closing entry)
const { rows } = await generateTrialBalance(supabase, userId, closedPeriodId)
const { rows } = await generateTrialBalance(supabase, companyId, closedPeriodId)
// Filter to balance sheet accounts (class 1-2) with non-zero closing balance
const balanceSheetAccounts = rows.filter(
@@ -467,7 +473,7 @@ export async function generateOpeningBalances(
}
// Create opening balance entry in next period
const openingEntry = await createJournalEntry(supabase, userId, {
const openingEntry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: nextPeriodId,
entry_date: nextPeriod.period_start,
description: `Ingående balans ${nextPeriod.name}`,
@@ -484,7 +490,7 @@ export async function generateOpeningBalances(
opening_balances_set: true,
})
.eq('id', nextPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
if (updateError) {
throw new Error(`Failed to set opening_balance_entry_id: ${updateError.message}`)
@@ -74,7 +74,7 @@ describe('uploadDocument', () => {
const supabase = makeClient()
const buffer = new TextEncoder().encode('test content').buffer
const result = await uploadDocument(supabase as never, 'user-1', {
const result = await uploadDocument(supabase as never, 'user-1', 'company-1', {
name: 'test.pdf',
buffer: buffer as ArrayBuffer,
type: 'application/pdf',
@@ -87,6 +87,7 @@ describe('uploadDocument', () => {
expect.objectContaining({
document: expect.objectContaining({ id: 'doc-1' }),
userId: 'user-1',
companyId: 'company-1',
})
)
})
+6 -5
View File
@@ -57,6 +57,7 @@ export async function computeSHA256(buffer: ArrayBuffer): Promise<string> {
export async function uploadDocument(
supabase: SupabaseClient,
userId: string,
companyId: string,
file: { name: string; buffer: ArrayBuffer; type?: string },
metadata: {
upload_source?: DocumentUploadSource
@@ -116,7 +117,7 @@ export async function uploadDocument(
await eventBus.emit({
type: 'document.uploaded',
payload: { document: result, userId },
payload: { document: result, userId, companyId },
})
return result
@@ -192,7 +193,7 @@ export async function createNewVersion(
*/
export async function linkToJournalEntry(
supabase: SupabaseClient,
userId: string,
companyId: string,
documentId: string,
journalEntryId: string,
journalEntryLineId?: string
@@ -205,7 +206,7 @@ export async function linkToJournalEntry(
journal_entry_line_id: journalEntryLineId || null,
})
.eq('id', documentId)
.eq('user_id', userId)
.eq('company_id', companyId)
.select()
.single()
@@ -221,7 +222,7 @@ export async function linkToJournalEntry(
*/
export async function verifyIntegrity(
supabase: SupabaseClient,
userId: string,
companyId: string,
documentId: string
): Promise<{ valid: boolean; storedHash: string; computedHash: string }> {
@@ -230,7 +231,7 @@ export async function verifyIntegrity(
.from('document_attachments')
.select('storage_path, sha256_hash')
.eq('id', documentId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (docError || !doc) {
+3 -3
View File
@@ -17,7 +17,7 @@ export async function getTaxCodes(supabase: SupabaseClient, userId: string): Pro
const { data, error } = await supabase
.from('tax_codes')
.select('*')
.or(`user_id.eq.${userId},user_id.is.null`)
.or(`company_id.eq.${userId},company_id.is.null`)
.order('code')
if (error) {
@@ -41,7 +41,7 @@ export async function getTaxCodeByCode(
.from('tax_codes')
.select('*')
.eq('code', code)
.or(`user_id.eq.${userId},user_id.is.null`)
.or(`company_id.eq.${userId},company_id.is.null`)
.order('user_id', { ascending: false, nullsFirst: false })
.limit(1)
.single()
@@ -93,7 +93,7 @@ export async function calculateMomsFromTaxCodes(
)
`)
.not('tax_code', 'is', null)
.eq('journal_entries.user_id', userId)
.eq('journal_entries.company_id', userId)
.eq('journal_entries.status', 'posted')
.gte('journal_entries.entry_date', periodStart)
.lte('journal_entries.entry_date', periodEnd)
+5 -5
View File
@@ -154,7 +154,7 @@ export async function updateDeadlineStatuses(
export async function updateDeadlineStatus(
supabase: SupabaseClient,
deadlineId: string,
userId: string,
companyId: string,
newStatus: DeadlineStatus
): Promise<{ success: boolean; error?: string }> {
// Fetch current deadline
@@ -162,7 +162,7 @@ export async function updateDeadlineStatus(
.from('deadlines')
.select('status, is_completed')
.eq('id', deadlineId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (fetchError || !deadline) {
@@ -193,7 +193,7 @@ export async function updateDeadlineStatus(
.from('deadlines')
.update(updates)
.eq('id', deadlineId)
.eq('user_id', userId)
.eq('company_id', companyId)
if (updateError) {
return { success: false, error: updateError.message }
@@ -207,7 +207,7 @@ export async function updateDeadlineStatus(
*/
export async function getDeadlinesNeedingAttention(
supabase: SupabaseClient,
userId: string
companyId: string
): Promise<{
actionNeeded: Array<{ id: string; title: string; due_date: string; tax_deadline_type: string | null }>
overdue: Array<{ id: string; title: string; due_date: string; tax_deadline_type: string | null }>
@@ -215,7 +215,7 @@ export async function getDeadlinesNeedingAttention(
const { data: deadlines, error } = await supabase
.from('deadlines')
.select('id, title, due_date, tax_deadline_type, status')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('is_completed', false)
.in('status', ['action_needed', 'overdue'])
.order('due_date', { ascending: true })
+5 -5
View File
@@ -26,14 +26,14 @@ export interface BatchMatchResult {
*/
export async function runDocumentMatchingSweep(
supabase: SupabaseClient,
userId: string,
companyId: string,
inboxItemIds?: string[]
): Promise<BatchMatchResult> {
// 1. Fetch unmatched inbox items
let query = supabase
.from('invoice_inbox_items')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.is('matched_transaction_id', null)
.in('status', ['ready', 'processing'])
@@ -57,7 +57,7 @@ export async function runDocumentMatchingSweep(
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.is('is_business', null)
.lt('amount', 0)
@@ -80,7 +80,7 @@ export async function runDocumentMatchingSweep(
for (const item of inboxItems as InvoiceInboxItem[]) {
const result = await matchDocumentToTransactions(
supabase,
userId,
companyId,
item,
transactions as Transaction[]
)
@@ -116,7 +116,7 @@ export async function runDocumentMatchingSweep(
match_method: result.method,
})
.eq('id', inboxItemId)
.eq('user_id', userId)
.eq('company_id', companyId)
}
return {
+4 -4
View File
@@ -44,7 +44,7 @@ export interface DocumentMatchResult {
*/
export async function matchDocumentToTransactions(
supabase: SupabaseClient,
userId: string,
companyId: string,
inboxItem: InvoiceInboxItem,
candidateTransactions?: Transaction[]
): Promise<DocumentMatchResult | null> {
@@ -61,7 +61,7 @@ export async function matchDocumentToTransactions(
return null
}
const transactions = candidateTransactions ?? (await fetchCandidateTransactions(supabase, userId, inboxItem))
const transactions = candidateTransactions ?? (await fetchCandidateTransactions(supabase, companyId, inboxItem))
console.log(`${tag} — ${transactions.length} candidate transactions`)
@@ -92,7 +92,7 @@ export async function matchDocumentToTransactions(
*/
async function fetchCandidateTransactions(
supabase: SupabaseClient,
userId: string,
companyId: string,
inboxItem: InvoiceInboxItem
): Promise<Transaction[]> {
const docDate = getDocumentDate(inboxItem)
@@ -106,7 +106,7 @@ async function fetchCandidateTransactions(
const { data, error } = await supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.is('is_business', null)
.lt('amount', 0)
+122
View File
@@ -0,0 +1,122 @@
export interface InviteEmailData {
companyName: string
inviterEmail: string
inviteUrl: string
}
export function generateInviteEmailSubject(data: InviteEmailData): string {
return `Du har bjudits in till ${data.companyName} på gnubok`
}
export function generateInviteEmailHtml(data: InviteEmailData): string {
const { companyName, inviterEmail, inviteUrl } = data
return `
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inbjudan till ${companyName}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5;">
<div style="max-width: 520px; margin: 0 auto; padding: 40px 20px;">
<div style="background: #ffffff; border-radius: 12px; padding: 40px 32px; border: 1px solid #e5e5e5;">
<!-- Header -->
<div style="margin-bottom: 28px;">
<p style="margin: 0 0 4px 0; font-size: 13px; color: #888; letter-spacing: 0.05em;">GNUBOK</p>
<h1 style="margin: 0 0 8px 0; font-size: 22px; font-weight: 600; color: #111;">
Du har blivit inbjuden
</h1>
<p style="margin: 0; color: #666; font-size: 15px;">
<strong>${inviterEmail}</strong> har bjudit in dig till <strong>${companyName}</strong> på gnubok.
</p>
</div>
<!-- CTA -->
<div style="margin: 28px 0;">
<a href="${inviteUrl}" style="display: inline-block; background: #111; color: #fff; text-decoration: none; padding: 12px 28px; border-radius: 8px; font-size: 14px; font-weight: 500;">
Acceptera inbjudan
</a>
</div>
<!-- Info -->
<p style="margin: 0; color: #999; font-size: 13px;">
Länken är giltig i 7 dagar. Om du inte förväntade dig denna inbjudan kan du ignorera detta meddelande.
</p>
</div>
</div>
</body>
</html>`
}
export function generateInviteEmailText(data: InviteEmailData): string {
return `Du har bjudits in till ${data.companyName} på gnubok av ${data.inviterEmail}.
Acceptera inbjudan: ${data.inviteUrl}
Länken är giltig i 7 dagar.`
}
// =============================================================================
// Team invite email templates
// =============================================================================
export interface TeamInviteEmailData {
inviterEmail: string
inviteUrl: string
}
export function generateTeamInviteEmailSubject(): string {
return 'Du har bjudits in till ett team på gnubok'
}
export function generateTeamInviteEmailHtml(data: TeamInviteEmailData): string {
const { inviterEmail, inviteUrl } = data
return `
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inbjudan till team</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5;">
<div style="max-width: 520px; margin: 0 auto; padding: 40px 20px;">
<div style="background: #ffffff; border-radius: 12px; padding: 40px 32px; border: 1px solid #e5e5e5;">
<!-- Header -->
<div style="margin-bottom: 28px;">
<p style="margin: 0 0 4px 0; font-size: 13px; color: #888; letter-spacing: 0.05em;">GNUBOK</p>
<h1 style="margin: 0 0 8px 0; font-size: 22px; font-weight: 600; color: #111;">
Du har blivit inbjuden till ett team
</h1>
<p style="margin: 0; color: #666; font-size: 15px;">
<strong>${inviterEmail}</strong> har bjudit in dig som konsult. Du får tillgång till alla företag i teamet.
</p>
</div>
<!-- CTA -->
<div style="margin: 28px 0;">
<a href="${inviteUrl}" style="display: inline-block; background: #111; color: #fff; text-decoration: none; padding: 12px 28px; border-radius: 8px; font-size: 14px; font-weight: 500;">
Acceptera inbjudan
</a>
</div>
<!-- Info -->
<p style="margin: 0; color: #999; font-size: 13px;">
Länken är giltig i 7 dagar. Om du inte förväntade dig denna inbjudan kan du ignorera detta meddelande.
</p>
</div>
</div>
</body>
</html>`
}
export function generateTeamInviteEmailText(data: TeamInviteEmailData): string {
return `Du har bjudits in som konsult till ett team på gnubok av ${data.inviterEmail}. Du får tillgång till alla företag i teamet.
Acceptera inbjudan: ${data.inviteUrl}
Länken är giltig i 7 dagar.`
}
+9 -9
View File
@@ -25,11 +25,11 @@ describe('EventBus', () => {
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: fakeEntry, userId: 'u1' },
payload: { entry: fakeEntry, userId: 'u1', companyId: 'company-1' },
})
expect(handler1).toHaveBeenCalledWith({ entry: fakeEntry, userId: 'u1' })
expect(handler2).toHaveBeenCalledWith({ entry: fakeEntry, userId: 'u1' })
expect(handler1).toHaveBeenCalledWith({ entry: fakeEntry, userId: 'u1', companyId: 'company-1' })
expect(handler2).toHaveBeenCalledWith({ entry: fakeEntry, userId: 'u1', companyId: 'company-1' })
})
it('emit() uses Promise.allSettled — a failing handler does not crash others', async () => {
@@ -42,7 +42,7 @@ describe('EventBus', () => {
// Should not throw
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: fakeEntry, userId: 'u1' },
payload: { entry: fakeEntry, userId: 'u1', companyId: 'company-1' },
})
expect(failingHandler).toHaveBeenCalled()
@@ -53,7 +53,7 @@ describe('EventBus', () => {
// Should not throw
await eventBus.emit({
type: 'journal_entry.drafted',
payload: { entry: fakeEntry, userId: 'u1' },
payload: { entry: fakeEntry, userId: 'u1', companyId: 'company-1' },
})
})
@@ -65,7 +65,7 @@ describe('EventBus', () => {
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: fakeEntry, userId: 'u1' },
payload: { entry: fakeEntry, userId: 'u1', companyId: 'company-1' },
})
expect(handler).not.toHaveBeenCalled()
@@ -82,11 +82,11 @@ describe('EventBus', () => {
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: fakeEntry, userId: 'u1' },
payload: { entry: fakeEntry, userId: 'u1', companyId: 'company-1' },
})
await eventBus.emit({
type: 'journal_entry.drafted',
payload: { entry: fakeEntry, userId: 'u1' },
payload: { entry: fakeEntry, userId: 'u1', companyId: 'company-1' },
})
expect(handler1).not.toHaveBeenCalled()
@@ -102,7 +102,7 @@ describe('EventBus', () => {
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: fakeEntry, userId: 'u1' },
payload: { entry: fakeEntry, userId: 'u1', companyId: 'company-1' },
})
expect(committedHandler).toHaveBeenCalledOnce()
@@ -34,7 +34,7 @@ describe('event-log-handler', () => {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice, userId: 'user-1' },
payload: { invoice, userId: 'user-1', companyId: 'company-1' },
})
expect(mockInsert).toHaveBeenCalledTimes(1)
@@ -57,7 +57,7 @@ describe('event-log-handler', () => {
await eventBus.emit({
type: 'customer.created',
payload: { customer, userId: 'user-1' },
payload: { customer, userId: 'user-1', companyId: 'company-1' },
})
expect(mockInsert).toHaveBeenCalledTimes(1)
@@ -77,7 +77,7 @@ describe('event-log-handler', () => {
await eventBus.emit({
type: 'transaction.synced',
payload: { transactions: [tx1, tx2, tx3], userId: 'user-1' },
payload: { transactions: [tx1, tx2, tx3], userId: 'user-1', companyId: 'company-1' },
})
expect(mockInsert).toHaveBeenCalledTimes(1)
@@ -91,7 +91,7 @@ describe('event-log-handler', () => {
it('does NOT persist journal_entry.drafted (excluded noise event)', async () => {
await eventBus.emit({
type: 'journal_entry.drafted',
payload: { entry: {} as unknown, userId: 'user-1' },
payload: { entry: {} as never, userId: 'user-1', companyId: 'company-1' },
})
expect(mockInsert).not.toHaveBeenCalled()
@@ -100,7 +100,7 @@ describe('event-log-handler', () => {
it('does NOT persist receipt.extracted (excluded noise event)', async () => {
await eventBus.emit({
type: 'receipt.extracted',
payload: { receipt: {} as unknown, documentId: null, confidence: 0.9, userId: 'user-1' },
payload: { receipt: {} as never, documentId: null, confidence: 0.9, userId: 'user-1', companyId: 'company-1' },
})
expect(mockInsert).not.toHaveBeenCalled()
@@ -109,7 +109,7 @@ describe('event-log-handler', () => {
it('does NOT persist supplier_invoice.received (excluded noise event)', async () => {
await eventBus.emit({
type: 'supplier_invoice.received',
payload: { inboxItem: {} as unknown, userId: 'user-1' },
payload: { inboxItem: {} as never, userId: 'user-1', companyId: 'company-1' },
})
expect(mockInsert).not.toHaveBeenCalled()
@@ -118,7 +118,7 @@ describe('event-log-handler', () => {
it('does NOT persist supplier_invoice.extracted (excluded noise event)', async () => {
await eventBus.emit({
type: 'supplier_invoice.extracted',
payload: { inboxItem: {} as unknown, confidence: 0.9, userId: 'user-1' },
payload: { inboxItem: {} as never, confidence: 0.9, userId: 'user-1', companyId: 'company-1' },
})
expect(mockInsert).not.toHaveBeenCalled()
@@ -130,7 +130,7 @@ describe('event-log-handler', () => {
// Should not throw
await eventBus.emit({
type: 'customer.created',
payload: { customer: makeCustomer(), userId: 'user-1' },
payload: { customer: makeCustomer(), userId: 'user-1', companyId: 'company-1' },
})
expect(mockInsert).toHaveBeenCalledTimes(1)
@@ -141,7 +141,7 @@ describe('event-log-handler', () => {
await eventBus.emit({
type: 'period.locked',
payload: { period, userId: 'user-1' },
payload: { period, userId: 'user-1', companyId: 'company-1' },
})
expect(mockInsert).toHaveBeenCalledWith(
+25 -22
View File
@@ -18,30 +18,31 @@ import type {
export type CoreEvent =
// Bookkeeping
| { type: 'journal_entry.drafted'; payload: { entry: JournalEntry; userId: string } }
| { type: 'journal_entry.committed'; payload: { entry: JournalEntry; userId: string } }
| { type: 'journal_entry.corrected'; payload: { original: JournalEntry; storno: JournalEntry; corrected: JournalEntry; userId: string } }
| { type: 'journal_entry.drafted'; payload: { entry: JournalEntry; userId: string; companyId: string } }
| { type: 'journal_entry.committed'; payload: { entry: JournalEntry; userId: string; companyId: string } }
| { type: 'journal_entry.corrected'; payload: { original: JournalEntry; storno: JournalEntry; corrected: JournalEntry; userId: string; companyId: string } }
// Documents
| { type: 'document.uploaded'; payload: { document: DocumentAttachment; userId: string } }
| { type: 'document.uploaded'; payload: { document: DocumentAttachment; userId: string; companyId: string } }
// Invoicing
| { type: 'invoice.created'; payload: { invoice: Invoice; userId: string } }
| { type: 'invoice.sent'; payload: { invoice: Invoice; userId: string } }
| { type: 'credit_note.created'; payload: { creditNote: CreditNote; userId: string } }
| { type: 'invoice.created'; payload: { invoice: Invoice; userId: string; companyId: string } }
| { type: 'invoice.sent'; payload: { invoice: Invoice; userId: string; companyId: string } }
| { type: 'credit_note.created'; payload: { creditNote: CreditNote; userId: string; companyId: string } }
// Banking
| { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string } }
| { type: 'transaction.categorized'; payload: { transaction: Transaction; account: string; taxCode: string; userId: string } }
| { type: 'transaction.reconciled'; payload: { transaction: Transaction; journalEntryId: string; method: ReconciliationMethod; userId: string } }
| { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string; companyId: string } }
| { type: 'transaction.categorized'; payload: { transaction: Transaction; account: string; taxCode: string; userId: string; companyId: string } }
| { type: 'transaction.reconciled'; payload: { transaction: Transaction; journalEntryId: string; method: ReconciliationMethod; userId: string; companyId: string } }
// Periods
| { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string } }
| { type: 'period.year_closed'; payload: { period: FiscalPeriod; userId: string } }
| { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
| { type: 'period.year_closed'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
// Customers
| { type: 'customer.created'; payload: { customer: Customer; userId: string } }
| { type: 'customer.created'; payload: { customer: Customer; userId: string; companyId: string } }
// Receipts
| { type: 'receipt.extracted'; payload: {
receipt: Receipt;
documentId: string | null;
confidence: number;
userId: string;
companyId: string;
}}
| { type: 'receipt.matched'; payload: {
receipt: Receipt;
@@ -49,25 +50,27 @@ export type CoreEvent =
confidence: number;
autoMatched: boolean;
userId: string;
companyId: string;
}}
| { type: 'receipt.confirmed'; payload: {
receipt: Receipt;
businessTotal: number;
privateTotal: number;
userId: string;
companyId: string;
}}
// Supplier Invoice Lifecycle
| { type: 'supplier_invoice.registered'; payload: { supplierInvoice: SupplierInvoice; userId: string } }
| { type: 'supplier_invoice.approved'; payload: { supplierInvoice: SupplierInvoice; userId: string } }
| { type: 'supplier_invoice.paid'; payload: { supplierInvoice: SupplierInvoice; paymentAmount: number; userId: string } }
| { type: 'supplier_invoice.credited'; payload: { supplierInvoice: SupplierInvoice; creditNote: SupplierInvoice; userId: string } }
| { type: 'supplier_invoice.registered'; payload: { supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
| { type: 'supplier_invoice.approved'; payload: { supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
| { type: 'supplier_invoice.paid'; payload: { supplierInvoice: SupplierInvoice; paymentAmount: number; userId: string; companyId: string } }
| { type: 'supplier_invoice.credited'; payload: { supplierInvoice: SupplierInvoice; creditNote: SupplierInvoice; userId: string; companyId: string } }
// Payment Matching
| { type: 'invoice.match_confirmed'; payload: { invoice: Invoice; transaction: Transaction; userId: string } }
| { type: 'supplier_invoice.match_confirmed'; payload: { supplierInvoice: SupplierInvoice; transaction: Transaction; userId: string } }
| { type: 'invoice.match_confirmed'; payload: { invoice: Invoice; transaction: Transaction; userId: string; companyId: string } }
| { type: 'supplier_invoice.match_confirmed'; payload: { supplierInvoice: SupplierInvoice; transaction: Transaction; userId: string; companyId: string } }
// Supplier Invoice Inbox
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string } }
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string } }
| { type: 'supplier_invoice.confirmed'; payload: { inboxItem: InvoiceInboxItem; supplierInvoice: SupplierInvoice; userId: string } }
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string; companyId: string } }
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string; companyId: string } }
| { type: 'supplier_invoice.confirmed'; payload: { inboxItem: InvoiceInboxItem; supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
// ============================================================
// Helper Types
+9 -9
View File
@@ -36,14 +36,14 @@ describe('ai-consent', () => {
describe('hasAiConsent', () => {
it('returns true for non-AI extensions without checking DB', async () => {
const result = await hasAiConsent(supabase as any, 'user-1', 'enable-banking')
const result = await hasAiConsent(supabase as any, 'company-1', 'enable-banking')
expect(result).toBe(true)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns false when no consent record exists', async () => {
mockResult({ data: null })
const result = await hasAiConsent(supabase as any, 'user-1', 'receipt-ocr')
const result = await hasAiConsent(supabase as any, 'company-1', 'receipt-ocr')
expect(result).toBe(false)
})
@@ -57,7 +57,7 @@ describe('ai-consent', () => {
},
},
})
const result = await hasAiConsent(supabase as any, 'user-1', 'receipt-ocr')
const result = await hasAiConsent(supabase as any, 'company-1', 'receipt-ocr')
expect(result).toBe(true)
})
@@ -71,7 +71,7 @@ describe('ai-consent', () => {
},
},
})
const result = await hasAiConsent(supabase as any, 'user-1', 'receipt-ocr')
const result = await hasAiConsent(supabase as any, 'company-1', 'receipt-ocr')
expect(result).toBe(false)
})
@@ -85,7 +85,7 @@ describe('ai-consent', () => {
},
},
})
const result = await hasAiConsent(supabase as any, 'user-1', 'receipt-ocr')
const result = await hasAiConsent(supabase as any, 'company-1', 'receipt-ocr')
expect(result).toBe(false)
})
})
@@ -93,13 +93,13 @@ describe('ai-consent', () => {
describe('grantAiConsent', () => {
it('upserts consent record to extension_data', async () => {
mockResult({ data: null, error: null })
await grantAiConsent(supabase as any, 'user-1', 'receipt-ocr')
await grantAiConsent(supabase as any, 'user-1', 'company-1', 'receipt-ocr')
expect(supabase.from).toHaveBeenCalledWith('extension_data')
})
it('does nothing for non-AI extensions', async () => {
await grantAiConsent(supabase as any, 'user-1', 'enable-banking')
await grantAiConsent(supabase as any, 'user-1', 'company-1', 'enable-banking')
expect(supabase.from).not.toHaveBeenCalled()
})
})
@@ -107,13 +107,13 @@ describe('ai-consent', () => {
describe('revokeAiConsent', () => {
it('upserts revoked consent to extension_data', async () => {
mockResult({ data: null, error: null })
await revokeAiConsent(supabase as any, 'user-1', 'ai-chat')
await revokeAiConsent(supabase as any, 'user-1', 'company-1', 'ai-chat')
expect(supabase.from).toHaveBeenCalledWith('extension_data')
})
it('does nothing for non-AI extensions', async () => {
await revokeAiConsent(supabase as any, 'user-1', 'email')
await revokeAiConsent(supabase as any, 'user-1', 'company-1', 'email')
expect(supabase.from).not.toHaveBeenCalled()
})
})
@@ -30,7 +30,7 @@ beforeEach(() => {
describe('createExtensionContext', () => {
it('returns context with correct userId and extensionId', () => {
const { supabase } = createMockSupabase()
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
expect(ctx.userId).toBe('user-1')
expect(ctx.extensionId).toBe('test-ext')
@@ -38,29 +38,29 @@ describe('createExtensionContext', () => {
it('provides supabase client', () => {
const { supabase } = createMockSupabase()
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
expect(ctx.supabase).toBe(supabase)
})
it('emit() delegates to eventBus.emit()', async () => {
const { supabase } = createMockSupabase()
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
const handler = vi.fn()
eventBus.on('journal_entry.committed', handler)
await ctx.emit({
type: 'journal_entry.committed',
payload: { entry: { id: 'e1' } as never, userId: 'user-1' },
payload: { entry: { id: 'e1' } as never, userId: 'user-1', companyId: 'company-1' },
})
expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'user-1' })
expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'user-1', companyId: 'company-1' })
})
it('log methods prefix with extensionId', () => {
const { supabase } = createMockSupabase()
const ctx = createExtensionContext(supabase as never, 'user-1', 'my-ext')
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'my-ext')
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@@ -83,7 +83,7 @@ describe('createExtensionContext', () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: { value: { autoOcr: true } }, error: null })
const ctx = createExtensionContext(supabase as never, 'user-1', 'receipt-ocr')
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'receipt-ocr')
const result = await ctx.settings.get<{ autoOcr: boolean }>('settings')
expect(result).toEqual({ autoOcr: true })
@@ -94,7 +94,7 @@ describe('createExtensionContext', () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: { value: { foo: 'bar' } }, error: null })
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
const result = await ctx.settings.get<{ foo: string }>()
expect(result).toEqual({ foo: 'bar' })
@@ -104,7 +104,7 @@ describe('createExtensionContext', () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: null, error: null })
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
const result = await ctx.settings.get('missing-key')
expect(result).toBeNull()
@@ -114,7 +114,7 @@ describe('createExtensionContext', () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: null, error: null })
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
await ctx.settings.set('my-key', { value: 123 })
expect(supabase.from).toHaveBeenCalledWith('extension_data')
@@ -122,7 +122,7 @@ describe('createExtensionContext', () => {
it('storage.getPublicUrl() returns URL string', () => {
const { supabase } = createMockSupabase()
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
const url = ctx.storage.getPublicUrl('documents', 'path/to/file.pdf')
expect(typeof url).toBe('string')
@@ -130,7 +130,7 @@ describe('createExtensionContext', () => {
it('services.ingestTransactions is a function', () => {
const { supabase } = createMockSupabase()
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
expect(typeof ctx.services.ingestTransactions).toBe('function')
})
+8 -8
View File
@@ -44,11 +44,11 @@ describe('ExtensionRegistry', () => {
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
payload: { entry: { id: 'e1' } as never, userId: 'u1', companyId: 'company-1' },
})
expect(handler).toHaveBeenCalled()
expect(handler.mock.calls[0][0]).toEqual({ entry: { id: 'e1' }, userId: 'u1' })
expect(handler.mock.calls[0][0]).toEqual({ entry: { id: 'e1' }, userId: 'u1', companyId: 'company-1' })
})
it('register() skips duplicate registration (same id)', () => {
@@ -80,7 +80,7 @@ describe('ExtensionRegistry', () => {
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
payload: { entry: { id: 'e1' } as never, userId: 'u1', companyId: 'company-1' },
})
expect(handler).not.toHaveBeenCalled()
@@ -124,11 +124,11 @@ describe('ExtensionRegistry', () => {
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
payload: { entry: { id: 'e1' } as never, userId: 'u1', companyId: 'company-1' },
})
await eventBus.emit({
type: 'journal_entry.drafted',
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
payload: { entry: { id: 'e1' } as never, userId: 'u1', companyId: 'company-1' },
})
expect(handler1).not.toHaveBeenCalled()
@@ -137,7 +137,7 @@ describe('ExtensionRegistry', () => {
it('handler receives ExtensionContext when context factory is set', async () => {
const handler = vi.fn()
const mockCtx = { extensionId: 'ctx-ext', userId: 'u1' } as ExtensionContext
const mockCtx = { extensionId: 'ctx-ext', userId: 'u1', companyId: 'company-1' } as ExtensionContext
setContextFactory((_supabase, _userId, _extId) => mockCtx)
@@ -150,11 +150,11 @@ describe('ExtensionRegistry', () => {
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
payload: { entry: { id: 'e1' } as never, userId: 'u1', companyId: 'company-1' },
})
expect(handler).toHaveBeenCalledWith(
{ entry: { id: 'e1' }, userId: 'u1' },
{ entry: { id: 'e1' }, userId: 'u1', companyId: 'company-1' },
mockCtx
)
})
+6 -2
View File
@@ -54,7 +54,7 @@ export const AI_DATA_DISCLOSURES: Record<AiExtensionId, {
*/
export async function hasAiConsent(
supabase: SupabaseClient,
userId: string,
companyId: string,
extensionId: string
): Promise<boolean> {
if (!isAiExtension(extensionId)) {
@@ -64,7 +64,7 @@ export async function hasAiConsent(
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('extension_id', extensionId)
.eq('key', 'ai_consent')
.single()
@@ -81,6 +81,7 @@ export async function hasAiConsent(
export async function grantAiConsent(
supabase: SupabaseClient,
userId: string,
companyId: string,
extensionId: string
): Promise<void> {
if (!isAiExtension(extensionId)) return
@@ -90,6 +91,7 @@ export async function grantAiConsent(
.upsert(
{
user_id: userId,
company_id: companyId,
extension_id: extensionId,
key: 'ai_consent',
value: {
@@ -108,6 +110,7 @@ export async function grantAiConsent(
export async function revokeAiConsent(
supabase: SupabaseClient,
userId: string,
companyId: string,
extensionId: string
): Promise<void> {
if (!isAiExtension(extensionId)) return
@@ -117,6 +120,7 @@ export async function revokeAiConsent(
.upsert(
{
user_id: userId,
company_id: companyId,
extension_id: extensionId,
key: 'ai_consent',
value: {
+7 -3
View File
@@ -29,6 +29,7 @@ function createExtLogger(extensionId: string): ExtensionLogger {
function createSettings(
supabase: SupabaseClient,
userId: string,
companyId: string,
extensionId: string
): ExtensionSettings {
return {
@@ -37,7 +38,7 @@ function createSettings(
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('extension_id', extensionId)
.eq('key', lookupKey)
.single()
@@ -51,11 +52,12 @@ function createSettings(
.upsert(
{
user_id: userId,
company_id: companyId,
extension_id: extensionId,
key,
value,
},
{ onConflict: 'user_id,extension_id,key' }
{ onConflict: 'company_id,extension_id,key' }
)
},
}
@@ -108,14 +110,16 @@ function createServices(): ExtensionServices {
export function createExtensionContext(
supabase: SupabaseClient,
userId: string,
companyId: string,
extensionId: string
): ExtensionContext {
return {
userId,
companyId,
extensionId,
supabase,
emit: (event: CoreEvent) => eventBus.emit(event),
settings: createSettings(supabase, userId, extensionId),
settings: createSettings(supabase, userId, companyId, extensionId),
storage: createStorage(supabase),
log: createExtLogger(extensionId),
services: createServices(),
+4 -2
View File
@@ -7,6 +7,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
export type ContextFactory = (
supabase: SupabaseClient,
userId: string,
companyId: string,
extensionId: string
) => ExtensionContext
@@ -52,14 +53,15 @@ class ExtensionRegistry {
if (extension.eventHandlers) {
for (const { eventType, handler } of extension.eventHandlers) {
// Wrap handler to inject ExtensionContext as second argument
const wrappedHandler = async (payload: { userId: string; [key: string]: unknown }) => {
const wrappedHandler = async (payload: { userId: string; companyId?: string; [key: string]: unknown }) => {
let ctx: ExtensionContext | undefined
if (contextFactory && payload.userId) {
try {
// Dynamic import to avoid circular deps at module load time
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
ctx = contextFactory(supabase, payload.userId, extension.id)
const companyId = payload.companyId ?? payload.userId // fallback for legacy events
ctx = contextFactory(supabase, payload.userId, companyId, extension.id)
} catch {
// Context creation failed (e.g. no request cookies in cron jobs).
// Handler still gets called — ctx will be undefined.
+2 -1
View File
@@ -142,12 +142,13 @@ export interface ExtensionStorage {
/** Core services exposed to extensions */
export interface ExtensionServices {
ingestTransactions(supabase: SupabaseClient, userId: string, raw: RawTransaction[], options?: IngestOptions): Promise<IngestResult>
ingestTransactions(supabase: SupabaseClient, companyId: string, userId: string, raw: RawTransaction[], options?: IngestOptions): Promise<IngestResult>
}
/** Context passed to extension lifecycle hooks and event handlers */
export interface ExtensionContext {
userId: string
companyId: string
extensionId: string
supabase: SupabaseClient
emit(event: CoreEvent): Promise<void>
@@ -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
)
+4 -4
View File
@@ -119,7 +119,7 @@ function calculateMatchScore(
*/
export async function findMatchingInvoices(
supabase: SupabaseClient,
userId: string,
companyId: string,
transaction: Transaction
): Promise<InvoiceMatch[]> {
// Only match income transactions
@@ -134,7 +134,7 @@ export async function findMatchingInvoices(
*,
customer:customers(*)
`)
.eq('user_id', userId)
.eq('company_id', companyId)
.in('status', ['sent', 'overdue', 'partially_paid'])
.order('due_date', { ascending: true })
@@ -231,11 +231,11 @@ export async function findMatchingInvoices(
*/
export async function getBestInvoiceMatch(
supabase: SupabaseClient,
userId: string,
companyId: string,
transaction: Transaction,
minConfidence: number = 0.80
): Promise<InvoiceMatch | null> {
const matches = await findMatchingInvoices(supabase, userId, transaction)
const matches = await findMatchingInvoices(supabase, companyId, transaction)
if (matches.length > 0 && matches[0].confidence >= minConfidence) {
return matches[0]
+1 -1
View File
@@ -188,7 +188,7 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', invoice.user_id)
.eq('company_id', invoice.company_id)
.single()
if (companyError || !company) {
+14 -12
View File
@@ -138,7 +138,7 @@ export async function runReconciliation(
let query = supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.eq('company_id', userId)
.is('journal_entry_id', null)
.eq('currency', 'SEK')
@@ -172,7 +172,7 @@ export async function runReconciliation(
is_business: true,
})
.eq('id', match.transaction.id)
.eq('user_id', userId)
.eq('company_id', userId)
if (error) {
errors++
@@ -186,6 +186,7 @@ export async function runReconciliation(
journalEntryId: match.glLine.journal_entry_id,
method: match.method,
userId,
companyId: userId,
},
})
} catch {
@@ -217,7 +218,7 @@ export async function getReconciliationStatus(
let txQuery = supabase
.from('transactions')
.select('amount, journal_entry_id, reconciliation_method')
.eq('user_id', userId)
.eq('company_id', userId)
.eq('currency', 'SEK')
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
@@ -230,7 +231,7 @@ export async function getReconciliationStatus(
.from('journal_entry_lines')
.select('debit_amount, credit_amount, journal_entries!inner(user_id, entry_date, status)')
.eq('account_number', '1930')
.eq('journal_entries.user_id', userId)
.eq('journal_entries.company_id', userId)
.eq('journal_entries.status', 'posted')
if (dateFrom) glQuery = glQuery.gte('journal_entries.entry_date', dateFrom)
@@ -292,7 +293,7 @@ export async function manualLink(
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('user_id', userId)
.eq('company_id', userId)
.single()
if (txError || !tx) {
@@ -308,7 +309,7 @@ export async function manualLink(
.from('journal_entries')
.select('id, user_id, status')
.eq('id', journalEntryId)
.eq('user_id', userId)
.eq('company_id', userId)
.single()
if (entryError || !entry) {
@@ -335,7 +336,7 @@ export async function manualLink(
.from('transactions')
.select('id')
.eq('journal_entry_id', journalEntryId)
.eq('user_id', userId)
.eq('company_id', userId)
.single()
if (existingLink) {
@@ -351,7 +352,7 @@ export async function manualLink(
is_business: true,
})
.eq('id', transactionId)
.eq('user_id', userId)
.eq('company_id', userId)
if (updateError) {
return { success: false, error: 'Failed to link transaction' }
@@ -365,6 +366,7 @@ export async function manualLink(
journalEntryId,
method: 'manual' as ReconciliationMethod,
userId,
companyId: userId,
},
})
} catch {
@@ -388,7 +390,7 @@ export async function unlinkReconciliation(
.from('transactions')
.select('id, journal_entry_id, reconciliation_method')
.eq('id', transactionId)
.eq('user_id', userId)
.eq('company_id', userId)
.single()
if (txError || !tx) {
@@ -411,7 +413,7 @@ export async function unlinkReconciliation(
is_business: null,
})
.eq('id', transactionId)
.eq('user_id', userId)
.eq('company_id', userId)
if (updateError) {
return { success: false, error: 'Failed to unlink transaction' }
@@ -434,12 +436,12 @@ export async function unlinkReconciliation(
/** Fetch unlinked 1930 GL lines via the RPC function */
export async function fetchUnlinkedGLLines(
supabase: SupabaseClient,
userId: string,
companyId: string,
dateFrom?: string,
dateTo?: string
): Promise<UnlinkedGLLine[]> {
const { data, error } = await supabase.rpc('get_unlinked_1930_lines', {
p_user_id: userId,
p_user_id: companyId,
p_date_from: dateFrom || null,
p_date_to: dateTo || null,
})
+7 -7
View File
@@ -41,7 +41,7 @@ describe('generateARLedger', () => {
{ data: [], error: null },
]
const report = await generateARLedger(supabase, 'user-1')
const report = await generateARLedger(supabase, 'company-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
expect(report.unpaid_count).toBe(0)
@@ -52,7 +52,7 @@ describe('generateARLedger', () => {
{ data: null, error: { message: 'DB error' } },
]
const report = await generateARLedger(supabase, 'user-1')
const report = await generateARLedger(supabase, 'company-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
})
@@ -107,7 +107,7 @@ describe('generateARLedger', () => {
},
]
const report = await generateARLedger(supabase, 'user-1', asOfDate)
const report = await generateARLedger(supabase, 'company-1', asOfDate)
expect(report.unpaid_count).toBe(3)
expect(report.entries).toHaveLength(2)
@@ -150,7 +150,7 @@ describe('generateARLedger', () => {
},
]
const report = await generateARLedger(supabase, 'user-1', '2024-06-15')
const report = await generateARLedger(supabase, 'company-1', '2024-06-15')
expect(report.entries[0].invoices[0].outstanding).toBe(2500)
expect(report.total_outstanding).toBe(2500)
@@ -189,7 +189,7 @@ describe('generateARLedger', () => {
},
]
const report = await generateARLedger(supabase, 'user-1', '2024-05-15')
const report = await generateARLedger(supabase, 'company-1', '2024-05-15')
// Sorted by due_date: F001 (June 1) before F002 (July 1)
expect(report.entries[0].invoices[0].invoice_number).toBe('F001')
@@ -217,7 +217,7 @@ describe('generateARLedger', () => {
},
]
const report = await generateARLedger(supabase, 'user-1', '2024-06-15')
const report = await generateARLedger(supabase, 'company-1', '2024-06-15')
expect(report.entries[0].invoices[0].outstanding).toBe(66.77)
expect(report.total_outstanding).toBe(66.77)
})
@@ -243,7 +243,7 @@ describe('generateARLedger', () => {
},
]
const report = await generateARLedger(supabase, 'user-1', '2024-06-15')
const report = await generateARLedger(supabase, 'company-1', '2024-06-15')
expect(report.entries[0].customer_name).toBe('Okänd kund')
})
})
@@ -56,7 +56,7 @@ describe('generateARReconciliation', () => {
},
]
const result = await generateARReconciliation(supabase, 'user-1', 'period-1')
const result = await generateARReconciliation(supabase, 'company-1', 'period-1')
// AR: (5000-2000) + (3000-0) = 6000
expect(result.ar_ledger_total).toBe(6000)
@@ -85,7 +85,7 @@ describe('generateARReconciliation', () => {
},
]
const result = await generateARReconciliation(supabase, 'user-1', 'period-1')
const result = await generateARReconciliation(supabase, 'company-1', 'period-1')
expect(result.ar_ledger_total).toBe(5000)
expect(result.account_1510_balance).toBe(6000)
@@ -99,7 +99,7 @@ describe('generateARReconciliation', () => {
{ data: [], error: null },
]
const result = await generateARReconciliation(supabase, 'user-1', 'period-1')
const result = await generateARReconciliation(supabase, 'company-1', 'period-1')
expect(result.ar_ledger_total).toBe(0)
expect(result.account_1510_balance).toBe(0)
@@ -118,7 +118,7 @@ describe('generateARReconciliation', () => {
},
]
const result = await generateARReconciliation(supabase, 'user-1', 'period-1')
const result = await generateARReconciliation(supabase, 'company-1', 'period-1')
expect(result.ar_ledger_total).toBe(0)
expect(result.account_1510_balance).toBe(3000)
@@ -139,7 +139,7 @@ describe('generateARReconciliation', () => {
},
]
const result = await generateARReconciliation(supabase, 'user-1', 'period-1')
const result = await generateARReconciliation(supabase, 'company-1', 'period-1')
// Balance = debits - credits = 10000 - 4000 - 3000 = 3000
expect(result.account_1510_balance).toBe(3000)
@@ -161,7 +161,7 @@ describe('generateARReconciliation', () => {
},
]
const result = await generateARReconciliation(supabase, 'user-1', 'period-1')
const result = await generateARReconciliation(supabase, 'company-1', 'period-1')
expect(result.ar_ledger_total).toBe(66.77)
expect(result.account_1510_balance).toBe(66.77)
+8 -8
View File
@@ -41,7 +41,7 @@ describe('generateBalanceSheet', () => {
isBalanced: true,
})
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
expect(report.asset_sections).toEqual([])
expect(report.equity_liability_sections).toEqual([])
@@ -60,7 +60,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
expect(report.asset_sections).toHaveLength(2)
expect(report.asset_sections[0].title).toBe('Kundfordringar')
@@ -81,7 +81,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
expect(report.equity_liability_sections).toHaveLength(2)
expect(report.equity_liability_sections[0].title).toBe('Eget kapital')
@@ -102,7 +102,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
const bankSection = report.asset_sections.find(s => s.title === 'Kassa och bank')!
expect(bankSection.rows).toHaveLength(1)
@@ -121,7 +121,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
expect(report.asset_sections).toHaveLength(1)
expect(report.asset_sections[0].title).toBe('Kassa och bank')
@@ -138,7 +138,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
expect(report.asset_sections).toHaveLength(1)
expect(report.asset_sections[0].title).toBe('Kundfordringar')
@@ -159,7 +159,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
expect(report.asset_sections).toHaveLength(1) // Only 1930
// Class 3-8 accounts are not included as balance sheet rows, but their
@@ -184,7 +184,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
// All three are in group '19' (Kassa och bank)
const section = report.asset_sections.find(s => s.title === 'Kassa och bank')!
@@ -105,7 +105,7 @@ describe('generateFullArchive', () => {
it('generates a ZIP with expected file structure', async () => {
enqueueStandardResponses()
const buffer = await generateFullArchive(supabase as any, 'user-1', {
const buffer = await generateFullArchive(supabase as any, 'company-1', {
period_id: 'period-1',
})
@@ -170,7 +170,7 @@ describe('generateFullArchive', () => {
}),
})
const buffer = await generateFullArchive(supabase as any, 'user-1', {
const buffer = await generateFullArchive(supabase as any, 'company-1', {
period_id: 'period-1',
})
@@ -209,7 +209,7 @@ describe('generateFullArchive', () => {
},
])
const buffer = await generateFullArchive(supabase as any, 'user-1', {
const buffer = await generateFullArchive(supabase as any, 'company-1', {
period_id: 'period-1',
include_documents: false,
})
@@ -231,7 +231,7 @@ describe('generateFullArchive', () => {
enqueueMany([{ data: null }])
await expect(
generateFullArchive(supabase as any, 'user-1', { period_id: 'nonexistent' })
generateFullArchive(supabase as any, 'company-1', { period_id: 'nonexistent' })
).rejects.toThrow('Fiscal period not found')
})
})
+7 -7
View File
@@ -45,7 +45,7 @@ describe('generateGeneralLedger', () => {
fiscal_periods: [{ data: null, error: null }],
}
const report = await generateGeneralLedger(supabase, 'user-1', 'period-1')
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1')
expect(report.accounts).toEqual([])
expect(report.period).toEqual({ start: '', end: '' })
})
@@ -63,7 +63,7 @@ describe('generateGeneralLedger', () => {
],
}
const report = await generateGeneralLedger(supabase, 'user-1', 'period-1')
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' })
})
@@ -101,7 +101,7 @@ describe('generateGeneralLedger', () => {
],
}
const report = await generateGeneralLedger(supabase, 'user-1', 'period-1')
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'])
@@ -155,7 +155,7 @@ describe('generateGeneralLedger', () => {
],
}
const report = await generateGeneralLedger(supabase, 'user-1', 'period-2')
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)
@@ -186,7 +186,7 @@ describe('generateGeneralLedger', () => {
],
}
const report = await generateGeneralLedger(supabase, 'user-1', 'period-1', '1500', '1999')
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1', '1500', '1999')
// Only accounts in 1500–1999 range
expect(report.accounts.map((a) => a.account_number)).toEqual(['1510', '1930'])
@@ -215,7 +215,7 @@ describe('generateGeneralLedger', () => {
],
}
const report = await generateGeneralLedger(supabase, 'user-1', 'period-1')
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)
@@ -243,7 +243,7 @@ describe('generateGeneralLedger', () => {
],
}
const report = await generateGeneralLedger(supabase, 'user-1', 'period-1')
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)
@@ -41,7 +41,7 @@ describe('generateIncomeStatement', () => {
isBalanced: true,
})
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
expect(report.revenue_sections).toEqual([])
expect(report.expense_sections).toEqual([])
@@ -60,7 +60,7 @@ describe('generateIncomeStatement', () => {
isBalanced: false,
})
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
expect(report.revenue_sections).toHaveLength(1)
expect(report.revenue_sections[0].title).toBe('Huvudintäkter')
@@ -81,7 +81,7 @@ describe('generateIncomeStatement', () => {
isBalanced: false,
})
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
expect(report.expense_sections).toHaveLength(2)
expect(report.expense_sections[0].title).toBe('Lokalkostnader')
@@ -101,7 +101,7 @@ describe('generateIncomeStatement', () => {
isBalanced: false,
})
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
expect(report.financial_sections).toHaveLength(2)
// Financial uses credit - debit
@@ -124,7 +124,7 @@ describe('generateIncomeStatement', () => {
isBalanced: false,
})
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
expect(report.total_revenue).toBe(20000)
expect(report.total_expenses).toBe(8000)
@@ -144,7 +144,7 @@ describe('generateIncomeStatement', () => {
isBalanced: false,
})
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
// Only the significant revenue row should appear
const revenueSection = report.revenue_sections.find(s => s.title === 'Huvudintäkter')!
@@ -164,7 +164,7 @@ describe('generateIncomeStatement', () => {
isBalanced: false,
})
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
// Only the non-empty section should remain
expect(report.revenue_sections).toHaveLength(1)
@@ -183,7 +183,7 @@ describe('generateIncomeStatement', () => {
isBalanced: true,
})
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
// Only class 3 should appear
expect(report.revenue_sections).toHaveLength(1)
@@ -45,7 +45,7 @@ describe('generateJournalRegister', () => {
fiscal_periods: [{ data: null, error: null }],
}
const report = await generateJournalRegister(supabase, 'user-1', 'period-1')
const report = await generateJournalRegister(supabase, 'company-1', 'period-1')
expect(report.entries).toEqual([])
expect(report.total_entries).toBe(0)
expect(report.period).toEqual({ start: '', end: '' })
@@ -61,7 +61,7 @@ describe('generateJournalRegister', () => {
],
}
const report = await generateJournalRegister(supabase, 'user-1', 'period-1')
const report = await generateJournalRegister(supabase, 'company-1', 'period-1')
expect(report.entries).toEqual([])
expect(report.total_entries).toBe(0)
expect(report.period).toEqual({ start: '2024-01-01', end: '2024-12-31' })
@@ -97,7 +97,7 @@ describe('generateJournalRegister', () => {
],
}
const report = await generateJournalRegister(supabase, 'user-1', 'period-1')
const report = await generateJournalRegister(supabase, 'company-1', 'period-1')
expect(report.total_entries).toBe(2)
expect(report.entries[0].voucher_number).toBe(1)
@@ -139,7 +139,7 @@ describe('generateJournalRegister', () => {
],
}
const report = await generateJournalRegister(supabase, 'user-1', 'period-1')
const report = await generateJournalRegister(supabase, 'company-1', 'period-1')
expect(report.entries[0].status).toBe('reversed')
expect(report.entries[1].status).toBe('posted')
@@ -170,7 +170,7 @@ describe('generateJournalRegister', () => {
],
}
const report = await generateJournalRegister(supabase, 'user-1', 'period-1')
const report = await generateJournalRegister(supabase, 'company-1', 'period-1')
const line1930 = report.entries[0].lines.find((l) => l.account_number === '1930')!
expect(line1930.account_name).toBe('Företagskonto')
@@ -199,7 +199,7 @@ describe('generateJournalRegister', () => {
],
}
const report = await generateJournalRegister(supabase, 'user-1', 'period-1')
const report = await generateJournalRegister(supabase, 'company-1', 'period-1')
expect(report.entries[0].voucher_series).toBe('A')
})
})
@@ -13,7 +13,7 @@ describe('generateMonthlyBreakdown', () => {
it('returns empty months when no fiscal period found', async () => {
mockResult({ data: null, error: { message: 'not found' } })
const result = await generateMonthlyBreakdown(supabase, 'user-1', 'period-1')
const result = await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1')
expect(result.months).toEqual([])
})
@@ -65,7 +65,7 @@ describe('generateMonthlyBreakdown', () => {
}
})
const result = await generateMonthlyBreakdown(supabase, 'user-1', 'period-1')
const result = await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1')
expect(result.months.length).toBe(12)
expect(result.months[0].label).toBe('Jan')
expect(result.months[0].income).toBe(0)
@@ -132,7 +132,7 @@ describe('generateMonthlyBreakdown', () => {
}
})
const result = await generateMonthlyBreakdown(supabase, 'user-1', 'period-1')
const result = await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1')
// January
const jan = result.months.find((m) => m.label === 'Jan')!
@@ -211,7 +211,7 @@ describe('generateMonthlyBreakdown', () => {
}
})
const result = await generateMonthlyBreakdown(supabase, 'user-1', 'period-1')
const result = await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1')
const jan = result.months.find((m) => m.label === 'Jan')!
// Class 1 and 2 are ignored
// Class 8 debit (8400 interest expense) → expense
+12 -12
View File
@@ -49,7 +49,7 @@ describe('generateSIEExport', () => {
{ data: null, error: null },
]
await expect(generateSIEExport(supabase, 'user-1', baseOptions))
await expect(generateSIEExport(supabase, 'company-1', baseOptions))
.rejects.toThrow('Fiscal period not found')
})
@@ -67,7 +67,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
const lines = output.split('\r\n')
expect(lines[0]).toBe('#FLAGGA 0')
@@ -89,7 +89,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport(supabase, 'user-1', {
const output = await generateSIEExport(supabase, 'company-1', {
...baseOptions,
org_number: null,
})
@@ -116,7 +116,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).toContain('#KONTO 1930 "Företagskonto"')
expect(output).toContain('#SRU 1930 7301')
@@ -155,7 +155,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).toContain('#VER "A" 1 20240315 "Sale invoice"')
expect(output).toContain('{')
@@ -186,7 +186,7 @@ describe('generateSIEExport', () => {
},
]
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).toContain('#DIM 1 "Kostnadsställe"')
expect(output).toContain('#DIM 6 "Projekt"')
@@ -219,7 +219,7 @@ describe('generateSIEExport', () => {
{ data: [{ code: 'P001', name: 'Projekt Alpha', is_active: true }], error: null },
]
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).toContain('\t#TRANS 5010 {1 "CC1" 6 "P001"} 8000.00 20240315')
expect(output).toContain('\t#TRANS 1930 {} -8000.00 20240315')
@@ -251,7 +251,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
// Account 1510 (class 1) → #UB, balance = 1250 - 0 = 1250
expect(output).toContain('#UB 0 1510 1250.00')
@@ -286,7 +286,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).toContain('#VER "A" 1 20240115 "Invoice for \\"consulting\\""')
})
@@ -300,7 +300,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
// Every line should end with \r\n
expect(output).toContain('\r\n')
@@ -322,7 +322,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).not.toContain('#VER')
expect(output).not.toContain('#TRANS')
@@ -337,7 +337,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).not.toContain('#DIM')
expect(output).not.toContain('#OBJEKT')
@@ -41,7 +41,7 @@ describe('generateSupplierLedger', () => {
{ data: [], error: null },
]
const report = await generateSupplierLedger(supabase, 'user-1')
const report = await generateSupplierLedger(supabase, 'company-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
expect(report.total_current).toBe(0)
@@ -54,7 +54,7 @@ describe('generateSupplierLedger', () => {
{ data: null, error: { message: 'DB error' } },
]
const report = await generateSupplierLedger(supabase, 'user-1')
const report = await generateSupplierLedger(supabase, 'company-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
})
@@ -106,7 +106,7 @@ describe('generateSupplierLedger', () => {
},
]
const report = await generateSupplierLedger(supabase, 'user-1', asOfDate)
const report = await generateSupplierLedger(supabase, 'company-1', asOfDate)
expect(report.entries).toHaveLength(1)
const entry = report.entries[0]
@@ -139,7 +139,7 @@ describe('generateSupplierLedger', () => {
},
]
const report = await generateSupplierLedger(supabase, 'user-1', '2024-06-15')
const report = await generateSupplierLedger(supabase, 'company-1', '2024-06-15')
expect(report.entries).toHaveLength(2)
const names = report.entries.map(e => e.supplier_name)
@@ -174,7 +174,7 @@ describe('generateSupplierLedger', () => {
},
]
const report = await generateSupplierLedger(supabase, 'user-1', '2024-06-15')
const report = await generateSupplierLedger(supabase, 'company-1', '2024-06-15')
expect(report.entries[0].supplier_name).toBe('Large')
expect(report.entries[1].supplier_name).toBe('Medium')
@@ -204,7 +204,7 @@ describe('generateSupplierLedger', () => {
},
]
const report = await generateSupplierLedger(supabase, 'user-1', '2024-06-15')
const report = await generateSupplierLedger(supabase, 'company-1', '2024-06-15')
expect(report.total_outstanding).toBe(8000)
expect(report.total_current).toBe(5000)
@@ -239,7 +239,7 @@ describe('generateSupplierLedger', () => {
},
]
const report = await generateSupplierLedger(supabase, 'user-1', '2024-06-15')
const report = await generateSupplierLedger(supabase, 'company-1', '2024-06-15')
expect(report.total_outstanding).toBe(100)
expect(report.total_current).toBe(100)
@@ -56,7 +56,7 @@ describe('generateReconciliation', () => {
},
]
const result = await generateReconciliation(supabase, 'user-1', 'period-1')
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
// Supplier total: 5000 + 3000 = 8000
expect(result.supplier_ledger_total).toBe(8000)
@@ -84,7 +84,7 @@ describe('generateReconciliation', () => {
},
]
const result = await generateReconciliation(supabase, 'user-1', 'period-1')
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
expect(result.supplier_ledger_total).toBe(5000)
expect(result.account_2440_balance).toBe(7000)
@@ -98,7 +98,7 @@ describe('generateReconciliation', () => {
{ data: [], error: null },
]
const result = await generateReconciliation(supabase, 'user-1', 'period-1')
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
expect(result.supplier_ledger_total).toBe(0)
expect(result.account_2440_balance).toBe(0)
@@ -117,7 +117,7 @@ describe('generateReconciliation', () => {
},
]
const result = await generateReconciliation(supabase, 'user-1', 'period-1')
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
expect(result.supplier_ledger_total).toBe(0)
expect(result.account_2440_balance).toBe(3000)
@@ -138,7 +138,7 @@ describe('generateReconciliation', () => {
},
]
const result = await generateReconciliation(supabase, 'user-1', 'period-1')
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
// Balance = credits - debits = 15000 - 5000 - 3000 = 7000
expect(result.account_2440_balance).toBe(7000)
@@ -161,7 +161,7 @@ describe('generateReconciliation', () => {
},
]
const result = await generateReconciliation(supabase, 'user-1', 'period-1')
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
expect(result.supplier_ledger_total).toBe(66.67)
expect(result.account_2440_balance).toBe(66.67)
+10 -10
View File
@@ -54,7 +54,7 @@ describe('generateTrialBalance', () => {
],
}
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
expect(result.rows).toEqual([])
expect(result.totalDebit).toBe(0)
@@ -92,7 +92,7 @@ describe('generateTrialBalance', () => {
],
}
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
expect(result.rows).toHaveLength(2)
// Sorted by account number
@@ -144,7 +144,7 @@ describe('generateTrialBalance', () => {
],
}
const result = await generateTrialBalance(supabase, 'user-1', 'period-2')
const result = await generateTrialBalance(supabase, 'company-1', 'period-2')
// 1930: opening debit 10000, period credit 500 → closing debit 10000, credit 500
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
@@ -207,7 +207,7 @@ describe('generateTrialBalance', () => {
],
}
const result = await generateTrialBalance(supabase, 'user-1', 'period-2')
const result = await generateTrialBalance(supabase, 'company-1', 'period-2')
// 1930: opening 8000 debit + period 1000 debit = closing 9000 debit
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
@@ -245,7 +245,7 @@ describe('generateTrialBalance', () => {
],
}
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
expect(result.rows[0].account_name).toBe('Konto 9999')
})
@@ -269,7 +269,7 @@ describe('generateTrialBalance', () => {
],
}
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
expect(result.rows[0].account_class).toBe(5)
})
@@ -302,7 +302,7 @@ describe('generateTrialBalance', () => {
],
}
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
expect(result.rows[0].closing_debit).toBe(100)
expect(result.totalDebit).toBe(100)
@@ -336,7 +336,7 @@ describe('generateTrialBalance', () => {
],
}
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
expect(result.totalDebit).toBe(1000)
expect(result.totalCredit).toBe(999)
@@ -354,7 +354,7 @@ describe('generateTrialBalance', () => {
],
}
await expect(generateTrialBalance(supabase, 'user-1', 'period-1')).rejects.toThrow('DB error')
await expect(generateTrialBalance(supabase, 'company-1', 'period-1')).rejects.toThrow('DB error')
})
it('handles balanced two-account entry', async () => {
@@ -383,7 +383,7 @@ describe('generateTrialBalance', () => {
],
}
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
expect(result.rows).toHaveLength(2)
expect(result.totalDebit).toBe(5000)
+18 -18
View File
@@ -197,7 +197,7 @@ describe('calculateVatDeclaration', () => {
{ data: [], error: null }, // entry counts
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
expect(result.rutor.ruta10).toBe(0)
@@ -229,7 +229,7 @@ describe('calculateVatDeclaration', () => {
{ data: [{ source_type: 'invoice_created' }, { source_type: 'invoice_created' }], error: null },
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Output VAT in ruta 10/11/12
expect(result.rutor.ruta10).toBe(2500)
@@ -257,7 +257,7 @@ describe('calculateVatDeclaration', () => {
{ data: [{ source_type: 'bank_transaction' }, { source_type: 'bank_transaction' }], error: null },
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta48).toBe(370)
expect(result.transactionCount).toBe(2)
@@ -276,7 +276,7 @@ describe('calculateVatDeclaration', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Both 2641 and 2645 debit balances sum into ruta48
expect(result.rutor.ruta48).toBe(700)
@@ -295,7 +295,7 @@ describe('calculateVatDeclaration', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta39).toBe(8000)
expect(result.rutor.ruta40).toBe(12000)
@@ -318,7 +318,7 @@ describe('calculateVatDeclaration', () => {
{ data: [{ source_type: 'invoice_created' }, { source_type: 'credit_note' }], error: null },
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Net: 2500 - 625 = 1875 output VAT in ruta10, 10000 - 2500 = 7500 revenue in ruta05
expect(result.rutor.ruta10).toBe(1875)
@@ -340,7 +340,7 @@ describe('calculateVatDeclaration', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta05).toBe(10000)
@@ -361,7 +361,7 @@ describe('calculateVatDeclaration', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta49).toBe(-2500) // 500 - 3000
})
@@ -374,7 +374,7 @@ describe('calculateVatDeclaration', () => {
]
// Should not throw — parameter accepted but not used
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1, 'cash')
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1, 'cash')
expect(result.rutor.ruta49).toBe(0)
})
@@ -400,7 +400,7 @@ describe('calculateVatDeclaration', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'quarterly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'quarterly', 2024, 1)
// Output VAT in ruta 10/11/12
expect(result.rutor.ruta10).toBe(2500)
@@ -436,7 +436,7 @@ describe('calculateVatDeclaration — reverse charge', () => {
{ data: [], error: null }, // entry counts
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta30).toBe(1250)
expect(result.rutor.ruta31).toBe(120)
@@ -465,7 +465,7 @@ describe('calculateVatDeclaration — reverse charge', () => {
{ data: [], error: null }, // entry counts
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta30).toBe(500)
@@ -510,7 +510,7 @@ describe('calculateVatDeclaration — reverse charge', () => {
{ data: [], error: null }, // entry counts
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta21).toBe(5000)
expect(result.rutor.ruta20).toBe(0)
@@ -554,7 +554,7 @@ describe('calculateVatDeclaration — reverse charge', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Uses subtotal_sek when available
expect(result.rutor.ruta22).toBe(3000)
@@ -595,7 +595,7 @@ describe('calculateVatDeclaration — reverse charge', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta24).toBe(2000)
expect(result.rutor.ruta21).toBe(0)
@@ -615,7 +615,7 @@ describe('calculateVatDeclaration — reverse charge', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta20).toBe(0)
expect(result.rutor.ruta21).toBe(0)
@@ -674,7 +674,7 @@ describe('calculateVatDeclaration — reverse charge', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// 5000 - 1000 = 4000 net base for EU services
expect(result.rutor.ruta21).toBe(4000)
@@ -718,7 +718,7 @@ describe('calculateVatDeclaration — reverse charge', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Only the posted entry's invoice (5000) should count
expect(result.rutor.ruta21).toBe(5000)
+2 -2
View File
@@ -38,7 +38,7 @@ export interface ARLedgerReport {
*/
export async function generateARLedger(
supabase: SupabaseClient,
userId: string,
companyId: string,
asOfDate?: string
): Promise<ARLedgerReport> {
const refDate = asOfDate ? new Date(asOfDate) : new Date()
@@ -47,7 +47,7 @@ export async function generateARLedger(
const { data: invoices, error } = await supabase
.from('invoices')
.select('*, customer:customers(id, name)')
.eq('user_id', userId)
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
if (error || !invoices) {
+4 -4
View File
@@ -13,7 +13,7 @@ export interface ARReconciliationResult {
*/
export async function generateARReconciliation(
supabase: SupabaseClient,
userId: string,
companyId: string,
periodId: string
): Promise<ARReconciliationResult> {
@@ -21,7 +21,7 @@ export async function generateARReconciliation(
const { data: invoices } = await supabase
.from('invoices')
.select('total, paid_amount')
.eq('user_id', userId)
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
const arLedgerTotal = (invoices || [])
@@ -35,12 +35,12 @@ export async function generateARReconciliation(
credit_amount,
journal_entry:journal_entries!inner(
status,
user_id,
company_id,
fiscal_period_id
)
`)
.eq('account_number', '1510')
.eq('journal_entries.user_id', userId)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.eq('journal_entries.status', 'posted')
+2 -2
View File
@@ -11,10 +11,10 @@ import type { BalanceSheetReport, BalanceSheetSection, TrialBalanceRow } from '@
*/
export async function generateBalanceSheet(
supabase: SupabaseClient,
userId: string,
companyId: string,
fiscalPeriodId: string
): Promise<BalanceSheetReport> {
const { rows } = await generateTrialBalance(supabase, userId, fiscalPeriodId)
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
// Filter to balance sheet accounts (class 1-2)
const balanceRows = rows.filter(
+13 -13
View File
@@ -30,7 +30,7 @@ interface DocumentManifestEntry {
*/
export async function generateFullArchive(
supabase: SupabaseClient,
userId: string,
companyId: string,
options: FullArchiveOptions
): Promise<ArrayBuffer> {
const { period_id, include_documents = true } = options
@@ -40,7 +40,7 @@ export async function generateFullArchive(
.from('fiscal_periods')
.select('*')
.eq('id', period_id)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (!period) {
@@ -51,7 +51,7 @@ export async function generateFullArchive(
const { data: company } = await supabase
.from('company_settings')
.select('company_name, org_number, moms_period')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (!company) {
@@ -61,7 +61,7 @@ export async function generateFullArchive(
const zip = new JSZip()
// 1. SIE4 export
const sieContent = await generateSIEExport(supabase, userId, {
const sieContent = await generateSIEExport(supabase, companyId, {
fiscal_period_id: period_id,
company_name: company.company_name || 'Unknown',
org_number: company.org_number,
@@ -74,11 +74,11 @@ export async function generateFullArchive(
const [trialBalance, incomeStatement, balanceSheet, generalLedger, journalRegister] =
await Promise.all([
generateTrialBalance(supabase, userId, period_id),
generateIncomeStatement(supabase, userId, period_id),
generateBalanceSheet(supabase, userId, period_id),
generateGeneralLedger(supabase, userId, period_id),
generateJournalRegister(supabase, userId, period_id),
generateTrialBalance(supabase, companyId, period_id),
generateIncomeStatement(supabase, companyId, period_id),
generateBalanceSheet(supabase, companyId, period_id),
generateGeneralLedger(supabase, companyId, period_id),
generateJournalRegister(supabase, companyId, period_id),
])
rapporter.file('saldobalans.json', JSON.stringify(trialBalance, null, 2))
@@ -92,7 +92,7 @@ export async function generateFullArchive(
const startDate = new Date(period.period_start)
const vatDeclaration = await calculateVatDeclaration(
supabase,
userId,
companyId,
'yearly',
startDate.getFullYear(),
1
@@ -111,7 +111,7 @@ export async function generateFullArchive(
const { data: documents } = await supabase
.from('document_attachments')
.select('id, file_name, storage_path, journal_entry_id')
.eq('user_id', userId)
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
if (documents && documents.length > 0) {
@@ -119,7 +119,7 @@ export async function generateFullArchive(
const { data: periodEntryIds } = await supabase
.from('journal_entries')
.select('id')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('fiscal_period_id', period_id)
.in('status', ['posted', 'reversed'])
@@ -172,7 +172,7 @@ export async function generateFullArchive(
const pageSize = 500
while (true) {
const result = await getAuditLog(supabase, userId, {
const result = await getAuditLog(supabase, companyId, {
from_date: period.period_start,
to_date: period.period_end,
page,
+6 -6
View File
@@ -46,7 +46,7 @@ export interface GeneralLedgerReport {
*/
export async function generateGeneralLedger(
supabase: SupabaseClient,
userId: string,
companyId: string,
periodId: string,
accountFrom?: string,
accountTo?: string
@@ -57,7 +57,7 @@ export async function generateGeneralLedger(
.from('fiscal_periods')
.select('period_start, period_end, opening_balance_entry_id')
.eq('id', periodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (!period) {
@@ -66,7 +66,7 @@ export async function generateGeneralLedger(
// ── Opening balances (IB) ──────────────────────────────────────
const { balances: openingByAccount, obEntryId } = await getOpeningBalances(
supabase, userId, period
supabase, companyId, period
)
// Convert to net balance (debit - credit) for GL running balance
@@ -97,8 +97,8 @@ export async function generateGeneralLedger(
}>(({ from, to }) => {
let query = supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry_id, journal_entries!inner(entry_date, voucher_number, voucher_series, description, source_type, user_id, fiscal_period_id, status)')
.eq('journal_entries.user_id', userId)
.select('account_number, debit_amount, credit_amount, journal_entry_id, journal_entries!inner(entry_date, voucher_number, voucher_series, description, source_type, company_id, fiscal_period_id, status)')
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
@@ -119,7 +119,7 @@ export async function generateGeneralLedger(
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.eq('company_id', companyId)
.range(from, to)
)
+2 -2
View File
@@ -13,10 +13,10 @@ import type { IncomeStatementReport, IncomeStatementSection, TrialBalanceRow } f
*/
export async function generateIncomeStatement(
supabase: SupabaseClient,
userId: string,
companyId: string,
fiscalPeriodId: string
): Promise<IncomeStatementReport> {
const { rows } = await generateTrialBalance(supabase, userId, fiscalPeriodId)
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
// Filter to income/expense accounts (class 3-8)
const incomeExpenseRows = rows.filter(
+5 -5
View File
@@ -199,7 +199,7 @@ function roundToKrona(value: number): number {
*/
export async function generateINK2Declaration(
supabase: SupabaseClient,
userId: string,
companyId: string,
fiscalPeriodId: string
): Promise<INK2Declaration> {
@@ -208,7 +208,7 @@ export async function generateINK2Declaration(
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
@@ -219,7 +219,7 @@ export async function generateINK2Declaration(
const { data: settings } = await supabase
.from('company_settings')
.select('company_name, org_number, entity_type')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
// Validate entity type
@@ -231,7 +231,7 @@ export async function generateINK2Declaration(
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
@@ -244,7 +244,7 @@ export async function generateINK2Declaration(
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.eq('company_id', companyId)
.range(from, to)
)
+5 -5
View File
@@ -41,7 +41,7 @@ export interface JournalRegisterReport {
*/
export async function generateJournalRegister(
supabase: SupabaseClient,
userId: string,
companyId: string,
periodId: string
): Promise<JournalRegisterReport> {
@@ -50,7 +50,7 @@ export async function generateJournalRegister(
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (!period) {
@@ -78,8 +78,8 @@ export async function generateJournalRegister(
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry_id, journal_entries!inner(id, entry_date, voucher_number, voucher_series, description, source_type, status, user_id, fiscal_period_id)')
.eq('journal_entries.user_id', userId)
.select('account_number, debit_amount, credit_amount, journal_entry_id, journal_entries!inner(id, entry_date, voucher_number, voucher_series, description, source_type, status, company_id, fiscal_period_id)')
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -95,7 +95,7 @@ export async function generateJournalRegister(
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.eq('company_id', companyId)
.range(from, to)
)
+5 -5
View File
@@ -25,7 +25,7 @@ const MONTH_LABELS = [
*/
export async function generateMonthlyBreakdown(
supabase: SupabaseClient,
userId: string,
companyId: string,
fiscalPeriodId: string
): Promise<MonthlyBreakdown> {
@@ -34,7 +34,7 @@ export async function generateMonthlyBreakdown(
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
@@ -51,12 +51,12 @@ export async function generateMonthlyBreakdown(
journal_entry:journal_entries!inner(
entry_date,
status,
user_id,
company_id,
fiscal_period_id
)
`)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.eq('journal_entries.user_id', userId)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.status', 'posted')
if (linesError || !lines) {
@@ -84,7 +84,7 @@ export async function generateMonthlyBreakdown(
const entry = line.journal_entry as unknown as {
entry_date: string
status: string
user_id: string
company_id: string
fiscal_period_id: string
}
const accountClass = parseInt(line.account_number.charAt(0))
+5 -5
View File
@@ -156,7 +156,7 @@ function roundToKrona(value: number): number {
*/
export async function generateNEDeclaration(
supabase: SupabaseClient,
userId: string,
companyId: string,
fiscalPeriodId: string
): Promise<NEDeclaration> {
@@ -165,7 +165,7 @@ export async function generateNEDeclaration(
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
@@ -176,7 +176,7 @@ export async function generateNEDeclaration(
const { data: settings } = await supabase
.from('company_settings')
.select('company_name, org_number, entity_type')
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
// Check if it's enskild firma
@@ -188,7 +188,7 @@ export async function generateNEDeclaration(
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
@@ -201,7 +201,7 @@ export async function generateNEDeclaration(
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.eq('company_id', companyId)
.range(from, to)
)
+6 -6
View File
@@ -20,7 +20,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
*/
export async function getOpeningBalances(
supabase: SupabaseClient,
userId: string,
companyId: string,
period: { period_start: string; opening_balance_entry_id: string | null } | null
): Promise<{
balances: Map<string, { debit: number; credit: number }>
@@ -38,7 +38,7 @@ export async function getOpeningBalances(
// Use the explicit opening balance entry (set by year-end closing).
// Typically ~50 rows — one per balance sheet account. Uses fetchAllRows
// for consistency (avoids silent truncation) and joins journal_entries
// to enforce user_id ownership (defense in depth alongside RLS).
// to enforce company_id ownership (defense in depth alongside RLS).
const obLines = await fetchAllRows<{
account_number: string
debit_amount: number
@@ -46,9 +46,9 @@ export async function getOpeningBalances(
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(user_id)')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id)')
.eq('journal_entry_id', obEntryId)
.eq('journal_entries.user_id', userId)
.eq('journal_entries.company_id', companyId)
.range(from, to)
)
@@ -69,8 +69,8 @@ export async function getOpeningBalances(
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(user_id, status, entry_date)')
.eq('journal_entries.user_id', userId)
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, status, entry_date)')
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
.lt('journal_entries.entry_date', period.period_start)
.range(from, to)
+7 -7
View File
@@ -13,7 +13,7 @@ import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } fro
*/
export async function generateSIEExport(
supabase: SupabaseClient,
userId: string,
companyId: string,
options: SIEExportOptions
): Promise<string> {
@@ -22,7 +22,7 @@ export async function generateSIEExport(
.from('fiscal_periods')
.select('*')
.eq('id', options.fiscal_period_id)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (!period) {
@@ -34,7 +34,7 @@ export async function generateSIEExport(
supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('is_active', true)
.order('account_number')
.range(from, to)
@@ -44,7 +44,7 @@ export async function generateSIEExport(
const { data: entries } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('fiscal_period_id', options.fiscal_period_id)
.in('status', ['posted', 'reversed'])
.order('voucher_number')
@@ -53,14 +53,14 @@ export async function generateSIEExport(
const { data: costCenters } = await supabase
.from('cost_centers')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('is_active', true)
.order('code')
const { data: projects } = await supabase
.from('projects')
.select('*')
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('is_active', true)
.order('code')
@@ -121,7 +121,7 @@ export async function generateSIEExport(
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', period.opening_balance_entry_id)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
if (obEntry?.lines) {
+2 -2
View File
@@ -24,7 +24,7 @@ export interface SupplierLedgerReport {
*/
export async function generateSupplierLedger(
supabase: SupabaseClient,
userId: string,
companyId: string,
asOfDate?: string
): Promise<SupplierLedgerReport> {
const refDate = asOfDate ? new Date(asOfDate) : new Date()
@@ -33,7 +33,7 @@ export async function generateSupplierLedger(
const { data: invoices, error } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(id, name)')
.eq('user_id', userId)
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
if (error || !invoices) {
+4 -4
View File
@@ -12,7 +12,7 @@ export interface ReconciliationResult {
*/
export async function generateReconciliation(
supabase: SupabaseClient,
userId: string,
companyId: string,
periodId: string
): Promise<ReconciliationResult> {
@@ -20,7 +20,7 @@ export async function generateReconciliation(
const { data: invoices } = await supabase
.from('supplier_invoices')
.select('remaining_amount')
.eq('user_id', userId)
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
const supplierLedgerTotal = (invoices || [])
@@ -34,12 +34,12 @@ export async function generateReconciliation(
credit_amount,
journal_entry:journal_entries!inner(
status,
user_id,
company_id,
fiscal_period_id
)
`)
.eq('account_number', '2440')
.eq('journal_entries.user_id', userId)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.eq('journal_entries.status', 'posted')
+6 -6
View File
@@ -15,7 +15,7 @@ import type { TrialBalanceRow } from '@/types'
*/
export async function generateTrialBalance(
supabase: SupabaseClient,
userId: string,
companyId: string,
fiscalPeriodId: string
): Promise<{
rows: TrialBalanceRow[]
@@ -29,12 +29,12 @@ export async function generateTrialBalance(
.from('fiscal_periods')
.select('period_start, opening_balance_entry_id')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.eq('company_id', companyId)
.single()
// ── Opening balances (IB) ──────────────────────────────────────
const { balances: openingBalances, obEntryId } = await getOpeningBalances(
supabase, userId, period
supabase, companyId, period
)
// ── Period lines (excluding opening balance entry) ─────────────
@@ -51,8 +51,8 @@ export async function generateTrialBalance(
}>(({ from, to }) => {
let query = supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(user_id, fiscal_period_id, status)')
.eq('journal_entries.user_id', userId)
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status)')
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.in('journal_entries.status', ['posted', 'reversed'])
@@ -76,7 +76,7 @@ export async function generateTrialBalance(
supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class')
.eq('user_id', userId)
.eq('company_id', companyId)
.range(from, to)
)
+8 -8
View File
@@ -123,7 +123,7 @@ function round(value: number): number {
*/
export async function calculateVatDeclaration(
supabase: SupabaseClient,
userId: string,
companyId: string,
periodType: VatPeriodType,
year: number,
period: number,
@@ -143,10 +143,10 @@ export async function calculateVatDeclaration(
account_number,
debit_amount,
credit_amount,
journal_entries!inner (user_id, entry_date, status)
journal_entries!inner (company_id, entry_date, status)
`)
.in('account_number', VAT_ACCOUNTS)
.eq('journal_entries.user_id', userId)
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
.gte('journal_entries.entry_date', start)
.lte('journal_entries.entry_date', end)
@@ -203,7 +203,7 @@ export async function calculateVatDeclaration(
}
// Calculate reverse charge purchase bases (ruta 20-24) from supplier invoices
const rcBases = await calculateReverseChargeBases(supabase, userId, start, end)
const rcBases = await calculateReverseChargeBases(supabase, companyId, start, end)
rutor.ruta20 = rcBases.ruta20
rutor.ruta21 = rcBases.ruta21
rutor.ruta22 = rcBases.ruta22
@@ -214,7 +214,7 @@ export async function calculateVatDeclaration(
const { data: entryCounts } = await supabase
.from('journal_entries')
.select('source_type')
.eq('user_id', userId)
.eq('company_id', companyId)
.in('status', ['posted', 'reversed'])
.gte('entry_date', start)
.lte('entry_date', end)
@@ -279,7 +279,7 @@ export async function calculateVatDeclaration(
*/
async function calculateReverseChargeBases(
supabase: SupabaseClient,
userId: string,
companyId: string,
start: string,
end: string,
): Promise<{ ruta20: number; ruta21: number; ruta22: number; ruta23: number; ruta24: number }> {
@@ -298,7 +298,7 @@ async function calculateReverseChargeBases(
supabase
.from('journal_entries')
.select('id, source_id')
.eq('user_id', userId)
.eq('company_id', companyId)
.in('source_type', supplierSourceTypes)
.eq('status', 'posted')
.gte('entry_date', start)
@@ -319,7 +319,7 @@ async function calculateReverseChargeBases(
.select('id, supplier_id, reverse_charge, is_credit_note, subtotal_sek, subtotal, currency, exchange_rate, suppliers!inner(supplier_type)')
.in('id', sourceIds)
.eq('reverse_charge', true)
.eq('user_id', userId)
.eq('company_id', companyId)
.range(from, to)
)
+1 -1
View File
@@ -12,7 +12,7 @@ const PAGE_SIZE = 1000
* supabase
* .from('chart_of_accounts')
* .select('account_number, account_name')
* .eq('user_id', userId)
* .eq('company_id', companyId)
* .range(from, to)
* )
* ```
+108 -7
View File
@@ -55,14 +55,20 @@ export async function updateSession(request: NextRequest) {
pathname.startsWith('/register') ||
pathname.startsWith('/auth') ||
pathname.startsWith('/reset-password') ||
pathname.startsWith('/sandbox')
pathname.startsWith('/sandbox') ||
pathname.startsWith('/invite')
) {
// If user is logged in and trying to access auth pages, redirect to dashboard or onboarding
if (user) {
const companyId = await resolveCompanyForMiddleware(supabase, user.id, request)
if (!companyId) {
return NextResponse.redirect(new URL('/onboarding', request.url))
}
const { data: settings } = await supabase
.from('company_settings')
.select('onboarding_complete')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
if (!settings?.onboarding_complete) {
@@ -107,15 +113,58 @@ export async function updateSession(request: NextRequest) {
}
}
// Company context resolution
const companyId = await resolveCompanyForMiddleware(supabase, user.id, request)
// No companies at all
if (!companyId) {
// Check if user is in a team (consultant without companies yet).
// Team members should reach the dashboard (which shows an empty state)
// rather than being forced through company onboarding.
const { data: teamMember } = await supabase
.from('team_members')
.select('id')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
if (teamMember) {
// Consultant with a team but no companies — allow dashboard access
if (pathname.startsWith('/onboarding')) {
return NextResponse.redirect(new URL('/', request.url))
}
return supabaseResponse
}
// No team, no companies → redirect to onboarding
if (pathname.startsWith('/onboarding')) {
return supabaseResponse
}
return NextResponse.redirect(new URL('/onboarding', request.url))
}
// Set company cookie on the response so downstream requests have it
supabaseResponse.cookies.set('gnubok-company-id', companyId, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
})
// Select-company page — allow access (for switching companies)
if (pathname.startsWith('/select-company') || pathname.startsWith('/companies/new')) {
return supabaseResponse
}
// Onboarding route - only accessible if not complete
if (pathname.startsWith('/onboarding')) {
const { data: settings } = await supabase
.from('company_settings')
.select('onboarding_complete')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
// If onboarding is complete, redirect to dashboard
if (settings?.onboarding_complete) {
return NextResponse.redirect(new URL('/', request.url))
}
@@ -123,17 +172,69 @@ export async function updateSession(request: NextRequest) {
return supabaseResponse
}
// Dashboard routes - require completed onboarding
// Dashboard routes - require completed onboarding for active company
const { data: settings } = await supabase
.from('company_settings')
.select('onboarding_complete')
.eq('user_id', user.id)
.eq('company_id', companyId)
.single()
// If no settings or onboarding not complete, redirect to onboarding
if (!settings?.onboarding_complete) {
return NextResponse.redirect(new URL('/onboarding', request.url))
}
return supabaseResponse
}
/**
* Resolve the active company for the authenticated user.
* Uses cookie → user_preferences → first membership as fallback.
* Cannot use lib/company/context.ts because middleware runs on Edge.
*/
async function resolveCompanyForMiddleware(
supabase: ReturnType<typeof createServerClient>,
userId: string,
request: NextRequest
): Promise<string | null> {
// 1. Try cookie
const cookieCompanyId = request.cookies.get('gnubok-company-id')?.value
if (cookieCompanyId) {
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('company_id', cookieCompanyId)
.eq('user_id', userId)
.single()
if (membership) return membership.company_id
}
// 2. Try user_preferences
const { data: prefs } = await supabase
.from('user_preferences')
.select('active_company_id')
.eq('user_id', userId)
.single()
if (prefs?.active_company_id) {
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('company_id', prefs.active_company_id)
.eq('user_id', userId)
.single()
if (membership) return membership.company_id
}
// 3. Fallback: first company membership
const { data: firstCompany } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', userId)
.order('created_at', { ascending: true })
.limit(1)
.single()
return firstCompany?.company_id ?? null
}
+8 -19
View File
@@ -36,27 +36,16 @@ export async function createClient() {
)
}
export async function createServiceClient() {
const cookieStore = await cookies()
export function createServiceClient() {
// Stateless service-role client — no cookies.
// Passing user session cookies causes @supabase/ssr to send the
// user's JWT as the Authorization header, which overrides the
// service role key and re-enables RLS. A cookie-less client
// ensures the service role key is used for authorization,
// properly bypassing RLS on every query.
return createServerClient(
safeUrl,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Ignore in Server Components
}
},
},
}
{ cookies: { getAll: () => [], setAll: () => {} } }
)
}
+10 -10
View File
@@ -56,7 +56,7 @@ function formatDateISO(date: Date): string {
*/
export async function generateTaxDeadlinesForUser(
supabase: SupabaseClient,
userId: string,
companyId: string,
settings: CompanySettingsForDeadlines,
years: number[] = []
): Promise<{ created: number; deleted: number }> {
@@ -76,7 +76,7 @@ export async function generateTaxDeadlinesForUser(
const { data: deletedData, error: deleteError } = await supabase
.from('deadlines')
.delete()
.eq('user_id', userId)
.eq('company_id', companyId)
.eq('source', 'system')
.gte('due_date', startDate)
.lte('due_date', endDate)
@@ -91,7 +91,7 @@ export async function generateTaxDeadlinesForUser(
// Generate new deadlines
const deadlines: Array<{
user_id: string
company_id: string
title: string
due_date: string
deadline_type: 'tax'
@@ -137,7 +137,7 @@ export async function generateTaxDeadlinesForUser(
const linkedReportPeriod = createLinkedReportPeriod(instance, config.type)
deadlines.push({
user_id: userId,
company_id: companyId,
title,
due_date: dueDate,
deadline_type: 'tax',
@@ -215,11 +215,11 @@ function createLinkedReportPeriod(
*/
export async function regenerateTaxDeadlinesForUser(
supabase: SupabaseClient,
userId: string,
companyId: string,
newSettings: CompanySettingsForDeadlines
): Promise<{ created: number; deleted: number }> {
const currentYear = new Date().getFullYear()
return generateTaxDeadlinesForUser(supabase, userId, newSettings, [currentYear, currentYear + 1])
return generateTaxDeadlinesForUser(supabase, companyId, newSettings, [currentYear, currentYear + 1])
}
/**
@@ -230,10 +230,10 @@ export async function generateNewYearDeadlines(
): Promise<{ usersProcessed: number; totalCreated: number }> {
const newYear = new Date().getFullYear()
// Fetch all users with company settings
// Fetch all companies with company settings
const { data: allSettings, error } = await supabase
.from('company_settings')
.select('user_id, entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month')
.select('company_id, entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month')
if (error) {
log.error('Error fetching company settings:', error)
@@ -247,7 +247,7 @@ export async function generateNewYearDeadlines(
try {
const result = await generateTaxDeadlinesForUser(
supabase,
settings.user_id,
settings.company_id,
{
entity_type: settings.entity_type,
moms_period: settings.moms_period,
@@ -261,7 +261,7 @@ export async function generateNewYearDeadlines(
usersProcessed++
totalCreated += result.created
} catch (err) {
log.error(`Error generating deadlines for user ${settings.user_id}:`, err)
log.error(`Error generating deadlines for company ${settings.company_id}:`, err)
}
}
+23 -20
View File
@@ -78,6 +78,7 @@ function createQueueMockSupabase() {
// ---------------------------------------------------------------------------
const USER_ID = 'user-1'
const COMPANY_ID = 'company-1'
function makeRaw(overrides: Partial<RawTransaction> = {}): RawTransaction {
return {
@@ -147,7 +148,7 @@ describe('ingestTransactions', () => {
// evaluateMappingRules will be called but we want low confidence
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(0)
@@ -169,7 +170,7 @@ describe('ingestTransactions', () => {
// Batch external_id dedup query — returns matching external_id
enqueue({ data: [{ external_id: raw.external_id }], error: null })
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
@@ -192,7 +193,7 @@ describe('ingestTransactions', () => {
// Insert fails
enqueue({ data: null, error: { message: 'DB constraint violation' } })
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.errors).toBe(1)
expect(result.imported).toBe(0)
@@ -231,12 +232,12 @@ describe('ingestTransactions', () => {
})
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.auto_matched_invoices).toBe(1)
expect(mockGetBestInvoiceMatch).toHaveBeenCalledWith(
expect.anything(), // supabase client
USER_ID,
COMPANY_ID,
expect.objectContaining({ id: 'tx-income' }),
0.50
)
@@ -265,7 +266,7 @@ describe('ingestTransactions', () => {
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.auto_matched_invoices).toBe(0)
@@ -301,11 +302,12 @@ describe('ingestTransactions', () => {
)
mockCreateTransactionJournalEntry.mockResolvedValue(journalEntry)
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.auto_categorized).toBe(1)
expect(mockCreateTransactionJournalEntry).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
USER_ID,
expect.objectContaining({ id: 'tx-cat' }),
expect.objectContaining({ confidence: 0.85 })
@@ -337,7 +339,7 @@ describe('ingestTransactions', () => {
makeMappingResult({ confidence: 0.6 })
)
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.auto_categorized).toBe(0)
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
@@ -368,7 +370,7 @@ describe('ingestTransactions', () => {
makeMappingResult({ confidence: 0.95, requires_review: true })
)
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.auto_categorized).toBe(0)
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
@@ -398,7 +400,7 @@ describe('ingestTransactions', () => {
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw1, raw2])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw1, raw2])
expect(result.imported).toBe(2)
expect(result.duplicates).toBe(0)
@@ -457,6 +459,7 @@ describe('ingestTransactions', () => {
const result = await ingestTransactions(
supabase as never,
COMPANY_ID,
USER_ID,
[rawNew, rawDup, rawErr]
)
@@ -475,7 +478,7 @@ describe('ingestTransactions', () => {
it('returns zero totals for an empty input array', async () => {
const { supabase } = createQueueMockSupabase()
const result = await ingestTransactions(supabase as never, USER_ID, [])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [])
expect(result).toEqual({
imported: 0,
@@ -507,7 +510,7 @@ describe('ingestTransactions', () => {
mockGetBestInvoiceMatch.mockRejectedValue(new Error('Network error'))
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
// Should still count as imported even though invoice matching failed
expect(result.imported).toBe(1)
@@ -533,7 +536,7 @@ describe('ingestTransactions', () => {
mockEvaluateMappingRules.mockRejectedValue(new Error('Mapping error'))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.auto_categorized).toBe(0)
@@ -587,7 +590,7 @@ describe('ingestTransactions', () => {
// Reconciliation update
enqueue({ data: null, error: null })
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.reconciled).toBe(1)
@@ -631,7 +634,7 @@ describe('ingestTransactions', () => {
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.reconciled).toBe(0)
@@ -660,7 +663,7 @@ describe('ingestTransactions', () => {
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.reconciled).toBe(0)
@@ -688,7 +691,7 @@ describe('ingestTransactions', () => {
// Batch external_id dedup query (no match by external_id)
enqueue({ data: [], error: null })
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
@@ -717,7 +720,7 @@ describe('ingestTransactions', () => {
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(0)
@@ -754,7 +757,7 @@ describe('ingestTransactions', () => {
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw1, raw2, raw3])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw1, raw2, raw3])
expect(result.duplicates).toBe(2)
expect(result.imported).toBe(1)
@@ -776,7 +779,7 @@ describe('ingestTransactions', () => {
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(0)
+12 -9
View File
@@ -21,7 +21,7 @@ export type { RawTransaction, IngestResult } from '@/types'
*/
async function buildBookedTransactionMap(
supabase: SupabaseClient,
userId: string,
companyId: string,
rawTransactions: RawTransaction[]
): Promise<Map<string, number>> {
const map = new Map<string, number>()
@@ -35,7 +35,7 @@ async function buildBookedTransactionMap(
const { data: booked } = await supabase
.from('transactions')
.select('date, amount')
.eq('user_id', userId)
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
.lte('date', dateTo)
@@ -70,6 +70,7 @@ async function buildBookedTransactionMap(
*/
export async function ingestTransactions(
supabase: SupabaseClient,
companyId: string,
userId: string,
rawTransactions: RawTransaction[],
options?: IngestOptions
@@ -87,12 +88,12 @@ export async function ingestTransactions(
// Pre-fetch booked transactions for content-based dedup (date+amount)
// This catches cross-source duplicates (e.g. same transaction imported
// via CSV and then again via PSD2 with different external_id)
const bookedMap = await buildBookedTransactionMap(supabase, userId, rawTransactions)
const bookedMap = await buildBookedTransactionMap(supabase, companyId, rawTransactions)
// Pre-fetch unlinked GL lines for reconciliation (non-critical)
let glLinePool: UnlinkedGLLine[] = []
try {
glLinePool = await fetchUnlinkedGLLines(supabase, userId)
glLinePool = await fetchUnlinkedGLLines(supabase, companyId)
} catch {
// Non-critical — reconciliation will be skipped
}
@@ -103,7 +104,7 @@ export async function ingestTransactions(
const { data } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(*)')
.eq('user_id', userId)
.eq('company_id', companyId)
.in('status', ['registered', 'approved'])
.gt('remaining_amount', 0)
@@ -135,7 +136,7 @@ export async function ingestTransactions(
const { data } = await supabase
.from('transactions')
.select('external_id')
.eq('user_id', userId)
.eq('company_id', companyId)
.in('external_id', chunk)
data?.forEach(r => existingExternalIds.add(r.external_id))
}
@@ -173,6 +174,7 @@ export async function ingestTransactions(
const { data: newTransaction, error: insertError } = await supabase
.from('transactions')
.insert({
company_id: companyId,
user_id: userId,
bank_connection_id: raw.bank_connection_id || null,
external_id: raw.external_id,
@@ -232,7 +234,7 @@ export async function ingestTransactions(
// (which calls findMatchingInvoices, which now checks references)
const bestMatch = await getBestInvoiceMatch(
supabase,
userId,
companyId,
newTransaction as Transaction,
0.50
)
@@ -321,13 +323,14 @@ export async function ingestTransactions(
try {
const mappingResult = await evaluateMappingRules(
supabase,
userId,
companyId,
newTransaction as Transaction
)
if (mappingResult.confidence >= 0.8 && !mappingResult.requires_review) {
const journalEntry = await createTransactionJournalEntry(
supabase,
companyId,
userId,
newTransaction as Transaction,
mappingResult
@@ -345,7 +348,7 @@ export async function ingestTransactions(
// Upsert counterparty template (auto-learned, lower confidence)
try {
await upsertCounterpartyTemplate(
supabase, userId, newTransaction as Transaction,
supabase, companyId, newTransaction as Transaction,
mappingResult, 'auto_learned'
)
} catch {