diff --git a/app/api/events/route.ts b/app/api/events/route.ts index 76b996ae..e629872e 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -23,6 +23,11 @@ export async function GET(request: Request) { // Dual auth: API key or session let userId: string let supabase: SupabaseClient + // When authenticated via an API key, the key is BOUND to a specific company. + // Honor that binding (least privilege) rather than resolving the user's + // active company — otherwise a key scoped to company A would leak company B's + // events whenever the user's active_company_id happened to point elsewhere. + let keyCompanyId: string | null = null const token = extractBearerToken(request) if (token?.startsWith('gnubok_sk_')) { @@ -31,6 +36,7 @@ export async function GET(request: Request) { return NextResponse.json({ error: authResult.error }, { status: authResult.status }) } userId = authResult.userId + keyCompanyId = authResult.companyId supabase = createServiceClientNoCookies() } else { supabase = await createClient() @@ -41,7 +47,14 @@ export async function GET(request: Request) { userId = user.id } - const companyId = await requireCompanyId(supabase, userId) + // Session auth resolves the active company; API-key auth uses the key's bound company. + const companyId = keyCompanyId ?? await requireCompanyId(supabase, userId) + // Defense in depth: never run the event_log query with an empty/undefined + // scope. requireCompanyId throws when there is no company, but guard the + // key-bound path too so a malformed binding can't widen the query scope. + if (!companyId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } // Validate query params const result = validateQuery(request, EventsQuerySchema) diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index 7bf30439..ce3008e9 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -1,10 +1,18 @@ import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' import { createSession, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client' import type { StoredAccount } from '@/extensions/general/enable-banking/types' import { eventBus } from '@/lib/events/bus' import { upsertFromPsd2 } from '@/lib/cash-accounts/service' +// This route emits bank_connection.consent_granted / .cash_account_mirror_failed +// (ASVS V16 / GDPR Art.30 audit events). ensureInitialized() must run at module +// load so registerEventLogHandler() has subscribed before the first emit() — +// otherwise the audit row is silently dropped on a cold instance where this +// redirect route is the first event-emitting code path to execute. +ensureInitialized() + // Suggested BAS account per currency. Mirrors the AccountPickerDialog defaults // (SEK→1930, EUR→1932, USD→1933, GBP→1934). The user can re-map in the picker // after this callback redirects them. diff --git a/app/api/extensions/ext/[...path]/__tests__/route.test.ts b/app/api/extensions/ext/[...path]/__tests__/route.test.ts index f0c0ae92..814f7d10 100644 --- a/app/api/extensions/ext/[...path]/__tests__/route.test.ts +++ b/app/api/extensions/ext/[...path]/__tests__/route.test.ts @@ -27,11 +27,19 @@ vi.mock('@/lib/extensions/context-factory', () => ({ }), })) +// Default to "MFA not enforced" so existing tests authenticate normally; +// the AAL2-gate regression test below flips this on. +vi.mock('@/lib/auth/mfa', () => ({ + shouldEnforceMfa: vi.fn(() => false), +})) + import { createClient } from '@/lib/supabase/server' +import { shouldEnforceMfa } from '@/lib/auth/mfa' import { extensionRegistry } from '@/lib/extensions/registry' import { GET, POST } from '../route' const mockCreateClient = vi.mocked(createClient) +const mockShouldEnforceMfa = vi.mocked(shouldEnforceMfa) function createPathParams(path: string[]) { return { params: Promise.resolve({ path }) } @@ -40,6 +48,9 @@ function createPathParams(path: string[]) { describe('Extension Catch-All Route', () => { beforeEach(() => { vi.clearAllMocks() + // clearAllMocks doesn't reset implementations — re-assert the default so the + // AAL2 test's mockReturnValue(true) can't leak into later cases. + mockShouldEnforceMfa.mockReturnValue(false) extensionRegistry.clear() }) @@ -88,6 +99,40 @@ describe('Extension Catch-All Route', () => { expect(status).toBe(401) }) + it('blocks a session that has not completed MFA (AAL2) and never dispatches the handler', async () => { + // Regression for the audit fix: the dispatcher is the single chokepoint for + // the whole extension surface, so an AAL1 (single-factor) session on hosted + // must be rejected before any extension handler runs. + const handler = vi.fn() + extensionRegistry.register({ + id: 'test-ext', + name: 'Test', + version: '1.0.0', + apiRoutes: [{ method: 'GET', path: '/data', handler }], + }) + + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1', app_metadata: {} } }, + error: null, + }) + // MFA is required for this user, but only AAL1 has been reached. + mockShouldEnforceMfa.mockReturnValue(true) + ;(supabase.auth as unknown as { mfa: unknown }).mfa = { + getAuthenticatorAssuranceLevel: vi + .fn() + .mockResolvedValue({ data: { currentLevel: 'aal1', nextLevel: 'aal2' } }), + } + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/ext/test-ext/data') + const response = await GET(request, createPathParams(['test-ext', 'data'])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(handler).not.toHaveBeenCalled() + }) + it('returns 404 for unmatched method/path', async () => { extensionRegistry.register({ id: 'test-ext', diff --git a/app/api/extensions/ext/[...path]/route.ts b/app/api/extensions/ext/[...path]/route.ts index 194b9218..0cfb7f37 100644 --- a/app/api/extensions/ext/[...path]/route.ts +++ b/app/api/extensions/ext/[...path]/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@/lib/supabase/server' +import { requireAuth } from '@/lib/auth/require-auth' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { extensionRegistry } from '@/lib/extensions/registry' @@ -232,16 +232,16 @@ async function handleRequest( return decorateResponse(response, requestId) } - // Auth check - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return decorateResponse( - NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), - requestId, - ) + // Auth check — requireAuth() enforces MFA (AAL2) on hosted, which the previous + // inline supabase.auth.getUser() did not. This dispatcher is the single + // chokepoint for the entire enabled-extension surface (banking sync, document + // upload/booking, supplier-invoice flows, migration), so enforcing MFA here + // closes the gap across all of them at once. + const auth = await requireAuth() + if (auth.error) { + return decorateResponse(auth.error, requestId) } + const { user, supabase } = auth // If path params were extracted, create a new Request with them as search params let handlerRequest = request diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index 72caefbe..516596d2 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -159,6 +159,32 @@ describe('POST /api/invoices/[id]/mark-paid', () => { ) }) + it('refuses to mark paid (INVOICE_PAID_BOOK_FAILED) when no payment journal entry is produced', async () => { + const customer = makeCustomer() + const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500, customer }) + + // Fetch invoice + enqueue({ data: invoice, error: null }) + // Duplicate-payment guard: two ILIKE probes — no candidates + enqueue({ data: [], error: null }) + enqueue({ data: [], error: null }) + // Company settings + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + // Deliberately NO status-update enqueued: the route must fail closed BEFORE + // touching the invoice row when nothing was booked. + + // Helper returns null without throwing (e.g. a closed/locked fiscal period). + mockCreateInvoicePaymentJournalEntry.mockResolvedValue(null) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled() + // No silent "paid with no journal entry" — GL must not diverge from the AR ledger. + expect(body.error.code).toBe('INVOICE_PAID_BOOK_FAILED') + }) + it('marks overdue invoice as paid with cash method', async () => { const customer = makeCustomer() const invoice = makeInvoice({ diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index 5c988146..77789214 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -6,6 +6,7 @@ import { import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' import { resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines' import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' import { MarkInvoicePaidSchema } from '@/lib/api/schemas' import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' @@ -204,6 +205,20 @@ export const POST = withRouteContext( details: { reason: err instanceof Error ? err.message : 'unknown' }, }) } + + // Fail closed: a real invoice must produce a payment voucher. If a helper + // returned null without throwing (e.g. a closed/locked fiscal period), + // refuse to mark the invoice paid — flipping status with no journal entry + // orphans the receivable and diverges the GL from the sub-ledger. + if (!journalEntryId) { + opLog.error('mark-paid produced no journal entry; refusing to mark paid', undefined, { + invoiceId: id, + }) + return errorResponseFromCode('INVOICE_PAID_BOOK_FAILED', opLog, { + requestId, + details: { reason: 'no_journal_entry_created' }, + }) + } } // CAS guard: only update if status is still in a payable state. @@ -221,34 +236,31 @@ export const POST = withRouteContext( if (updateError) { opLog.error('failed to update invoice status', updateError) + // The payment voucher already posted but the invoice row did not flip to + // paid; cancel the orphan so the GL doesn't diverge from the sub-ledger. + if (journalEntryId) { + await cancelOrphanedPaymentEntry( + supabase, + companyId!, + user.id, + journalEntryId, + 'Automatiskt makulerad: fakturauppdatering misslyckades efter bokförd betalning', + ) + } return errorResponse(updateError, opLog, { requestId }) } if (!updateResult || updateResult.length === 0) { - // Status changed between read and write — cancel the orphaned JE and - // document the voucher gap before reporting back. + // Status changed between read and write (concurrent settle) — cancel the + // orphaned payment voucher and document the voucher gap before reporting. if (journalEntryId) { - const { data: orphan } = await supabase - .from('journal_entries') - .select('fiscal_period_id, voucher_series, voucher_number') - .eq('id', journalEntryId) - .single() - - await supabase - .from('journal_entries') - .update({ status: 'cancelled' }) - .eq('id', journalEntryId) - - if (orphan) { - await supabase.from('voucher_gap_explanations').insert({ - company_id: companyId, - fiscal_period_id: orphan.fiscal_period_id, - voucher_series: orphan.voucher_series || 'A', - gap_number: orphan.voucher_number, - explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', - created_by: user.id, - }) - } + await cancelOrphanedPaymentEntry( + supabase, + companyId!, + user.id, + journalEntryId, + 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', + ) } return errorResponseFromCode('INVOICE_PAID_RACE', opLog, { requestId }) } diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index 0ff81eed..ee568763 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -207,6 +207,17 @@ export const POST = withRouteContext( }) } + // Fail closed: every supplier payment must post a voucher. If a helper + // returned null without throwing (e.g. a closed/locked fiscal period), do + // NOT flip the invoice — that would diverge the GL from the AP sub-ledger. + if (!journalEntryId) { + opLog.error('supplier mark-paid produced no journal entry; refusing to mark paid', undefined) + return errorResponseFromCode('SI_PAID_FAILED', opLog, { + requestId, + details: { reason: 'no_journal_entry_created' }, + }) + } + const newRemaining = Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100 const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100 const isFullyPaid = newRemaining <= 0 @@ -228,24 +239,35 @@ export const POST = withRouteContext( if (updateError) { opLog.error('supplier invoice update failed', updateError) + // The payment voucher already posted but the invoice row did not flip; + // cancel the orphan so the GL doesn't diverge from the AP sub-ledger. + await cancelOrphanedPaymentEntry( + supabase, companyId!, user.id, journalEntryId, + 'Automatiskt makulerad: fakturauppdatering misslyckades efter bokförd betalning', + ) return errorResponse(updateError, opLog, { requestId }) } if (!updateResult || updateResult.length === 0) { // CAS guard: another request paid the invoice between our read and write. // Cancel the orphaned JE and document the voucher gap. - if (journalEntryId) { - await cancelOrphanedPaymentEntry( - supabase, companyId!, user.id, journalEntryId, - 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', - ) - } + await cancelOrphanedPaymentEntry( + supabase, companyId!, user.id, journalEntryId, + 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', + ) return errorResponseFromCode('SI_PAID_ALREADY', opLog, { requestId, details: { reason: 'race' }, }) } + // Record the payment row. payment-sync.ts derives the reversal/recalc amount + // from this row (falling back to the FULL paid_amount when the row is + // missing), so a missing row would silently desync a later reversal of a + // PARTIAL payment. The status flip above already succeeded, so on insert + // failure roll the invoice back to its pre-payment state and cancel the + // voucher rather than leave it 'paid' with no payment record (the previous + // code swallowed this error and left the sub-ledger desynced). const { error: paymentError } = await supabase .from('supplier_invoice_payments') .insert({ @@ -261,7 +283,30 @@ export const POST = withRouteContext( }) if (paymentError) { - opLog.warn('failed to record supplier_invoice_payments row', paymentError) + opLog.error('failed to record supplier_invoice_payments row — rolling back', paymentError) + await supabase + .from('supplier_invoices') + .update({ + status: invoice.status, + remaining_amount: invoice.remaining_amount, + paid_amount: invoice.paid_amount, + paid_at: invoice.paid_at ?? null, + payment_journal_entry_id: + (invoice as { payment_journal_entry_id?: string | null }).payment_journal_entry_id ?? null, + }) + .eq('id', id) + .eq('company_id', companyId) + // CAS: only undo OUR flip. If a concurrent request already transitioned + // the row away from newStatus, don't clobber that legitimate state. + .eq('status', newStatus) + await cancelOrphanedPaymentEntry( + supabase, companyId!, user.id, journalEntryId, + 'Automatiskt makulerad: betalningspost kunde inte registreras', + ) + return errorResponseFromCode('SI_PAID_FAILED', opLog, { + requestId, + details: { reason: 'payment_record_insert_failed' }, + }) } // Under kontantmetoden the cash payment entry is the ONLY booking of the diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts index b9019c32..b93e5734 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts @@ -38,6 +38,8 @@ import { createInvoicePaymentJournalEntry, } from '@/lib/bookkeeping/invoice-entries' import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' +import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors' +import { getErrorMessage } from '@/lib/errors/get-error-message' import { eventBus } from '@/lib/events' import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates' import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types' @@ -402,21 +404,36 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string } if (!journalEntryId) { - warnings.push({ - code: 'JOURNAL_ENTRY_NOT_POSTED', - message: - 'Payment journal entry was not created (likely no open fiscal period). Verify the period and book manually if required.', + // Fail closed: a real invoice must produce a posted payment voucher. + // A null here (e.g. no open fiscal period) means nothing was booked, + // so flipping the invoice to paid/partially_paid would diverge the GL + // from the AR sub-ledger. Abort BEFORE the invoice update below — + // mirrors the v1 match-invoice strict mode. + ctx.log.error('mark-paid: no payment journal entry produced — aborting before state mutation', undefined, { + invoiceId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('INVOICE_PAID_BOOK_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'no_journal_entry_created' }, }) } } catch (err) { - ctx.log.error('mark-paid: journal entry creation failed', err as Error, { + if (err instanceof AccountsNotInChartError) { + return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId }) + } + ctx.log.error('mark-paid: payment JE creation failed — aborting before state mutation', err as Error, { invoiceId, companyId: ctx.companyId, }) - warnings.push({ - code: 'JOURNAL_ENTRY_NOT_POSTED', - message: - 'Payment was recorded but the journal entry posting failed. Check the engine logs; reconcile before period close.', + const message = isBookkeepingError(err) + ? getErrorMessage(err, { context: 'invoice' }) + : err instanceof Error + ? err.message + : 'Unknown error' + return v1ErrorResponseFromCode('INVOICE_PAID_BOOK_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { reason: message }, }) } } diff --git a/lib/bookkeeping/__tests__/currency-revaluation.test.ts b/lib/bookkeeping/__tests__/currency-revaluation.test.ts index 0d0c8b12..4fdc507e 100644 --- a/lib/bookkeeping/__tests__/currency-revaluation.test.ts +++ b/lib/bookkeeping/__tests__/currency-revaluation.test.ts @@ -127,7 +127,17 @@ function buildFilterChain(data: unknown[]) { return chain }) - // Make it thenable for await + // Paging stability order — no-op in the mock (data is already deterministic). + chain.order = vi.fn().mockImplementation(() => chain) + + // fetchAllRows paginates via .range(from, to); slice so pagination terminates + // correctly even when a test supplies more than one page of rows. + chain.range = vi.fn().mockImplementation((from: number, to: number) => ({ + then: (resolve: (val: unknown) => void) => + resolve({ data: filtered.slice(from, to + 1), error: null }), + })) + + // Make it thenable for await (used by callers that don't paginate) chain.then = (resolve: (val: unknown) => void) => { resolve({ data: filtered, error: null }) } diff --git a/lib/bookkeeping/currency-revaluation.ts b/lib/bookkeeping/currency-revaluation.ts index 62558757..163f2ae9 100644 --- a/lib/bookkeeping/currency-revaluation.ts +++ b/lib/bookkeeping/currency-revaluation.ts @@ -1,4 +1,5 @@ import type { SupabaseClient } from '@supabase/supabase-js' +import { fetchAllRows } from '@/lib/supabase/fetch-all' import { fetchMultipleRates } from '@/lib/currency/riksbanken' import { createJournalEntry } from '@/lib/bookkeeping/engine' import { @@ -24,19 +25,26 @@ export async function getOpenForeignCurrencyReceivables( supabase: SupabaseClient, companyId: string ): Promise { - const { data, error } = await supabase - .from('invoices') - .select('*') - .eq('company_id', companyId) - .in('status', ['sent', 'overdue']) - .neq('currency', 'SEK') - .not('exchange_rate', 'is', null) - - if (error) { - throw new BookkeepingDatabaseError('fetch_currency_receivables', error.message) + try { + // Paginated with a stable id order so a company with >1000 open FX invoices + // is fully revalued rather than silently truncated at 1000 rows. + return await fetchAllRows(({ from, to }) => + supabase + .from('invoices') + .select('*') + .eq('company_id', companyId) + .in('status', ['sent', 'overdue']) + .neq('currency', 'SEK') + .not('exchange_rate', 'is', null) + .order('id', { ascending: true }) + .range(from, to) + , { dedupeBy: (i) => i.id }) + } catch (err) { + throw new BookkeepingDatabaseError( + 'fetch_currency_receivables', + err instanceof Error ? err.message : 'fetch failed' + ) } - - return (data || []) as Invoice[] } /** @@ -48,19 +56,26 @@ export async function getOpenForeignCurrencyPayables( supabase: SupabaseClient, companyId: string ): Promise { - const { data, error } = await supabase - .from('supplier_invoices') - .select('*') - .eq('company_id', companyId) - .in('status', ['registered', 'approved', 'overdue', 'partially_paid']) - .neq('currency', 'SEK') - .not('exchange_rate', 'is', null) - - if (error) { - throw new BookkeepingDatabaseError('fetch_currency_payables', error.message) + try { + // Paginated with a stable id order so a company with >1000 open FX payables + // is fully revalued rather than silently truncated at 1000 rows. + return await fetchAllRows(({ from, to }) => + supabase + .from('supplier_invoices') + .select('*') + .eq('company_id', companyId) + .in('status', ['registered', 'approved', 'overdue', 'partially_paid']) + .neq('currency', 'SEK') + .not('exchange_rate', 'is', null) + .order('id', { ascending: true }) + .range(from, to) + , { dedupeBy: (i) => i.id }) + } catch (err) { + throw new BookkeepingDatabaseError( + 'fetch_currency_payables', + err instanceof Error ? err.message : 'fetch failed' + ) } - - return (data || []) as SupplierInvoice[] } /** diff --git a/lib/bookkeeping/template-prompt.ts b/lib/bookkeeping/template-prompt.ts deleted file mode 100644 index 4737c792..00000000 --- a/lib/bookkeeping/template-prompt.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Template Prompt Builder - * - * Generates the booking template list section for AI extraction prompts. - * Used by both receipt-analyzer and invoice-analyzer to stay in sync. - */ - -import { BOOKING_TEMPLATES } from './booking-templates' - -/** - * Build the template list section for AI prompts. - * Lists all expense templates with their Swedish name, primary debit account, and VAT rate. - */ -export function buildTemplatePromptSection(): string { - const expenseTemplates = BOOKING_TEMPLATES.filter((t) => t.direction === 'expense') - - const lines = expenseTemplates.map((t) => { - const vatInfo = t.vat_rate > 0 ? `moms ${t.vat_rate * 100}%` : 'momsfri' - return `- ${t.id}: ${t.name_sv} (konto ${t.debit_account}, ${vatInfo})` - }) - - return `BOKFÖRINGSMALLAR (välj den mest passande suggestedTemplateId): -${lines.join('\n')}` -} - -/** - * Build a compact template ID list for validation. - */ -export function getValidTemplateIds(): string[] { - return BOOKING_TEMPLATES.map((t) => t.id) -} diff --git a/lib/deadlines/status-engine.ts b/lib/deadlines/status-engine.ts index bd838357..fe6c8f5f 100644 --- a/lib/deadlines/status-engine.ts +++ b/lib/deadlines/status-engine.ts @@ -56,37 +56,6 @@ export function daysUntilDeadline(dueDate: string): number { return Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)) } -/** - * Determine the automatic status based on deadline date - */ -export function getAutomaticStatus( - dueDate: string, - currentStatus: DeadlineStatus -): DeadlineStatus | null { - const daysUntil = daysUntilDeadline(dueDate) - - // Already in terminal or user-controlled state - if (['submitted', 'confirmed', 'in_progress'].includes(currentStatus)) { - // Check for overdue on submitted (shouldn't happen often) - if (currentStatus === 'submitted' && daysUntil < 0) { - return null // Keep as submitted, don't change to overdue - } - return null - } - - // Past deadline without submission - if (daysUntil < 0 && currentStatus !== 'overdue') { - return 'overdue' - } - - // Within action needed threshold - if (daysUntil <= ACTION_NEEDED_THRESHOLD_DAYS && currentStatus === 'upcoming') { - return 'action_needed' - } - - return null -} - /** * Update deadline statuses automatically (called by daily cron) */ diff --git a/lib/email/resend.ts b/lib/email/resend.ts deleted file mode 100644 index 2d3d2019..00000000 --- a/lib/email/resend.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @deprecated Import from '@/lib/email/service' instead. - * This file exists only for backward compatibility. - */ - -import { getEmailService } from './service' -import type { SendEmailOptions, SendEmailResult } from './service' - -export type { SendEmailOptions, SendEmailResult } - -export async function sendEmail(options: SendEmailOptions): Promise { - return getEmailService().sendEmail(options) -} - -export function isResendConfigured(): boolean { - return getEmailService().isConfigured() -} diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index cf843725..039c7acb 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -28,6 +28,7 @@ import { createCreditNoteJournalEntry, } from '@/lib/bookkeeping/invoice-entries' import { createJournalEntry, findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' +import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' import { runWithActor } from '@/lib/bookkeeping/actor-context-node' import type { CommitActor } from '@/lib/bookkeeping/actor-context' import { correctEntry } from '@/lib/core/bookkeeping/storno-service' @@ -1028,16 +1029,59 @@ async function commitMarkInvoicePaid( ) journalEntryId = je?.id ?? null } + + // Fail closed: a real invoice must produce a posted payment voucher. + // Marking it paid with no journal entry orphans the receivable and + // diverges the GL from the AR sub-ledger. Nothing was posted (the helper + // returned null), so there is no voucher to cancel. + if (!journalEntryId) { + return { + error: + 'Betalningen kunde inte bokföras (ingen verifikation skapades — t.ex. stängd räkenskapsperiod). ' + + 'Fakturan har inte markerats som betald.', + status: 422, + } + } } const now = new Date().toISOString() - const { error: updateError } = await supabase + // CAS guard: only flip from a payable status so a concurrently-settled + // invoice no-ops here instead of double-booking the payment. + const { data: updateResult, error: updateError } = await supabase .from('invoices') .update({ status: 'paid', paid_at: now, paid_amount: invoice.total }) .eq('id', invoiceId) .eq('company_id', companyId) + .in('status', ['sent', 'overdue']) + .select('id') - if (updateError) return { error: 'Failed to update invoice status', status: 500 } + if (updateError) { + // The payment voucher already posted but the invoice row did not flip; + // cancel the orphan so the GL doesn't diverge from the sub-ledger. + if (journalEntryId) { + await cancelOrphanedPaymentEntry( + supabase, companyId, userId, journalEntryId, + 'Automatiskt makulerad: fakturauppdatering misslyckades efter bokförd betalning', + ) + } + return { error: 'Failed to update invoice status', status: 500 } + } + + if (!updateResult || updateResult.length === 0) { + // Race lost: the invoice was settled concurrently between our read and + // write. Cancel the orphaned payment voucher and document the gap rather + // than leaving a double booking. + if (journalEntryId) { + await cancelOrphanedPaymentEntry( + supabase, companyId, userId, journalEntryId, + 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', + ) + } + return { + error: 'Invoice can only be marked as paid when status is "sent" or "overdue"', + status: 409, + } + } return { data: { status: 'paid', journal_entry_id: journalEntryId } } } @@ -3810,7 +3854,7 @@ async function commitPendingOperationInner( } const now = new Date().toISOString() - await supabase + const { error: finalizeError } = await supabase .from('pending_operations') .update({ status: 'committed', @@ -3819,6 +3863,20 @@ async function commitPendingOperationInner( }) .eq('id', pendingOp.id) + if (finalizeError) { + // The executor's side-effects already committed (and are immutable); only + // the terminal status write failed. Without surfacing this, the row would + // sit in 'committing' indefinitely — the expire sweep only targets + // 'pending' ops, so nothing would ever reconcile it. Log loudly with the + // ids needed to finalize manually; the response still reports success + // because the actual work is done. + log.error('failed to finalize pending_operation to committed (left in committing)', finalizeError, { + pendingOperationId: pendingOp.id, + operationType: pendingOp.operation_type, + companyId, + }) + } + return { status: 'committed', data: result.data, diff --git a/lib/reports/__tests__/ar-reconciliation.test.ts b/lib/reports/__tests__/ar-reconciliation.test.ts index 4539ceab..64581037 100644 --- a/lib/reports/__tests__/ar-reconciliation.test.ts +++ b/lib/reports/__tests__/ar-reconciliation.test.ts @@ -10,7 +10,7 @@ let calls: Array<{ method: string; args: unknown[] }> function makeBuilder() { const b: Record = {} - for (const m of ['select', 'eq', 'in']) { + for (const m of ['select', 'eq', 'in', 'order', 'range']) { b[m] = vi.fn().mockImplementation((...args: unknown[]) => { calls.push({ method: m, args }) return b diff --git a/lib/reports/__tests__/supplier-reconciliation.test.ts b/lib/reports/__tests__/supplier-reconciliation.test.ts index e15ffe8b..eb47ef73 100644 --- a/lib/reports/__tests__/supplier-reconciliation.test.ts +++ b/lib/reports/__tests__/supplier-reconciliation.test.ts @@ -10,7 +10,7 @@ let calls: Array<{ method: string; args: unknown[] }> function makeBuilder() { const b: Record = {} - for (const m of ['select', 'eq', 'in']) { + for (const m of ['select', 'eq', 'in', 'order', 'range']) { b[m] = vi.fn().mockImplementation((...args: unknown[]) => { calls.push({ method: m, args }) return b @@ -71,6 +71,25 @@ describe('generateReconciliation', () => { expect(result.is_reconciled).toBe(true) }) + it('paginates the 2440 ledger query — sums >1000 lines instead of truncating at 1000', async () => { + // Regression guard for the silent PostgREST 1000-row cap: fetchAllRows must + // page through ALL ledger lines. A full first page (length === PAGE_SIZE) + // forces a second fetch. + // Unique ids so the dedupe-by-id safety net doesn't collapse rows. + const page1 = Array.from({ length: 1000 }, (_, i) => ({ id: `p1-${i}`, debit_amount: 0, credit_amount: 10 })) + const page2 = Array.from({ length: 500 }, (_, i) => ({ id: `p2-${i}`, debit_amount: 0, credit_amount: 10 })) + results = [ + { data: [], error: null }, // 0: supplier_invoices — none open + { data: page1, error: null }, // 1: 2440 lines page 1 (full → triggers next page) + { data: page2, error: null }, // 2: 2440 lines page 2 (partial → stop) + ] + + const result = await generateReconciliation(supabase, 'company-1', 'period-1') + + // 1500 lines × 10 = 15 000. A 1000-row truncation would wrongly yield 10 000. + expect(result.account_2440_balance).toBe(15000) + }) + it('detects mismatch when difference != 0', async () => { results = [ // 0: supplier_invoices — total 5000 diff --git a/lib/reports/ar-reconciliation.ts b/lib/reports/ar-reconciliation.ts index a1f87ea2..a0cbd55b 100644 --- a/lib/reports/ar-reconciliation.ts +++ b/lib/reports/ar-reconciliation.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' +import { fetchAllRows } from '@/lib/supabase/fetch-all' export interface ARReconciliationResult { ar_ledger_total: number @@ -40,11 +41,23 @@ export async function generateARReconciliation( // total/paid_amount are stored in invoice currency; account 1510 is in SEK // (booked at invoice-date rate), so convert each row before summing. - const { data: invoices } = await supabase - .from('invoices') - .select('total, paid_amount, currency, exchange_rate') - .eq('company_id', companyId) - .in('status', ['sent', 'overdue']) + // Paginated: a company with >1000 open invoices would otherwise be silently + // truncated, manufacturing a phantom reconciliation gap. + const invoices = await fetchAllRows<{ + id: string + total: number | null + paid_amount: number | null + currency: string | null + exchange_rate: number | null + }>(({ from, to }) => + supabase + .from('invoices') + .select('id, total, paid_amount, currency, exchange_rate') + .eq('company_id', companyId) + .in('status', ['sent', 'overdue']) + .order('id', { ascending: true }) + .range(from, to) + ) let unconvertedFxCount = 0 const arLedgerTotal = (invoices || []) @@ -73,28 +86,37 @@ export async function generateARReconciliation( // trial balance / balance sheet use. A corrected invoice flips its original to // status='reversed'; that reversed leg is cancelled by the posted storno, so // both must be summed or a corrected invoice manufactures a phantom gap. - const { data: journalLines } = await supabase - .from('journal_entry_lines') - .select(` - debit_amount, - credit_amount, - journal_entry:journal_entries!inner( - status, - company_id, - fiscal_period_id - ) - `) - .in('account_number', ['1510', '1513']) - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.fiscal_period_id', periodId) - .in('journal_entries.status', ['posted', 'reversed']) + // Paginated with a stable id order (+ dedupe defense) so a period with >1000 + // ledger lines on 1510/1513 isn't silently truncated into a phantom gap. + const journalLines = await fetchAllRows<{ + id: string + debit_amount: number | null + credit_amount: number | null + }>(({ from, to }) => + supabase + .from('journal_entry_lines') + .select(` + id, + debit_amount, + credit_amount, + journal_entry:journal_entries!inner( + status, + company_id, + fiscal_period_id + ) + `) + .in('account_number', ['1510', '1513']) + .eq('journal_entries.company_id', companyId) + .eq('journal_entries.fiscal_period_id', periodId) + .in('journal_entries.status', ['posted', 'reversed']) + .order('id', { ascending: true }) + .range(from, to) + , { dedupeBy: (l) => l.id }) // Both 1510 and 1513 are debit-normal assets: balance = debits - credits let account1510Balance = 0 - if (journalLines) { - for (const line of journalLines) { - account1510Balance = Math.round((account1510Balance + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)) * 100) / 100 - } + for (const line of journalLines) { + account1510Balance = Math.round((account1510Balance + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)) * 100) / 100 } const difference = Math.round((arLedgerTotal - account1510Balance) * 100) / 100 diff --git a/lib/reports/avgifter-basis.ts b/lib/reports/avgifter-basis.ts index 30ff9e42..4cf0f806 100644 --- a/lib/reports/avgifter-basis.ts +++ b/lib/reports/avgifter-basis.ts @@ -1,4 +1,5 @@ import type { SupabaseClient } from '@supabase/supabase-js' +import { fetchAllRows } from '@/lib/supabase/fetch-all' /** * Arbetsgivaravgiftsunderlag — Employer contribution basis report. @@ -53,21 +54,36 @@ export async function generateAvgifterBasis( ): Promise { const r = (x: number) => Math.round(x * 100) / 100 - // Load all salary run employees for booked runs this year - const { data: runEmployees, error } = await supabase - .from('salary_run_employees') - .select(` - avgifter_basis, - avgifter_amount, - avgifter_rate, - salary_run:salary_runs!inner(period_year, period_month, status) - `) - .eq('company_id', companyId) - - if (error) throw new Error(`Failed to load avgifter data: ${error.message}`) + // Load all salary run employees for booked runs this year. + // Paginated with a stable id order: a multi-year/high-headcount company can + // exceed PostgREST's 1000-row cap, and a silent truncation here would + // under-report the arbetsgivaravgifter basis reconciled against AGI filings. + const runEmployees = await fetchAllRows<{ + id: string + avgifter_basis: number + avgifter_amount: number + avgifter_rate: number + // PostgREST's type-level select parser models an embedded resource as an + // array, so keep this `unknown` (the rows are read via an explicit cast + // below) to stay assignable regardless of postgrest-js version. + salary_run: unknown + }>(({ from, to }) => + supabase + .from('salary_run_employees') + .select(` + id, + avgifter_basis, + avgifter_amount, + avgifter_rate, + salary_run:salary_runs!inner(period_year, period_month, status) + `) + .eq('company_id', companyId) + .order('id', { ascending: true }) + .range(from, to) + , { dedupeBy: (e) => e.id }) // Filter to booked runs for the year - const bookedForYear = (runEmployees || []).filter(sre => { + const bookedForYear = runEmployees.filter(sre => { const run = sre.salary_run as unknown as { period_year: number; period_month: number; status: string } | null return run && run.period_year === year && run.status === 'booked' }) diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index d4dc0ac8..0b1727e1 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -686,13 +686,18 @@ async function writeMasterData( await Promise.all( tables.map(async (t) => { try { - const rows = await fetchAllRows>(({ from, to }) => { + const rows = await fetchAllRows<{ id: string } & Record>(({ from, to }) => { let q = supabase.from(t.name).select('*').eq('company_id', companyId) if (t.orderBy) { q = q.order(t.orderBy, { ascending: true }) } - return q.range(from, to) - }) + // Always end on the unique PK so paging has a stable TOTAL order. A + // non-unique display order (e.g. created_at) or no order at all + // silently SKIPS/DUPLICATES rows across page boundaries — data loss in + // a statutory 7-year retention archive. (All these tables have an + // `id uuid` PK.) dedupeBy is defense-in-depth against the duplicate case. + return q.order('id', { ascending: true }).range(from, to) + }, { dedupeBy: (r) => r.id }) data.file(t.file, JSON.stringify(rows, null, 2)) } catch (err) { data.file( diff --git a/lib/reports/ink2/ink2-engine.ts b/lib/reports/ink2/ink2-engine.ts index 47dd90aa..70da375c 100644 --- a/lib/reports/ink2/ink2-engine.ts +++ b/lib/reports/ink2/ink2-engine.ts @@ -727,17 +727,20 @@ export async function generateINK2Declaration( throw new Error('INK2 declaration is only for aktiebolag (limited company)') } - // Fetch all posted journal entries with lines for this period - const { data: entries, error: entriesError } = await supabase - .from('journal_entries') - .select('*, lines:journal_entry_lines(*)') - .eq('company_id', companyId) - .eq('fiscal_period_id', fiscalPeriodId) - .in('status', ['posted', 'reversed']) - - if (entriesError) { - throw new Error(`Failed to fetch journal entries: ${entriesError.message}`) - } + // Fetch all posted journal entries with lines for this period. + // Paginated: a period can exceed PostgREST's 1000-row cap, and a silent + // truncation here would under-report the INK2 tax declaration. PostgREST + // ranges count parent rows, so the embedded lines come with each entry. + const entries = await fetchAllRows(({ from, to }) => + supabase + .from('journal_entries') + .select('*, lines:journal_entry_lines(*)') + .eq('company_id', companyId) + .eq('fiscal_period_id', fiscalPeriodId) + .in('status', ['posted', 'reversed']) + .order('id', { ascending: true }) + .range(from, to) + , { dedupeBy: (e) => e.id }) // Fetch chart of accounts for account names const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) => diff --git a/lib/reports/ne-bilaga/ne-engine.ts b/lib/reports/ne-bilaga/ne-engine.ts index eb3ef39a..60ab4a6c 100644 --- a/lib/reports/ne-bilaga/ne-engine.ts +++ b/lib/reports/ne-bilaga/ne-engine.ts @@ -195,17 +195,20 @@ export async function generateNEDeclaration( throw new Error('NE declaration is only for enskild firma (sole proprietorship)') } - // Fetch all posted journal entries with lines for this period - const { data: entries, error: entriesError } = await supabase - .from('journal_entries') - .select('*, lines:journal_entry_lines(*)') - .eq('company_id', companyId) - .eq('fiscal_period_id', fiscalPeriodId) - .in('status', ['posted', 'reversed']) - - if (entriesError) { - throw new Error(`Failed to fetch journal entries: ${entriesError.message}`) - } + // Fetch all posted journal entries with lines for this period. + // Paginated: a period can exceed PostgREST's 1000-row cap, and a silent + // truncation here would under-report the NE-bilaga tax declaration. PostgREST + // ranges count parent rows, so the embedded lines come with each entry. + const entries = await fetchAllRows(({ from, to }) => + supabase + .from('journal_entries') + .select('*, lines:journal_entry_lines(*)') + .eq('company_id', companyId) + .eq('fiscal_period_id', fiscalPeriodId) + .in('status', ['posted', 'reversed']) + .order('id', { ascending: true }) + .range(from, to) + , { dedupeBy: (e) => e.id }) // Fetch chart of accounts for account names const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) => diff --git a/lib/reports/supplier-reconciliation.ts b/lib/reports/supplier-reconciliation.ts index ee737239..0753c997 100644 --- a/lib/reports/supplier-reconciliation.ts +++ b/lib/reports/supplier-reconciliation.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' +import { fetchAllRows } from '@/lib/supabase/fetch-all' export interface ReconciliationResult { supplier_ledger_total: number @@ -33,11 +34,22 @@ export async function generateReconciliation( // remaining_amount is stored in invoice currency; account 2440 is in SEK // (booked at invoice-date rate), so convert each row before summing. - const { data: invoices } = await supabase - .from('supplier_invoices') - .select('remaining_amount, currency, exchange_rate') - .eq('company_id', companyId) - .in('status', ['registered', 'approved', 'partially_paid', 'overdue']) + // Paginated: a company with >1000 open supplier invoices would otherwise be + // silently truncated, manufacturing a phantom reconciliation gap. + const invoices = await fetchAllRows<{ + id: string + remaining_amount: number | null + currency: string | null + exchange_rate: number | null + }>(({ from, to }) => + supabase + .from('supplier_invoices') + .select('id, remaining_amount, currency, exchange_rate') + .eq('company_id', companyId) + .in('status', ['registered', 'approved', 'partially_paid', 'overdue']) + .order('id', { ascending: true }) + .range(from, to) + ) let unconvertedFxCount = 0 const supplierLedgerTotal = (invoices || []) @@ -68,29 +80,38 @@ export async function generateReconciliation( // debit balance. (This is exactly the false −41 121,25 kr "Ej avstämd" gap a // fully-paid, fully-corrected company hit: posted-only = −41 121,25, but // posted+reversed = 0, matching the leverantörsreskontra.) - const { data: journalLines } = await supabase - .from('journal_entry_lines') - .select(` - debit_amount, - credit_amount, - journal_entry:journal_entries!inner( - status, - company_id, - fiscal_period_id - ) - `) - .eq('account_number', '2440') - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.fiscal_period_id', periodId) - .in('journal_entries.status', ['posted', 'reversed']) + // Paginated with a stable id order (+ dedupe defense) so a period with >1000 + // ledger lines on 2440 isn't silently truncated into a phantom gap. + const journalLines = await fetchAllRows<{ + id: string + debit_amount: number | null + credit_amount: number | null + }>(({ from, to }) => + supabase + .from('journal_entry_lines') + .select(` + id, + debit_amount, + credit_amount, + journal_entry:journal_entries!inner( + status, + company_id, + fiscal_period_id + ) + `) + .eq('account_number', '2440') + .eq('journal_entries.company_id', companyId) + .eq('journal_entries.fiscal_period_id', periodId) + .in('journal_entries.status', ['posted', 'reversed']) + .order('id', { ascending: true }) + .range(from, to) + , { dedupeBy: (l) => l.id }) // Account 2440 is a liability: credit normal balance // Balance = credits - debits let account2440Balance = 0 - if (journalLines) { - for (const line of journalLines) { - account2440Balance = Math.round((account2440Balance + (line.credit_amount || 0) - (line.debit_amount || 0)) * 100) / 100 - } + for (const line of journalLines) { + account2440Balance = Math.round((account2440Balance + (Number(line.credit_amount) || 0) - (Number(line.debit_amount) || 0)) * 100) / 100 } const difference = Math.round((supplierLedgerTotal - account2440Balance) * 100) / 100 diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index 70ea4bca..6ca626d7 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -337,21 +337,27 @@ export async function calculateVatDeclaration( if (t) revenueByRate[rate] = round(t.credit - t.debit) } - // Count journal entries by source type for metadata - const { data: entryCounts } = await supabase - .from('journal_entries') - .select('source_type') - .eq('company_id', companyId) - .in('status', ['posted', 'reversed']) - .gte('entry_date', start) - .lte('entry_date', end) + // Count journal entries by source type for metadata. + // Paginated with a stable id order so the invoice/transaction counts don't + // silently truncate at 1000 entries for a busy VAT period. + const entryCounts = await fetchAllRows<{ id: string; source_type: string }>(({ from, to }) => + supabase + .from('journal_entries') + .select('id, source_type') + .eq('company_id', companyId) + .in('status', ['posted', 'reversed']) + .gte('entry_date', start) + .lte('entry_date', end) + .order('id', { ascending: true }) + .range(from, to) + , { dedupeBy: (e) => e.id }) const invoiceSources = new Set([ 'invoice_created', 'invoice_paid', 'invoice_cash_payment', 'credit_note', ]) let invoiceCount = 0 let transactionCount = 0 - for (const e of entryCounts || []) { + for (const e of entryCounts) { if (invoiceSources.has(e.source_type)) invoiceCount++ else if (e.source_type === 'bank_transaction') transactionCount++ } diff --git a/lib/salary/__tests__/engangsskatt.test.ts b/lib/salary/__tests__/engangsskatt.test.ts deleted file mode 100644 index d1e73813..00000000 --- a/lib/salary/__tests__/engangsskatt.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { calculateEngangsskatt } from '../engangsskatt' - -describe('calculateEngangsskatt', () => { - it('calculates 0% tax for very low annual income', () => { - const result = calculateEngangsskatt(5000, 1000) - // Annual: 1000 × 12 + 5000 = 17,000 → bracket 0-20,000 → 0% - expect(result.taxRate).toBe(0.00) - expect(result.taxAmount).toBe(0) - }) - - it('calculates tax for moderate annual income', () => { - const result = calculateEngangsskatt(10000, 25000) - // Annual: 25000 × 12 + 10000 = 310,000 → bracket 200,001-350,000 → 30% - expect(result.taxRate).toBe(0.30) - expect(result.taxAmount).toBe(3000) - }) - - it('calculates higher tax when annual income crosses state tax threshold', () => { - const result = calculateEngangsskatt(50000, 55000) - // Annual: 55000 × 12 + 50000 = 710,000 → bracket 660,401-950,000 → 52% - expect(result.taxRate).toBe(0.52) - expect(result.taxAmount).toBe(26000) - }) - - it('uses total annual income including bonus for bracket lookup', () => { - const result = calculateEngangsskatt(100000, 40000) - // Annual: 40000 × 12 + 100000 = 580,000 → bracket 500,001-660,400 → 34% - expect(result.taxRate).toBe(0.34) - expect(result.taxAmount).toBe(34000) - }) - - it('returns calculation steps for transparency', () => { - const result = calculateEngangsskatt(10000, 30000) - expect(result.steps.length).toBe(2) - expect(result.steps[0].label).toBe('Beräknad årsinkomst') - expect(result.annualIncomeEstimate).toBe(370000) // 30000 × 12 + 10000 - }) - - it('handles very high income bracket', () => { - const result = calculateEngangsskatt(200000, 120000) - // Annual: 120000 × 12 + 200000 = 1,640,000 → bracket ≥1,500,001 → 57% - expect(result.taxRate).toBe(0.57) - expect(result.taxAmount).toBe(114000) - }) -}) diff --git a/lib/salary/effective-values.ts b/lib/salary/effective-values.ts deleted file mode 100644 index c537e005..00000000 --- a/lib/salary/effective-values.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Effective salary values — coalesce per-employee overrides over the - * engine-computed defaults. - * - * Used by booking (salary-entries.ts) and AGI (agi/generate-declaration.ts) - * so a manual adjustment for FoU-avdrag or jämkning flows through to both - * the ledger and the Skatteverket declaration. - */ -export interface SalaryRunEmployeeWithOverrides { - tax_withheld: number - tax_withheld_override?: number | null - avgifter_amount: number - avgifter_amount_override?: number | null - avgifter_basis: number - avgifter_basis_override?: number | null -} - -export function effectiveTax(sre: SalaryRunEmployeeWithOverrides): number { - return sre.tax_withheld_override ?? sre.tax_withheld -} - -export function effectiveAvgifter(sre: SalaryRunEmployeeWithOverrides): number { - return sre.avgifter_amount_override ?? sre.avgifter_amount -} - -export function effectiveAvgifterBasis(sre: SalaryRunEmployeeWithOverrides): number { - return sre.avgifter_basis_override ?? sre.avgifter_basis -} diff --git a/lib/salary/engangsskatt.ts b/lib/salary/engangsskatt.ts deleted file mode 100644 index d12732bb..00000000 --- a/lib/salary/engangsskatt.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Engångsskatt — tax on one-time payments (bonuses, retroactive pay, etc.) - * - * Per Skatteverket: One-time payments use a percentage-based tax table, - * not the regular monthly tax table. The rate depends on the employee's - * estimated annual income level. - * - * Skatteverket publishes "Tabell för beräkning av skatteavdrag på - * engångsbelopp" annually. - * - * Simplified 2026 brackets (based on published rates): - */ - -export interface EngangsskattResult { - taxRate: number - taxAmount: number - annualIncomeEstimate: number - steps: { label: string; formula: string; output: number }[] -} - -/** - * 2026 engångsskatt brackets. - * Rate includes both kommunalskatt and statlig skatt. - * Based on average kommunalskatt (~32.5%). - */ -const ENGANGSSKATT_BRACKETS_2026: Array<{ fromAnnual: number; toAnnual: number; rate: number }> = [ - { fromAnnual: 0, toAnnual: 20000, rate: 0.00 }, - { fromAnnual: 20001, toAnnual: 50000, rate: 0.10 }, - { fromAnnual: 50001, toAnnual: 100000, rate: 0.20 }, - { fromAnnual: 100001, toAnnual: 200000, rate: 0.25 }, - { fromAnnual: 200001, toAnnual: 350000, rate: 0.30 }, - { fromAnnual: 350001, toAnnual: 500000, rate: 0.32 }, - { fromAnnual: 500001, toAnnual: 660400, rate: 0.34 }, - { fromAnnual: 660401, toAnnual: 950000, rate: 0.52 }, // State tax kicks in - { fromAnnual: 950001, toAnnual: 1500000, rate: 0.55 }, - { fromAnnual: 1500001, toAnnual: Infinity, rate: 0.57 }, -] - -/** - * Calculate engångsskatt for a one-time payment. - * - * @param oneTimeAmount - The bonus/one-time payment amount - * @param monthlySalary - Employee's regular monthly salary (for estimating annual income) - * @param monthsWorkedThisYear - Months already worked (for pro-rata annual estimate) - */ -export function calculateEngangsskatt( - oneTimeAmount: number, - monthlySalary: number, - monthsWorkedThisYear: number = 12 -): EngangsskattResult { - const r = (x: number) => Math.round(x * 100) / 100 - - // Estimate annual income = regular salary × 12 + one-time amount - const annualRegular = monthlySalary * 12 - const annualIncomeEstimate = r(annualRegular + oneTimeAmount) - - // Find the bracket based on total annual income including the one-time payment - let taxRate = 0.30 // default fallback - for (const bracket of ENGANGSSKATT_BRACKETS_2026) { - if (annualIncomeEstimate >= bracket.fromAnnual && annualIncomeEstimate <= bracket.toAnnual) { - taxRate = bracket.rate - break - } - } - - const taxAmount = r(oneTimeAmount * taxRate) - - return { - taxRate, - taxAmount, - annualIncomeEstimate, - steps: [ - { - label: 'Beräknad årsinkomst', - formula: 'monthly × 12 + engångsbelopp', - output: annualIncomeEstimate, - }, - { - label: `Engångsskatt (${(taxRate * 100).toFixed(0)}%)`, - formula: `engångsbelopp × ${(taxRate * 100).toFixed(0)}%`, - output: taxAmount, - }, - ], - } -} diff --git a/lib/salary/salary-transaction-matcher.ts b/lib/salary/salary-transaction-matcher.ts deleted file mode 100644 index ee29e37f..00000000 --- a/lib/salary/salary-transaction-matcher.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js' -import { createLogger } from '@/lib/logger' - -const log = createLogger('salary-transaction-matcher') - -/** - * Auto-match salary payment bank transactions to salary journal entries. - * - * When bank transactions arrive (via Enable Banking sync or CSV import), - * this matcher looks for transactions that correspond to salary net payments: - * - Transaction date matches salary run payment_date - * - Transaction amount matches -total_net (negative = outgoing payment) - * - Transaction is not already categorized - * - * On match, links the transaction to the salary journal entry via - * the existing reconciliation system. - * - * Per BFL 5 kap: Bank statement reconciliation is required. - * Per BFNAR 2013:2: Automated matching must be logged. - */ -export async function matchSalaryTransactions( - supabase: SupabaseClient, - companyId: string, - transactionIds: string[] -): Promise<{ matched: number }> { - if (transactionIds.length === 0) return { matched: 0 } - - // Load unmatched transactions - const { data: transactions, error: txError } = await supabase - .from('transactions') - .select('id, date, amount, description') - .eq('company_id', companyId) - .in('id', transactionIds) - .is('journal_entry_id', null) - .lt('amount', 0) // Only outgoing payments - - if (txError || !transactions || transactions.length === 0) return { matched: 0 } - - // Load booked salary runs for matching - const { data: salaryRuns, error: srError } = await supabase - .from('salary_runs') - .select('id, payment_date, total_net, salary_entry_id, status') - .eq('company_id', companyId) - .eq('status', 'booked') - - if (srError || !salaryRuns || salaryRuns.length === 0) return { matched: 0 } - - let matched = 0 - - for (const tx of transactions) { - // Look for salary run where: - // - payment_date matches transaction date - // - total_net matches |transaction.amount| (within 1 SEK tolerance for öresavrundning) - const txAmount = Math.abs(tx.amount) - - const matchingRun = salaryRuns.find(run => { - if (run.payment_date !== tx.date) return false - const diff = Math.abs(run.total_net - txAmount) - return diff <= 1 // 1 SEK tolerance for rounding - }) - - if (matchingRun && matchingRun.salary_entry_id) { - // Link transaction to the salary journal entry - const { error: linkError } = await supabase - .from('transactions') - .update({ - journal_entry_id: matchingRun.salary_entry_id, - is_business: true, - category: 'salary', - }) - .eq('id', tx.id) - .is('journal_entry_id', null) // CAS guard - - if (!linkError) { - matched++ - log.info(`Matched salary transaction ${tx.id} to run ${matchingRun.id} (${txAmount} SEK)`) - - // Log the match in payment_match_log for audit trail - await supabase.from('payment_match_log').insert({ - company_id: companyId, - transaction_id: tx.id, - match_type: 'salary_payment', - matched_entity_id: matchingRun.id, - matched_entity_type: 'salary_run', - amount: txAmount, - auto_matched: true, - }) - } - } - } - - if (matched > 0) { - log.info(`Auto-matched ${matched} salary transaction(s) for company ${companyId}`) - } - - return { matched } -} diff --git a/lib/tax/calculator.ts b/lib/tax/calculator.ts deleted file mode 100644 index a74f76e4..00000000 --- a/lib/tax/calculator.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { EntityType, TaxEstimate } from '@/types' - -// Current Swedish tax rates (2026) -const TAX_RATES = { - egenavgifter: 0.2897, // 28.97% for EF - bolagsskatt: 0.206, // 20.6% for AB - arbetsgivaravgifter: 0.3142, // 31.42% employer contributions - municipalTax: 0.3238, // 32.38% average municipal tax for 2026 - stateTax: 0.20, // 20% state income tax on high incomes -} - -// State income tax threshold (brytpunkt) for 2026 -const STATE_TAX_THRESHOLD = 643100 // Taxable income above this gets +20% state tax -// Note: The "brytpunkt" is 660,400 kr but that includes grundavdrag - -/** - * Calculate progressive grundavdrag (basic deduction) for 2026 - * Based on Skatteverket's table - varies by income level - * - * Income ranges and corresponding grundavdrag: - * - 0 - 25,100: grundavdrag = income (no tax) - * - 25,100 - 58,900: 25,100 kr - * - 58,900 - 161,000: Progressive increase up to 45,600 kr - * - 161,000 - 184,900: 45,600 kr (maximum) - * - 184,900 - 466,000: Progressive decrease - * - 466,000+: 17,400 kr (minimum for high earners) - */ -export function calculateGrundavdrag(taxableIncome: number): number { - if (taxableIncome <= 0) return 0 - - // Very low income - grundavdrag equals income (no tax) - if (taxableIncome <= 25100) { - return taxableIncome - } - - // Low income - minimum grundavdrag - if (taxableIncome <= 58900) { - return 25100 - } - - // Rising phase - interpolate between 25,100 and 45,600 - if (taxableIncome <= 161000) { - const progress = (taxableIncome - 58900) / (161000 - 58900) - return Math.round(25100 + progress * (45600 - 25100)) - } - - // Maximum grundavdrag zone - if (taxableIncome <= 184900) { - return 45600 - } - - // Declining phase - interpolate between 45,600 and 17,400 - if (taxableIncome <= 466000) { - const progress = (taxableIncome - 184900) / (466000 - 184900) - return Math.round(45600 - progress * (45600 - 17400)) - } - - // High earners - minimum grundavdrag - return 17400 -} - -/** - * Calculate tax estimates for Enskild Firma - * @param netIncome - Net income after expenses - * @param preliminaryTaxPaidYTD - Preliminary tax paid year to date - * @param _reserved - Reserved for future use (deductions extension) - * @param momsFromUnpaidInvoices - VAT from unpaid invoices that needs to be paid - */ -export function calculateEFTax( - netIncome: number, - preliminaryTaxPaidYTD: number = 0, - _reserved?: unknown, - momsFromUnpaidInvoices: number = 0 -): TaxEstimate { - if (netIncome <= 0) { - return { - egenavgifter: 0, - income_tax: 0, - state_tax: 0, - moms_to_pay: momsFromUnpaidInvoices, - total_tax_liability: momsFromUnpaidInvoices, - preliminary_paid_ytd: preliminaryTaxPaidYTD, - difference: momsFromUnpaidInvoices - preliminaryTaxPaidYTD, - } - } - - // Egenavgifter (self-employment contributions) - 28.97% - const egenavgifter = netIncome * TAX_RATES.egenavgifter - - // Taxable income (after egenavgifter deduction) - // 25% of egenavgifter is deductible from taxable income - const egenavgifterDeduction = egenavgifter * 0.25 - const taxableIncome = netIncome - egenavgifterDeduction - - // Calculate progressive grundavdrag based on income level - const grundavdrag = calculateGrundavdrag(taxableIncome) - const incomeAfterGrundavdrag = Math.max(0, taxableIncome - grundavdrag) - - // Municipal income tax (~32.38% average for 2026) - const municipalTax = incomeAfterGrundavdrag * TAX_RATES.municipalTax - - // State income tax (20% on income above threshold) - // Only applies to taxable income above 643,100 kr - const incomeAboveThreshold = Math.max(0, incomeAfterGrundavdrag - STATE_TAX_THRESHOLD) - const stateTax = incomeAboveThreshold * TAX_RATES.stateTax - - const totalIncomeTax = municipalTax + stateTax - const totalTax = egenavgifter + totalIncomeTax + momsFromUnpaidInvoices - - return { - egenavgifter: Math.round(egenavgifter), - income_tax: Math.round(municipalTax), - state_tax: Math.round(stateTax), - moms_to_pay: Math.round(momsFromUnpaidInvoices), - total_tax_liability: Math.round(totalTax), - preliminary_paid_ytd: preliminaryTaxPaidYTD, - difference: Math.round(totalTax - preliminaryTaxPaidYTD), - grundavdrag: Math.round(grundavdrag), - } -} - -/** - * Calculate tax estimates for Aktiebolag - * @param profit - Company profit - * @param salaryCostsYTD - Salary costs year to date (for reference) - * @param preliminaryTaxPaidYTD - Preliminary tax paid year to date - * @param momsFromUnpaidInvoices - VAT from unpaid invoices - */ -export function calculateABTax( - profit: number, - salaryCostsYTD: number = 0, - preliminaryTaxPaidYTD: number = 0, - momsFromUnpaidInvoices: number = 0 -): TaxEstimate { - // Bolagsskatt on profit - const bolagsskatt = profit > 0 ? profit * TAX_RATES.bolagsskatt : 0 - - // Arbetsgivaravgifter on salaries (already included in salary costs usually) - // This is just for reference - const totalTax = bolagsskatt + momsFromUnpaidInvoices - - return { - bolagsskatt: Math.round(bolagsskatt), - moms_to_pay: Math.round(momsFromUnpaidInvoices), - total_tax_liability: Math.round(totalTax), - preliminary_paid_ytd: preliminaryTaxPaidYTD, - difference: Math.round(totalTax - preliminaryTaxPaidYTD), - } -} - -/** - * Calculate available balance (after tax reservations) - */ -export function calculateAvailableBalance( - currentBalance: number, - taxEstimate: TaxEstimate -): number { - const taxReservation = Math.max(0, taxEstimate.total_tax_liability - taxEstimate.preliminary_paid_ytd) - return Math.max(0, currentBalance - taxReservation) -} - -/** - * Format tax breakdown for display - */ -export function formatTaxBreakdown( - entityType: EntityType, - taxEstimate: TaxEstimate -): { label: string; amount: number }[] { - const items: { label: string; amount: number }[] = [] - - if (entityType === 'enskild_firma') { - if (taxEstimate.egenavgifter) { - items.push({ label: 'Egenavgifter (28,97%)', amount: taxEstimate.egenavgifter }) - } - if (taxEstimate.income_tax) { - items.push({ label: 'Kommunalskatt (~32%)', amount: taxEstimate.income_tax }) - } - if (taxEstimate.state_tax && taxEstimate.state_tax > 0) { - items.push({ label: 'Statlig skatt (20%)', amount: taxEstimate.state_tax }) - } - } else { - if (taxEstimate.bolagsskatt) { - items.push({ label: 'Bolagsskatt (20,6%)', amount: taxEstimate.bolagsskatt }) - } - } - - if (taxEstimate.moms_to_pay > 0) { - items.push({ label: 'Moms att betala', amount: taxEstimate.moms_to_pay }) - } - - return items -} - -/** - * Calculate balance breakdown for display - * Shows disponibelt, skatt reservation, and moms reservation separately - */ -export function calculateBalanceBreakdown( - currentBalance: number, - taxEstimate: TaxEstimate -): { - disponibelt: number - skattReservation: number - momsReservation: number - totalLocked: number -} { - // VAT reservation (separate from income tax) - const momsReservation = Math.max(0, taxEstimate.moms_to_pay) - - // Income tax/egenavgifter reservation (excluding VAT and what's already paid) - const incomeTaxLiability = taxEstimate.total_tax_liability - momsReservation - const skattReservation = Math.max(0, incomeTaxLiability - taxEstimate.preliminary_paid_ytd) - - const totalLocked = skattReservation + momsReservation - const disponibelt = Math.max(0, currentBalance - totalLocked) - - return { - disponibelt, - skattReservation, - momsReservation, - totalLocked, - } -} - -/** - * Calculate available balance (after tax reservations) - * Enhanced version that returns more details - */ -export function calculateAvailableBalanceDetailed( - currentBalance: number, - taxEstimate: TaxEstimate -): { - availableBalance: number - breakdown: ReturnType -} { - const breakdown = calculateBalanceBreakdown(currentBalance, taxEstimate) - return { - availableBalance: breakdown.disponibelt, - breakdown, - } -} diff --git a/lib/vat/eu-countries.ts b/lib/vat/eu-countries.ts index 5eaf5849..9e6cec5f 100644 --- a/lib/vat/eu-countries.ts +++ b/lib/vat/eu-countries.ts @@ -49,65 +49,3 @@ export const EU_COUNTRIES: EUCountry[] = [ { code: 'ES', name: 'Spanien', nameEn: 'Spain', vatPrefix: 'ES', currency: 'EUR' }, { code: 'SE', name: 'Sverige', nameEn: 'Sweden', vatPrefix: 'SE', currency: 'SEK' }, ] - -/** EU country codes excluding Sweden (for intra-community checks) */ -export const EU_COUNTRY_CODES_EXCL_SE = EU_COUNTRIES - .filter(c => c.code !== 'SE') - .map(c => c.code) - -/** All EU country codes including Sweden */ -export const EU_COUNTRY_CODES = EU_COUNTRIES.map(c => c.code) - -/** - * Build a lookup set of all known names/codes for EU countries (excluding Sweden). - * Handles ISO codes, English names, and Swedish names — all uppercased for matching. - */ -const EU_LOOKUP_EXCL_SE = new Set( - EU_COUNTRIES - .filter(c => c.code !== 'SE') - .flatMap(c => [c.code, c.name, c.nameEn].map(s => s.toUpperCase())) -) - -const EU_LOOKUP_INCL_SE = new Set( - EU_COUNTRIES - .flatMap(c => [c.code, c.name, c.nameEn].map(s => s.toUpperCase())) -) - -/** - * Check if a country value is an EU member state (excluding Sweden). - * Accepts ISO codes ("DE"), English names ("Germany"), or Swedish names ("Tyskland"). - */ -export function isEUCountry(country: string): boolean { - return EU_LOOKUP_EXCL_SE.has(country.trim().toUpperCase()) -} - -/** - * Check if a country value is an EU member state (including Sweden). - * Accepts ISO codes, English names, or Swedish names. - */ -export function isEUCountryIncludingSE(country: string): boolean { - return EU_LOOKUP_INCL_SE.has(country.trim().toUpperCase()) -} - -/** Get EU country data by ISO code, English name, or Swedish name */ -export function getEUCountry(country: string): EUCountry | undefined { - const upper = country.trim().toUpperCase() - return EU_COUNTRIES.find( - c => c.code === upper || c.name.toUpperCase() === upper || c.nameEn.toUpperCase() === upper - ) -} - -/** Get the VIES VAT prefix for a country (note: Greece uses 'EL' not 'GR') */ -export function getVatPrefix(countryCode: string): string | undefined { - return getEUCountry(countryCode)?.vatPrefix -} - -/** - * Normalize a country value to its ISO 3166-1 alpha-2 code. - * Accepts ISO codes, English names, or Swedish names. - * Returns the input uppercased if no match is found. - */ -export function toCountryCode(country: string): string { - const found = getEUCountry(country) - return found ? found.code : country.trim().toUpperCase() -} diff --git a/lib/webhooks/diff.ts b/lib/webhooks/diff.ts deleted file mode 100644 index 41fa62ad..00000000 --- a/lib/webhooks/diff.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Compute previous_attributes for update-style webhook events. - * - * Stripe pattern: when an entity changes, the webhook payload carries the - * NEW state in `data.object` and a `previous_attributes` field that holds - * ONLY the fields whose values changed, with their PRIOR values. This lets - * receivers diff without an extra GET round-trip. - * - * We compute this from a (priorRow, currentRow) pair captured by the route - * handler before/after its mutation. A field is considered "changed" if - * the JSON-serialised values differ. - * - * Phase 6 PR-1 only emits this for events that fundamentally describe - * mutations of an existing resource (invoice.paid, supplier_invoice.paid, - * supplier_invoice.approved, period.locked, period.unlocked, - * period.year_closed, salary_run.approved, salary_run.booked, ...). Pure - * "created" events leave previous_attributes null. - */ - -export function computePreviousAttributes>( - prior: T | null | undefined, - current: T | null | undefined, -): Record | null { - if (!prior || !current) return null - const diff: Record = {} - // Iterate over the union of keys so a removed field is also surfaced. - const keys = new Set([...Object.keys(prior), ...Object.keys(current)]) - for (const k of keys) { - const a = prior[k] - const b = current[k] - if (!shallowEquals(a, b)) { - diff[k] = a - } - } - return Object.keys(diff).length > 0 ? diff : null -} - -function shallowEquals(a: unknown, b: unknown): boolean { - if (a === b) return true - if (a === null || b === null || a === undefined || b === undefined) return false - // Cheap structural check via JSON; sufficient for the row-shaped objects - // we diff. Field order is stable because both sides are projected from - // the same SELECT. - try { - return JSON.stringify(a) === JSON.stringify(b) - } catch { - return false - } -} diff --git a/next.config.ts b/next.config.ts index 89c70bc8..60c4efec 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,8 +7,6 @@ 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'", // Recapt: scoped to the two specific hosts the SDK actually contacts — @@ -29,7 +27,7 @@ const cspDirectives = [ // "Det här innehållet har blockerats" in Chrome. Firefox uses PDF.js and // Edge uses its own viewer, so neither hits this. See crbug.com/271452. "object-src 'self' blob:", - `frame-src 'self' blob: ${supabaseUrl}${activepiecesUrl ? ` ${activepiecesUrl}` : ""}`, + `frame-src 'self' blob: ${supabaseUrl}`, "frame-ancestors 'none'", ].join("; "); diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index befc5f1c..7c98380f 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -1,7 +1,7 @@ { "_comment": "Ratchet baseline for scripts/checks/no-new-antipatterns.mjs. These counts may only decrease. Re-run with --update after a migration lowers them. Goal: both reach 0 (A1 route-auth campaign, D1 rounding codemod).", "rawRouteAuth": { - "count": 168, + "count": 165, "files": [ "app/api/account/delete/route.ts", "app/api/account/password/route.ts", @@ -31,7 +31,6 @@ "app/api/bookkeeping/journal-entries/[id]/chain/route.ts", "app/api/bookkeeping/journal-entries/[id]/no-document-required/route.ts", "app/api/bookkeeping/journal-entries/[id]/notes/route.ts", - "app/api/bookkeeping/journal-entries/[id]/route.ts", "app/api/bookkeeping/journal-entries/route.ts", "app/api/bookkeeping/mapping-rules/evaluate/route.ts", "app/api/bookkeeping/mapping-rules/route.ts", @@ -58,7 +57,6 @@ "app/api/events/route.ts", "app/api/extensions/[sector]/[slug]/data/route.ts", "app/api/extensions/[sector]/[slug]/settings/route.ts", - "app/api/extensions/ext/[...path]/route.ts", "app/api/extensions/skatteverket/skattekonto/drift/route.ts", "app/api/import/sie/[id]/route.ts", "app/api/import/sie/create-accounts/route.ts", @@ -67,7 +65,6 @@ "app/api/invoices/[id]/convert/route.ts", "app/api/invoices/[id]/mark-sent/route.ts", "app/api/invoices/[id]/pdf/route.ts", - "app/api/invoices/[id]/route.ts", "app/api/invoices/preview-pdf/route.ts", "app/api/kpi/preferences/route.ts", "app/api/mcp-oauth/authorize/route.ts", @@ -174,6 +171,6 @@ ] }, "naiveOreRound": { - "count": 659 + "count": 655 } }