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:
co-authored by
Claude Opus 4.6
parent
1756af6c5e
commit
0dd1f5ebc1
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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[],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.' }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.`
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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: () => {} } }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user