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:
co-authored by
Claude Opus 4.8
parent
b800dcd403
commit
f8504f3bd0
+14
-1
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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("; ");
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user