diff --git a/CLAUDE.md b/CLAUDE.md index 6bc43ed6..f0cf77e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ The engine (`lib/bookkeeping/engine.ts`) is the most critical system. All accoun `standard_25`, `reduced_12`, `reduced_6`, `reverse_charge`, `export`, `exempt` -Invoice items support individual `vat_rate` values (mixed-rate invoices). `generatePerRateLines()` in `invoice-entries.ts` groups by rate. Use `getAvailableVatRates(customerType, vatNumberValidated)` from `lib/invoices/vat-rules.ts`. +Invoice items support individual `vat_rate` values (mixed-rate invoices). `generatePerRateLines()` in `lib/bookkeeping/invoice-entries.ts` groups by rate. Use `getAvailableVatRates(customerType, vatNumberValidated)` from `lib/invoices/vat-rules.ts`. ### VAT Declaration Rutor (SKV 4700) @@ -115,7 +115,7 @@ Extensions are opt-in plugins in `extensions/general//`, controlled by `ex gnubok exposes its bookkeeping engine as an MCP (Model Context Protocol) server, letting users do bookkeeping through Claude Desktop, Claude Code, or any MCP-compatible client. -**MCP extension** (`extensions/general/mcp-server/`): 10 tools — transactions, categorization, customers, invoices, trial balance, VAT report, KPI report, income statement. JSON-RPC 2.0 protocol implemented directly (no SDK dependency). Endpoint: `/api/extensions/ext/mcp-server/mcp`. +**MCP extension** (`extensions/general/mcp-server/`): 26 tools — transactions, categorization, customers, suppliers, invoices, supplier invoices, accounts, fiscal periods, trial balance, general ledger, balance sheet, income statement, AR/supplier ledger, reconciliation, VAT report, KPI report, receipt matching, invoice payments/sending. JSON-RPC 2.0 protocol implemented directly (no SDK dependency). Endpoint: `/api/extensions/ext/mcp-server/mcp`. **API key infrastructure** (`lib/auth/api-keys.ts`, `api_keys` table): SHA-256 hashed keys with `gnubok_sk_` prefix. Rate limited at 100 RPM via atomic DB RPC (`validate_and_increment_api_key`). `createServiceClientNoCookies()` creates a Supabase service client without cookies for API key auth — all queries filter by `user_id` (defense in depth). @@ -176,7 +176,7 @@ export async function POST(request: Request) { ## Database & Migrations -**Location**: `supabase/migrations/` — 70 files. Early migrations use sequential numbering (`20240101000001`–`20240101000038`), later ones use real timestamps. +**Location**: `supabase/migrations/` — 80 files. Early migrations use sequential numbering (`20240101000001`–`20240101000038`), later ones use real timestamps. ### Migration Rules @@ -202,7 +202,7 @@ export async function POST(request: Request) { ## Deployment -Hosted on **Vercel**. Cron jobs defined in `vercel.json` (banking sync, deadlines, reminders, tax deadlines, document verification, sandbox cleanup). +Hosted on **Vercel**. Cron jobs defined in `vercel.json` (banking sync, deadlines, reminders, tax deadlines, document verification, sandbox cleanup, event cleanup). **Core env vars**: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `NEXT_PUBLIC_APP_URL`, `CRON_SECRET`. **Auth env vars**: `NEXT_PUBLIC_REQUIRE_MFA` (set `true` on hosted), `NEXT_PUBLIC_SELF_HOSTED` (set `true` for Docker). Extension env vars only needed when that extension is enabled. diff --git a/app/api/bookkeeping/fiscal-periods/[id]/opening-balances/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/opening-balances/route.ts new file mode 100644 index 00000000..7b7ef234 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/opening-balances/route.ts @@ -0,0 +1,67 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { getOpeningBalances } from '@/lib/reports/opening-balances' +import { requireCompanyId } from '@/lib/company/context' + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = await requireCompanyId(supabase, user.id) + const { id } = await params + + // Fetch the fiscal period + const { data: period, error: periodError } = await supabase + .from('fiscal_periods') + .select('period_start, opening_balance_entry_id') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (periodError || !period) { + return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 }) + } + + // Get opening balances + const { balances } = await getOpeningBalances(supabase, companyId, period) + + // Fetch account names for the accounts that have balances + const accountNumbers = Array.from(balances.keys()) + + if (accountNumbers.length === 0) { + return NextResponse.json({ data: [] }) + } + + const { data: accounts } = await supabase + .from('chart_of_accounts') + .select('account_number, account_name') + .eq('company_id', companyId) + .in('account_number', accountNumbers) + + const accountNameMap = new Map( + (accounts || []).map(a => [a.account_number, a.account_name]) + ) + + // Build response with account names and net balances + const data = accountNumbers + .sort() + .map(accountNumber => { + const bal = balances.get(accountNumber)! + const net = Math.round((bal.debit - bal.credit) * 100) / 100 + return { + account_number: accountNumber, + account_name: accountNameMap.get(accountNumber) || accountNumber, + balance: net, + } + }) + .filter(row => row.balance !== 0) + + return NextResponse.json({ data }) +} diff --git a/app/api/bookkeeping/fiscal-periods/[id]/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/route.ts new file mode 100644 index 00000000..0b304e76 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/route.ts @@ -0,0 +1,126 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { validateBody } from '@/lib/api/validate' +import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration' +import { requireCompanyId } from '@/lib/company/context' +import { z } from 'zod' + +const UpdateFiscalPeriodSchema = z.object({ + name: z.string().min(1).optional(), + period_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Startdatum måste vara i format ÅÅÅÅ-MM-DD').optional(), + period_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Slutdatum måste vara i format ÅÅÅÅ-MM-DD').optional(), +}) + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = await requireCompanyId(supabase, user.id) + + const validation = await validateBody(request, UpdateFiscalPeriodSchema) + if (!validation.success) return validation.response + const body = validation.data + + // Fetch the period + const { data: period, error: fetchError } = await supabase + .from('fiscal_periods') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !period) { + return NextResponse.json({ error: 'Räkenskapsår hittades inte' }, { status: 404 }) + } + + // Cannot edit locked or closed periods + if (period.locked_at) { + return NextResponse.json({ error: 'Kan inte ändra ett låst räkenskapsår' }, { status: 400 }) + } + if (period.is_closed) { + return NextResponse.json({ error: 'Kan inte ändra ett stängt räkenskapsår' }, { status: 400 }) + } + + // If dates are being changed, check for existing journal entries + if (body.period_start || body.period_end) { + const { count: entryCount } = await supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('fiscal_period_id', id) + .in('status', ['posted', 'reversed']) + + if (entryCount && entryCount > 0) { + return NextResponse.json( + { error: `Kan inte ändra datum: ${entryCount} bokförda verifikationer finns i perioden. Ta bort eller flytta dem först.` }, + { status: 400 } + ) + } + + const newStart = body.period_start || period.period_start + const newEnd = body.period_end || period.period_end + + // Validate period duration (max 18 months per BFL 3 kap.) + const durationError = validatePeriodDuration(newStart, newEnd) + if (durationError) { + return NextResponse.json({ error: durationError }, { status: 400 }) + } + + // Check for overlapping periods (excluding this one) + const { data: overlapping } = await supabase + .from('fiscal_periods') + .select('id, name') + .eq('company_id', companyId) + .neq('id', id) + .lte('period_start', newEnd) + .gte('period_end', newStart) + .limit(1) + + if (overlapping && overlapping.length > 0) { + return NextResponse.json( + { error: `Överlappar med befintligt räkenskapsår: ${overlapping[0].name}` }, + { status: 409 } + ) + } + } + + // Build update object + const updates: Record = {} + if (body.name) updates.name = body.name + if (body.period_start) updates.period_start = body.period_start + if (body.period_end) updates.period_end = body.period_end + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ data: period }) + } + + const { data: updated, error: updateError } = await supabase + .from('fiscal_periods') + .update(updates) + .eq('id', id) + .eq('company_id', companyId) + .select() + .single() + + if (updateError) { + // Database CHECK constraints will catch invalid month boundaries + const msg = updateError.message + if (msg.includes('period_start') || msg.includes('period_end')) { + return NextResponse.json( + { error: 'Perioden måste börja den 1:a i en månad och sluta sista dagen i en månad' }, + { status: 400 } + ) + } + return NextResponse.json({ error: msg }, { status: 500 }) + } + + return NextResponse.json({ data: updated }) +} diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 898781dc..b7dc0b8e 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -174,7 +174,7 @@ export async function GET(request: Request) { // Batch reconciliation sweep when SIE overlap detected if (sieOverlap && totalImported > 0) { try { - await runReconciliation(supabase, connection.user_id, { + await runReconciliation(supabase, connection.company_id, { dateFrom: fromDate, dateTo: toDate, }) diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 13bd0378..1f748d1f 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -43,7 +43,7 @@ export async function PUT(request: Request) { // Fetch current settings to check for tax-relevant changes const { data: oldSettings } = await supabase .from('company_settings') - .select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month, onboarding_complete') + .select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete') .eq('company_id', companyId) .single() @@ -67,6 +67,33 @@ export async function PUT(request: Request) { ) } + // Validate: aktiebolag must use accrual accounting (BFNAR 2006:1) + if (effectiveEntityType === 'aktiebolag' && body.accounting_method === 'cash') { + return NextResponse.json( + { error: 'Aktiebolag måste använda faktureringsmetoden (BFNAR 2006:1)' }, + { status: 400 } + ) + } + + // Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.) + const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered + if (effectiveVatRegistered === true) { + const effectiveVatNumber = body.vat_number ?? oldSettings?.vat_number + if (!effectiveVatNumber) { + return NextResponse.json( + { error: 'Momsregistreringsnummer krävs när företaget är momsregistrerat (ML 11 kap. 8§)' }, + { status: 400 } + ) + } + const effectiveMomsPeriod = body.moms_period ?? oldSettings?.moms_period + if (!effectiveMomsPeriod) { + return NextResponse.json( + { error: 'Momsperiod krävs när företaget är momsregistrerat (SFL 26 kap.)' }, + { status: 400 } + ) + } + } + const { data, error } = await supabase .from('company_settings') .update(body) diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index ef7d793e..fa7b0411 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -560,6 +560,7 @@ export const arcimMigrationExtension: Extension = { const results = await executeMigration({ consentId, userId: user.id, + companyId: ctx?.companyId ?? user.id, supabase, importCompanyInfo, importCustomers, diff --git a/extensions/general/arcim-migration/lib/entity-mapper.ts b/extensions/general/arcim-migration/lib/entity-mapper.ts index e9422462..5c6d70b2 100644 --- a/extensions/general/arcim-migration/lib/entity-mapper.ts +++ b/extensions/general/arcim-migration/lib/entity-mapper.ts @@ -219,10 +219,11 @@ function inferVatRate(taxPercent?: number): number { // ── Public mappers ────────────────────────────────────────────────── -export function mapCustomer(dto: CustomerDto, userId: string): Record { +export function mapCustomer(dto: CustomerDto, userId: string, companyId: string): Record { const addr = formatAddress(dto.party.postalAddress) return { user_id: userId, + company_id: companyId, name: dto.party.name, customer_type: inferCustomerType(dto), email: dto.party.contact?.email || null, @@ -236,10 +237,11 @@ export function mapCustomer(dto: CustomerDto, userId: string): Record { +export function mapSupplier(dto: SupplierDto, userId: string, companyId: string): Record { const addr = formatAddress(dto.party.postalAddress) return { user_id: userId, + company_id: companyId, name: dto.party.name, supplier_type: inferSupplierType(dto), email: dto.party.contact?.email || null, @@ -262,6 +264,7 @@ export function mapSupplier(dto: SupplierDto, userId: string): Record; items: Record[] } { const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value) @@ -287,6 +290,7 @@ export function mapSalesInvoice( const invoice: Record = { user_id: userId, + company_id: companyId, customer_id: customerId, invoice_number: dto.invoiceNumber, invoice_date: dto.issueDate, @@ -331,6 +335,7 @@ function mapSalesInvoiceLine(line: SalesInvoiceLineDto, index: number): Record; items: Record[] } { const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value) @@ -354,6 +359,7 @@ export function mapSupplierInvoice( const invoice: Record = { user_id: userId, + company_id: companyId, supplier_id: supplierId, supplier_invoice_number: dto.invoiceNumber, invoice_date: dto.issueDate, diff --git a/extensions/general/arcim-migration/lib/migration-orchestrator.ts b/extensions/general/arcim-migration/lib/migration-orchestrator.ts index 7f5a05e1..7047c8d7 100644 --- a/extensions/general/arcim-migration/lib/migration-orchestrator.ts +++ b/extensions/general/arcim-migration/lib/migration-orchestrator.ts @@ -33,6 +33,7 @@ import { export interface MigrationOptions { consentId: string userId: string + companyId: string supabase: SupabaseClient importCompanyInfo?: boolean importCustomers?: boolean @@ -49,7 +50,7 @@ function emitProgress(options: MigrationOptions, progress: MigrationProgress) { // ── Main orchestrator ───────────────────────────────────────────── export async function executeMigration(options: MigrationOptions): Promise { - const { consentId, userId, supabase } = options + const { consentId, userId, companyId, supabase } = options const results: MigrationResults = {} try { @@ -63,7 +64,7 @@ export async function executeMigration(options: MigrationOptions): Promise = {} @@ -83,7 +84,7 @@ export async function executeMigration(options: MigrationOptions): Promise 0) { - await supabase.from('company_settings').update(updates).eq('company_id', userId) + await supabase.from('company_settings').update(updates).eq('company_id', companyId) } results.companyInfo = { imported: true } } @@ -116,7 +117,7 @@ export async function executeMigration(options: MigrationOptions): Promise 0) { try { - const reconResult = await runReconciliation(supabase, user.id, { + const reconResult = await runReconciliation(supabase, ctx?.companyId ?? user.id, { dateFrom: fromDate, dateTo: toDate, }) diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts index acfce34e..baa5b368 100644 --- a/lib/api/__tests__/schemas.test.ts +++ b/lib/api/__tests__/schemas.test.ts @@ -1004,7 +1004,47 @@ describe('UpdateSettingsSchema', () => { it('accepts partial update', () => { const result = UpdateSettingsSchema.safeParse({ company_name: 'My AB', + }) + expect(result.success).toBe(true) + }) + + it('accepts vat_registered: true with required vat_number and moms_period', () => { + const result = UpdateSettingsSchema.safeParse({ vat_registered: true, + vat_number: 'SE556123456701', + moms_period: 'quarterly', + }) + expect(result.success).toBe(true) + }) + + it('accepts vat_registered: true without vat_number at schema level (route-level check uses effective state)', () => { + const result = UpdateSettingsSchema.safeParse({ + vat_registered: true, + moms_period: 'quarterly', + }) + expect(result.success).toBe(true) + }) + + it('accepts vat_registered: true without moms_period at schema level (route-level check uses effective state)', () => { + const result = UpdateSettingsSchema.safeParse({ + vat_registered: true, + vat_number: 'SE556123456701', + }) + expect(result.success).toBe(true) + }) + + it('rejects aktiebolag with kontantmetoden (BFNAR 2006:1)', () => { + const result = UpdateSettingsSchema.safeParse({ + entity_type: 'aktiebolag', + accounting_method: 'cash', + }) + expect(result.success).toBe(false) + }) + + it('allows enskild firma with kontantmetoden', () => { + const result = UpdateSettingsSchema.safeParse({ + entity_type: 'enskild_firma', + accounting_method: 'cash', }) expect(result.success).toBe(true) }) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 511426b6..c6825cd6 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -366,7 +366,7 @@ export const UpdateSettingsSchema = z.object({ country: z.string().optional(), f_skatt: z.boolean().optional(), vat_registered: z.boolean().optional(), - vat_number: z.string().optional(), + vat_number: z.string().regex(/^SE\d{12}$/, 'Momsregistreringsnummer måste vara SE följt av 12 siffror').nullable().optional(), moms_period: MomsPeriodSchema.nullable().optional(), fiscal_year_start_month: z.number().int().min(1).max(12).optional(), preliminary_tax_monthly: z.number().nullable().optional(), @@ -374,6 +374,7 @@ export const UpdateSettingsSchema = z.object({ clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4-5 siffror').optional().or(z.literal('')), account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6-12 siffror').optional().or(z.literal('')), bankgiro: z.string().regex(/^(\d{3,4}-\d{4}|\d{7,8})$/, 'Ogiltigt bankgironummer (7-8 siffror)').nullable().optional().or(z.literal('')), + plusgiro: z.string().regex(/^\d{1,7}-\d{1}$/, 'Ogiltigt plusgironummer').nullable().optional().or(z.literal('')), iban: z.string().optional(), bic: z.string().optional(), accounting_method: AccountingMethodSchema.optional(), @@ -381,7 +382,9 @@ export const UpdateSettingsSchema = z.object({ next_invoice_number: z.number().int().positive().optional(), invoice_default_days: z.number().int().positive().optional(), invoice_default_notes: z.string().nullable().optional(), + phone: z.string().optional(), email: z.string().email().optional(), + website: z.string().optional().or(z.literal('')), pays_salaries: z.boolean().optional(), sector_slug: z.string().nullable().optional(), }).refine( @@ -396,6 +399,18 @@ export const UpdateSettingsSchema = z.object({ message: 'Enskild firma must have fiscal year starting in January (BFL 3 kap.)', path: ['fiscal_year_start_month'], } +).refine( + (data) => { + // BFNAR 2006:1: Aktiebolag must use accrual accounting (faktureringsmetoden) + if (data.entity_type === 'aktiebolag' && data.accounting_method !== undefined) { + return data.accounting_method === 'accrual' + } + return true + }, + { + message: 'Aktiebolag måste använda faktureringsmetoden (BFNAR 2006:1)', + path: ['accounting_method'], + } ) // ============================================================ diff --git a/lib/core/bookkeeping/period-service.ts b/lib/core/bookkeeping/period-service.ts index cbd2d290..5b498de6 100644 --- a/lib/core/bookkeeping/period-service.ts +++ b/lib/core/bookkeeping/period-service.ts @@ -151,20 +151,12 @@ export async function createNextPeriod( const nextStart = new Date(current.period_end) nextStart.setDate(nextStart.getDate() + 1) - // Compute period length from current period to handle broken fiscal years - const currentStart = new Date(current.period_start) - const currentEnd = new Date(current.period_end) - - // Calculate months difference - const monthsDiff = - (currentEnd.getFullYear() - currentStart.getFullYear()) * 12 + - (currentEnd.getMonth() - currentStart.getMonth()) - - // Next period end: add same number of months from next start, then go to end of that month + // After a broken first fiscal year, subsequent years should always be + // 12 months (standard fiscal year). The first year is the only one that + // can be longer/shorter than 12 months per BFL 3 kap. const nextEnd = new Date(nextStart) - nextEnd.setMonth(nextEnd.getMonth() + monthsDiff) - // Go to end of the month - nextEnd.setMonth(nextEnd.getMonth() + 1) + nextEnd.setMonth(nextEnd.getMonth() + 12) + // Go to last day of that month nextEnd.setDate(0) const nextStartStr = nextStart.toISOString().split('T')[0] diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index c52fed62..c76e819d 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -503,6 +503,12 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {company.bankgiro} )} + {company.plusgiro && ( + + Plusgiro: + {company.plusgiro} + + )} {company.iban && ( IBAN: diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts index e601c21d..40bea081 100644 --- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts +++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts @@ -278,7 +278,7 @@ describe('runReconciliation', () => { // from('transactions').select — unmatched enqueue({ data: [] }) - const result = await runReconciliation(supabase as never, 'user-1') + const result = await runReconciliation(supabase as never, 'company-1') expect(result.matches).toEqual([]) expect(result.applied).toBe(0) @@ -300,7 +300,7 @@ describe('runReconciliation', () => { // from('transactions') returns unmatched transactions enqueue({ data: [tx] }) - const result = await runReconciliation(supabase as never, 'user-1', { dryRun: true }) + const result = await runReconciliation(supabase as never, 'company-1', { dryRun: true }) expect(result.matches).toHaveLength(1) expect(result.matches[0].method).toBe('auto_exact') @@ -325,7 +325,7 @@ describe('runReconciliation', () => { // Update transaction with link enqueue({ data: null, error: null }) - const result = await runReconciliation(supabase as never, 'user-1', { dryRun: false }) + const result = await runReconciliation(supabase as never, 'company-1', { dryRun: false }) expect(result.matches).toHaveLength(1) expect(result.applied).toBe(1) @@ -379,7 +379,7 @@ describe('manualLink', () => { // Transaction query returns null enqueue({ data: null, error: { message: 'Not found' } }) - const result = await manualLink(supabase as never, 'user-1', 'tx-1', 'je-1') + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1') expect(result.success).toBe(false) expect(result.error).toBe('Transaction not found') @@ -392,7 +392,7 @@ describe('manualLink', () => { // Transaction found but already linked enqueue({ data: tx }) - const result = await manualLink(supabase as never, 'user-1', 'tx-1', 'je-1') + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1') expect(result.success).toBe(false) expect(result.error).toBe('Transaction is already linked to a journal entry') @@ -405,11 +405,11 @@ describe('manualLink', () => { // Transaction found enqueue({ data: tx }) // Journal entry found - enqueue({ data: { id: 'je-1', user_id: 'user-1', status: 'posted' } }) + enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } }) // No 1930 lines enqueue({ data: [] }) - const result = await manualLink(supabase as never, 'user-1', 'tx-1', 'je-1') + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1') expect(result.success).toBe(false) expect(result.error).toBe('Journal entry has no line on account 1930') @@ -422,7 +422,7 @@ describe('manualLink', () => { // Transaction found enqueue({ data: tx }) // Journal entry found - enqueue({ data: { id: 'je-1', user_id: 'user-1', status: 'posted' } }) + enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } }) // 1930 line exists enqueue({ data: [{ debit_amount: 1000, credit_amount: 0 }] }) // No existing link @@ -430,7 +430,7 @@ describe('manualLink', () => { // Update succeeds enqueue({ data: null, error: null }) - const result = await manualLink(supabase as never, 'user-1', 'tx-1', 'je-1') + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1') expect(result.success).toBe(true) }) @@ -488,7 +488,7 @@ describe('unlinkReconciliation', () => { }, }) - const result = await unlinkReconciliation(supabase as never, 'user-1', 'tx-1') + const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1') expect(result.success).toBe(false) expect(result.error).toContain('Cannot unlink') @@ -508,7 +508,7 @@ describe('unlinkReconciliation', () => { // Update succeeds enqueue({ data: null, error: null }) - const result = await unlinkReconciliation(supabase as never, 'user-1', 'tx-1') + const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1') expect(result.success).toBe(true) }) diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index 64345971..e5949e88 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -126,19 +126,19 @@ export function tryReconcileTransaction( */ export async function runReconciliation( supabase: SupabaseClient, - userId: string, - options: ReconciliationOptions = {} + companyId: string, + options: ReconciliationOptions & { userId?: string } = {} ): Promise { const { dateFrom, dateTo, dryRun = false } = options // Fetch unlinked GL lines via RPC - const glLines = await fetchUnlinkedGLLines(supabase, userId, dateFrom, dateTo) + const glLines = await fetchUnlinkedGLLines(supabase, companyId, dateFrom, dateTo) // Fetch unmatched transactions let query = supabase .from('transactions') .select('*') - .eq('company_id', userId) + .eq('company_id', companyId) .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('company_id', userId) + .eq('company_id', companyId) if (error) { errors++ @@ -185,8 +185,8 @@ export async function runReconciliation( transaction: match.transaction, journalEntryId: match.glLine.journal_entry_id, method: match.method, - userId, - companyId: userId, + userId: options.userId ?? companyId, + companyId, }, }) } catch { @@ -210,7 +210,7 @@ export async function runReconciliation( */ export async function getReconciliationStatus( supabase: SupabaseClient, - userId: string, + companyId: string, dateFrom?: string, dateTo?: string ): Promise { @@ -218,7 +218,7 @@ export async function getReconciliationStatus( let txQuery = supabase .from('transactions') .select('amount, journal_entry_id, reconciliation_method') - .eq('company_id', userId) + .eq('company_id', companyId) .eq('currency', 'SEK') if (dateFrom) txQuery = txQuery.gte('date', dateFrom) @@ -229,9 +229,9 @@ export async function getReconciliationStatus( // Get GL 1930 lines (all, not just unlinked) let glQuery = supabase .from('journal_entry_lines') - .select('debit_amount, credit_amount, journal_entries!inner(user_id, entry_date, status)') + .select('debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status)') .eq('account_number', '1930') - .eq('journal_entries.company_id', userId) + .eq('journal_entries.company_id', companyId) .eq('journal_entries.status', 'posted') if (dateFrom) glQuery = glQuery.gte('journal_entries.entry_date', dateFrom) @@ -259,7 +259,7 @@ export async function getReconciliationStatus( ).length // Unlinked GL lines count - const unlinkedLines = await fetchUnlinkedGLLines(supabase, userId, dateFrom, dateTo) + const unlinkedLines = await fetchUnlinkedGLLines(supabase, companyId, dateFrom, dateTo) const difference = Math.round((bankTotal - glBalance) * 100) / 100 @@ -284,16 +284,17 @@ export async function getReconciliationStatus( */ export async function manualLink( supabase: SupabaseClient, - userId: string, + companyId: string, transactionId: string, - journalEntryId: string + journalEntryId: string, + userId?: string ): Promise<{ success: boolean; error?: string }> { // Fetch transaction const { data: tx, error: txError } = await supabase .from('transactions') .select('*') .eq('id', transactionId) - .eq('company_id', userId) + .eq('company_id', companyId) .single() if (txError || !tx) { @@ -307,9 +308,9 @@ export async function manualLink( // Fetch journal entry + verify it has a 1930 line const { data: entry, error: entryError } = await supabase .from('journal_entries') - .select('id, user_id, status') + .select('id, company_id, status') .eq('id', journalEntryId) - .eq('company_id', userId) + .eq('company_id', companyId) .single() if (entryError || !entry) { @@ -336,7 +337,7 @@ export async function manualLink( .from('transactions') .select('id') .eq('journal_entry_id', journalEntryId) - .eq('company_id', userId) + .eq('company_id', companyId) .single() if (existingLink) { @@ -352,7 +353,7 @@ export async function manualLink( is_business: true, }) .eq('id', transactionId) - .eq('company_id', userId) + .eq('company_id', companyId) if (updateError) { return { success: false, error: 'Failed to link transaction' } @@ -365,8 +366,8 @@ export async function manualLink( transaction: tx as Transaction, journalEntryId, method: 'manual' as ReconciliationMethod, - userId, - companyId: userId, + userId: userId ?? companyId, + companyId, }, }) } catch { @@ -382,7 +383,7 @@ export async function manualLink( */ export async function unlinkReconciliation( supabase: SupabaseClient, - userId: string, + companyId: string, transactionId: string ): Promise<{ success: boolean; error?: string }> { // Fetch transaction @@ -390,7 +391,7 @@ export async function unlinkReconciliation( .from('transactions') .select('id, journal_entry_id, reconciliation_method') .eq('id', transactionId) - .eq('company_id', userId) + .eq('company_id', companyId) .single() if (txError || !tx) { @@ -413,13 +414,13 @@ export async function unlinkReconciliation( is_business: null, }) .eq('id', transactionId) - .eq('company_id', userId) + .eq('company_id', companyId) if (updateError) { return { success: false, error: 'Failed to unlink transaction' } } - logMatchEvent(supabase, userId, transactionId, 'unmatched', { + logMatchEvent(supabase, companyId, transactionId, 'unmatched', { previousState: { journal_entry_id: tx.journal_entry_id, reconciliation_method: tx.reconciliation_method, @@ -441,7 +442,7 @@ export async function fetchUnlinkedGLLines( dateTo?: string ): Promise { const { data, error } = await supabase.rpc('get_unlinked_1930_lines', { - p_user_id: companyId, + p_company_id: companyId, p_date_from: dateFrom || null, p_date_to: dateTo || null, }) diff --git a/next.config.ts b/next.config.ts index 240c691c..c8857519 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,8 @@ const isDev = process.env.NODE_ENV === "development"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ""; +const activepiecesUrl = process.env.ACTIVEPIECES_URL ?? ""; + const cspDirectives = [ "default-src 'self'", `connect-src 'self' ${supabaseUrl} https://*.supabase.co wss://*.supabase.co https://*.ingest.sentry.io https://*.enablebanking.com https://*.recapt.app`, @@ -13,6 +15,7 @@ const cspDirectives = [ "img-src 'self' data: blob: https:", "font-src 'self'", "worker-src 'self' blob:", + `frame-src 'self'${activepiecesUrl ? ` ${activepiecesUrl}` : ""}`, "frame-ancestors 'none'", ].join("; "); diff --git a/supabase/migrations/20260401000000_add_pays_salaries_column.sql b/supabase/migrations/20260401000000_add_pays_salaries_column.sql new file mode 100644 index 00000000..c4162e29 --- /dev/null +++ b/supabase/migrations/20260401000000_add_pays_salaries_column.sql @@ -0,0 +1,4 @@ +-- Add pays_salaries column to company_settings +-- Required by tax deadline logic (arbetsgivardeklaration for aktiebolag) +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS pays_salaries boolean NOT NULL DEFAULT false; diff --git a/supabase/migrations/20260401100000_fix_unlinked_1930_lines_company_id.sql b/supabase/migrations/20260401100000_fix_unlinked_1930_lines_company_id.sql new file mode 100644 index 00000000..d4b455e4 --- /dev/null +++ b/supabase/migrations/20260401100000_fix_unlinked_1930_lines_company_id.sql @@ -0,0 +1,54 @@ +-- Fix get_unlinked_1930_lines to filter by company_id instead of user_id +-- The multi-tenant migration (20260330130000) added company_id to journal_entries +-- and transactions, but this RPC was not updated. + +DROP FUNCTION IF EXISTS public.get_unlinked_1930_lines(uuid, date, date); + +CREATE FUNCTION public.get_unlinked_1930_lines( + p_company_id UUID, + p_date_from DATE DEFAULT NULL, + p_date_to DATE DEFAULT NULL +) +RETURNS TABLE ( + line_id UUID, + journal_entry_id UUID, + debit_amount NUMERIC, + credit_amount NUMERIC, + line_description TEXT, + entry_date DATE, + voucher_number INT, + voucher_series TEXT, + entry_description TEXT, + source_type TEXT +) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT + jel.id AS line_id, + je.id AS journal_entry_id, + jel.debit_amount, + jel.credit_amount, + jel.line_description, + je.entry_date, + je.voucher_number, + je.voucher_series, + je.description AS entry_description, + je.source_type + FROM public.journal_entry_lines jel + JOIN public.journal_entries je ON je.id = jel.journal_entry_id + WHERE jel.account_number = '1930' + AND je.company_id = p_company_id + AND je.status = 'posted' + AND (p_date_from IS NULL OR je.entry_date >= p_date_from) + AND (p_date_to IS NULL OR je.entry_date <= p_date_to) + AND NOT EXISTS ( + SELECT 1 + FROM public.transactions t + WHERE t.journal_entry_id = je.id + AND t.company_id = p_company_id + ) + ORDER BY je.entry_date, je.voucher_number; +$$; diff --git a/tests/helpers.ts b/tests/helpers.ts index 83d3f028..3e571d83 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -495,6 +495,8 @@ export function makeCompanySettings( country: 'SE', phone: null, email: null, + website: null, + pays_salaries: false, f_skatt: true, vat_registered: true, vat_number: null, @@ -505,6 +507,7 @@ export function makeCompanySettings( clearing_number: null, account_number: null, bankgiro: null, + plusgiro: null, iban: null, bic: null, accounting_method: 'accrual', diff --git a/types/index.ts b/types/index.ts index 31b4a3f8..a6dcaa91 100644 --- a/types/index.ts +++ b/types/index.ts @@ -148,8 +148,10 @@ export interface CompanySettings { // Contact phone: string | null email: string | null + website: string | null // Tax registration + pays_salaries: boolean f_skatt: boolean vat_registered: boolean vat_number: string | null @@ -170,6 +172,7 @@ export interface CompanySettings { clearing_number: string | null account_number: string | null bankgiro: string | null + plusgiro: string | null iban: string | null bic: string | null