fix: audit batch — pagination truncation, MFA/dead-code cleanup, mark-paid fail-closed (#841)

* fix(reports): paginate 8 more report/ledger queries (1000-row truncation)

Raw .select() without fetchAllRows() silently caps at PostgREST's 1000-row
limit, producing wrong statutory output for high-volume companies. Following
#806 (trial-balance/VAT), wrap the remaining offenders in
fetchAllRows + a stable .order('id') + dedupeBy:

- ink2-engine / ne-engine: INK2 & NE-bilaga tax declarations under-counted
- ar-reconciliation (1510/1513), supplier-reconciliation (2440): phantom
  "Ej avstämd" gaps
- full-archive-export: 7-year DR archive (added a unique total order so rows
  are not silently skipped/duplicated across pages)
- avgifter-basis, currency-revaluation, vat-declaration

Adds a regression guard test asserting >1000 ledger lines are summed, not
truncated at 1000.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): close extension-dispatcher MFA gap, scope /api/events to API key, sweep dead code

Security/correctness:
- ext/[...path] dispatcher now uses requireAuth() instead of inline
  supabase.auth.getUser(), enforcing MFA (AAL2) on hosted across the whole
  enabled-extension surface (banking sync, document upload/booking, supplier
  invoices, migration). Ratchets antipatterns-baseline raw-route-auth 168->165.
- /api/events now filters by the API key's bound company_id instead of the
  user's active company (was a cross-company read with a scoped key).
- enable-banking OAuth callback calls ensureInitialized() at module load so
  the PSD2 consent audit event (ASVS V16 / GDPR Art.30) isn't dropped on a
  cold-start instance.

Dead-code sweep (all confirmed zero importers):
- delete lib/tax/calculator.ts, lib/salary/engangsskatt.ts (+test),
  lib/email/resend.ts, lib/salary/salary-transaction-matcher.ts,
  lib/webhooks/diff.ts, lib/salary/effective-values.ts,
  lib/bookkeeping/template-prompt.ts
- trim unused lib/vat/eu-countries.ts helpers (keep EU_COUNTRIES)
- remove dead getAutomaticStatus() and the abandoned Activepieces CSP entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(invoices): fail closed when a payment journal entry doesn't post

Three mark-paid paths (legacy route, v1 API, agent commit) diverged on the
"mark paid but the JE failed" case — two would flip the invoice to paid (or
leave an orphaned posted voucher) with no booking, silently diverging the GL
from the AR/AP sub-ledger. Unify on fail-closed:

- legacy + v1 + agent commitMarkInvoicePaid: never mark paid without a posted
  voucher; on a null/failed JE return INVOICE_PAID_BOOK_FAILED before any
  state mutation (v1 mirrors the match-invoice strict mode).
- agent path: add the .in('status',[...]).select('id') CAS guard and cancel
  the orphaned voucher (cancelOrphanedPaymentEntry) on a lost race or update
  error, matching the web route.
- legacy route: cancel the orphan on a non-race update error too (was only
  handled on the race branch).
- supplier mark-paid: stop swallowing a failed supplier_invoice_payments
  insert — that row drives the reversal amount in payment-sync; roll back the
  status flip and cancel the voucher instead.
- pending-ops orchestrator: error-check the terminal 'committed' write so an
  op stranded in 'committing' (the expire sweep only targets 'pending') is at
  least logged loudly.

Adds a guard test for the legacy fail-closed path. Full unit suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): unblock core build + address compliance-review findings

- avgifter-basis.ts: fix the core-build TypeScript error — PostgREST's
  type-level select parser models the salary_run embed as an array, which
  wasn't assignable to the object-typed generic. Type it `unknown` (rows are
  read via an explicit cast), making it robust across postgrest-js versions.
- /api/events: add a non-null companyId guard before the event_log query
  (defense-in-depth for the API-key-bound scope) — addresses ASVS V8.2.1 /
  ISO A.5.15.
- supplier mark-paid: add a CAS guard (.eq('status', newStatus)) to the
  payment-insert-failure rollback so a concurrent settlement can't be
  clobbered — addresses ASVS V2.3.
- dispatcher: add an AAL2 regression test asserting a non-MFA session is
  rejected (403) and the extension handler never runs — addresses the
  GDPR Art.32 review ask for the single extension chokepoint.

Verified deletions are safe: effective-values.ts was a dead duplicate — the
live AGI/payslip path inlines the same `?? override` coalescing
(generate-declaration.ts), so AGI correctness is unaffected.

next build: exit 0. Full unit suite: 6147 passing. ESLint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-30 14:34:23 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b800dcd403
commit f8504f3bd0
32 changed files with 522 additions and 870 deletions
+14 -1
View File
@@ -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)
@@ -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.
@@ -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',
+10 -10
View File
@@ -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
@@ -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({
+35 -23
View File
@@ -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 })
}
@@ -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
@@ -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 },
})
}
}
@@ -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 })
}
+39 -24
View File
@@ -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<Invoice[]> {
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<Invoice>(({ 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<SupplierInvoice[]> {
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<SupplierInvoice>(({ 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[]
}
/**
-31
View File
@@ -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)
}
-31
View File
@@ -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)
*/
-17
View File
@@ -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<SendEmailResult> {
return getEmailService().sendEmail(options)
}
export function isResendConfigured(): boolean {
return getEmailService().isConfigured()
}
+61 -3
View File
@@ -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,
@@ -10,7 +10,7 @@ let calls: Array<{ method: string; args: unknown[] }>
function makeBuilder() {
const b: Record<string, unknown> = {}
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
@@ -10,7 +10,7 @@ let calls: Array<{ method: string; args: unknown[] }>
function makeBuilder() {
const b: Record<string, unknown> = {}
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
+46 -24
View File
@@ -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
+29 -13
View File
@@ -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<AvgifterBasisReport> {
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'
})
+8 -3
View File
@@ -686,13 +686,18 @@ async function writeMasterData(
await Promise.all(
tables.map(async (t) => {
try {
const rows = await fetchAllRows<Record<string, unknown>>(({ from, to }) => {
const rows = await fetchAllRows<{ id: string } & Record<string, unknown>>(({ 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(
+14 -11
View File
@@ -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<JournalEntry>(({ 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 }) =>
+14 -11
View File
@@ -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<JournalEntry>(({ 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 }) =>
+45 -24
View File
@@ -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
+15 -9
View File
@@ -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++
}
-46
View File
@@ -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)
})
})
-28
View File
@@ -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
}
-85
View File
@@ -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,
},
],
}
}
-97
View File
@@ -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 }
}
-241
View File
@@ -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<typeof calculateBalanceBreakdown>
} {
const breakdown = calculateBalanceBreakdown(currentBalance, taxEstimate)
return {
availableBalance: breakdown.disponibelt,
breakdown,
}
}
-62
View File
@@ -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()
}
-49
View File
@@ -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<T extends Record<string, unknown>>(
prior: T | null | undefined,
current: T | null | undefined,
): Record<string, unknown> | null {
if (!prior || !current) return null
const diff: Record<string, unknown> = {}
// 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
}
}
+1 -3
View File
@@ -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("; ");
+2 -5
View File
@@ -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
}
}