diff --git a/DECISIONS.md b/DECISIONS.md index d34edc49..179363a3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1019,6 +1019,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-15] Confirmed intentional (Swedish-review note): with override=true and an unresolvable filename, the attach endpoint links a document to any same-company, same-declared-year, posted verifikat, migrated or not. This mirrors /api/documents/[id]/link, which imposes no filename check at all, so it introduces no new capability class; tenant, year and period-lock enforcement always apply. [2026-08-15] BankID tabs bind to a random non-secret `flowId` signed into the shared flow cookie and sent as a request header after start or explicit resume: mode pinning alone cannot distinguish two same-mode tabs, so an older tab could otherwise silently follow, cancel, or complete a newer person's identification after `/start` replaced the origin-wide cookie. This supersedes the 2026-08-15 decision that deliberately skipped mode matching on active polls. [2026-08-15] Did not apply BankID migration `20260815120000` to Supabase staging during PR #1625 follow-through: read-only reconciliation found 14 staging-only and 99 branch-only migration versions, so applying on top of that divergent ledger would violate the no-orphan rule. Production is reconciled with zero remote-only versions and exactly this PR migration local-only; hosted pg-real validates the migration until staging is reconciled. +[2026-08-17] Klarmarkera (closed_externally) ships without a pg-real test: the migration is one additive boolean column, no trigger/RPC/RLS change; the close-path guards are unit-tested in period-service.test.ts. [2026-08-17] MCP article refs on create_invoice only, not update_invoice: the update tool's full-replace item semantics need their own design pass; also refuse cross-currency price prefill instead of converting, the agent must pick the currency explicitly. [2026-08-17] Session replay masking inverted to deny-by-default (founder-directed after user pushback on session recording): ALL input values are masked (maskAllInputs with no maskInputFn, so rrweb masks wholesale; placeholders are attributes and stay visible) and ALL text is masked unless it sits under data-ph-unmask chrome or a th (dry-table column headers are raw th per page, so the mask function treats th as chrome rather than tagging hundreds of sites). Chrome tags live on the shared primitives (PageHeader, Label, Button except role=combobox triggers which render selected values, TabsTrigger, Badge, Card/Dialog/Sheet titles and descriptions, tooltips, help popovers, empty states, settings labels); tagged chrome is still pattern-scrubbed for amounts and identity numbers, and data-ph-mask beats data-ph-unmask so call sites that interpolate user data into chrome stay masked. Toasts (title AND description) deliberately NOT tagged: they interpolate user data at too many call sites to audit, and an audit found live leaks (deadline titles, bank account names) in titles alone. Confirm-dialog wrappers (ConfirmDialog, ConfirmationDialog, DestructiveConfirmDialog) force data-ph-mask on their titles/descriptions centrally: convention 10 makes confirm copy describe the object being acted on, so it is user data by design; that one change closed 14+ audited leak sites. A very-thorough audit of user data flowing into unmasked primitives ran in the same change and every found site got a call-site data-ph-mask. Failure mode for untagged new UI is over-masking, never leakage. Supersedes the 2026-08-06 pattern-based default; privacy policy and RoPA updated in the same change. [2026-08-17] Betalfil missing-bankgiro UX: advisory warning in PaymentFilePanel (download stays enabled, route stays the authority) + click-to-prefill from tic_snapshot instead of auto-seeding company_settings.bankgiro: sender payment data must be user-confirmed, and the snapshot is unvalidated registry JSON. diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx index 7c68bd05..66e62980 100644 --- a/app/(dashboard)/bookkeeping/year-end/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -4,6 +4,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { Card, CardContent } from '@/components/ui/card' import { EmptyState } from '@/components/ui/empty-state' +import { AttnLine } from '@/components/ui/attn-line' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { ContextPicker } from '@/components/common/ContextPicker' import { Skeleton } from '@/components/ui/skeleton' import { CalendarPlus, Check, Lock } from 'lucide-react' @@ -36,6 +38,12 @@ interface PeriodOption { name: string period_start: string period_end: string + /** + * True when the period can actually be closed here (open, ended, no closing + * entry). The dropdown can also hold a known-but-ineligible period from the + * URL; the klarmarkera affordance must not render for that one. + */ + eligible: boolean } export default function YearEndPage() { @@ -79,9 +87,9 @@ export default function YearEndPage() { const { data } = (await res.json()) as { data: FiscalPeriod[] } const all = data ?? [] const today = new Date().toISOString().split('T')[0] - const eligible = all.filter( - (p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today, - ) + const eligible: PeriodOption[] = all + .filter((p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today) + .map((p) => ({ ...p, eligible: true })) // Oldest first: accountants close in order. eligible.sort((a, b) => a.period_start.localeCompare(b.period_start)) if (cancelled) return @@ -100,7 +108,7 @@ export default function YearEndPage() { if (!known) { setSelectedPeriodId(eligible.length > 0 ? eligible[0].id : null) } else if (!eligible.some((p) => p.id === selectedPeriodId)) { - options = [...eligible, known].sort((a, b) => + options = [...eligible, { ...known, eligible: false }].sort((a, b) => a.period_start.localeCompare(b.period_start), ) } @@ -212,6 +220,47 @@ export default function YearEndPage() { [selectedPeriodId, periods], ) + // ---- Klarmarkera: year already closed in a previous bookkeeping system ---- + // Imported historical years (SIE) land here as "pending bokslut" even though + // the bokslut was done in the old software. The confirm dialog POSTs + // close-external, which closes + locks the period without a closing entry. + const [confirmExternalOpen, setConfirmExternalOpen] = useState(false) + const selectedOption = periods?.find((p) => p.id === selectedPeriodId) ?? null + + const markClosedExternally = useCallback(async () => { + if (!selectedPeriodId) return + try { + const res = await fetch( + `/api/bookkeeping/fiscal-periods/${selectedPeriodId}/close-external`, + { method: 'POST' }, + ) + const body = await res.json() + if (!res.ok) { + toast({ + title: 'Kunde inte klarmarkera perioden', + description: getErrorMessage(body?.error), + variant: 'destructive', + }) + return + } + toast({ + title: 'Perioden klarmarkerad', + description: `${selectedOption?.name ?? 'Perioden'} är nu markerad som avslutad i tidigare program.`, + }) + // Drop back to "no selection": the load effect refetches and picks the + // next eligible period (the marked one no longer qualifies). + setPeriods(null) + setSelectedPeriodId(null) + setStep('preflight') + } catch (err) { + toast({ + title: 'Kunde inte klarmarkera perioden', + description: getErrorMessage(err), + variant: 'destructive', + }) + } + }, [selectedPeriodId, selectedOption?.name, toast]) + return (
@@ -242,6 +291,32 @@ export default function YearEndPage() {
+ {showWizard && step === 'preflight' && selectedOption?.eligible && !navigationBlocked && ( + setConfirmExternalOpen(true) }} + > + Är bokslutet för {selectedOption.name} redan gjort i ett tidigare bokföringsprogram? + + )} + + + {selectedOption?.name ?? 'Perioden'} markeras som avslutad i ett tidigare + bokföringsprogram. Perioden stängs och låses: inga nya verifikat kan bokföras i den, + och inget bokslutsverifikat skapas här eftersom bokslutet redan finns i det gamla + programmet. Rapporter som bygger på periodens bokslut (t.ex. jämförelseår i nästa + årsredovisning och INK2 för perioden) kan sakna uppgifter och behöver i så fall + hämtas från det tidigare programmet. Åtgärden loggas i behandlingshistoriken. + + } + confirmLabel="Klarmarkera" + onConfirm={markClosedExternally} + /> + {periods === null && !periodsError && ( diff --git a/app/api/bookkeeping/fiscal-periods/[id]/close-external/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/close-external/__tests__/route.test.ts new file mode 100644 index 00000000..dfc1ba39 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/close-external/__tests__/route.test.ts @@ -0,0 +1,82 @@ +/** + * Tests for POST /api/bookkeeping/fiscal-periods/[id]/close-external + * ("klarmarkera": period closed in a previous bookkeeping system). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/core/bookkeeping/period-service', () => ({ + markPeriodClosedExternally: vi.fn(), +})) + +import { markPeriodClosedExternally } from '@/lib/core/bookkeeping/period-service' +import { POST } from '../route' + +const mockMark = vi.mocked(markPeriodClosedExternally) +const idParams = { params: Promise.resolve({ id: 'period-1' }) } + +beforeEach(() => { + vi.clearAllMocks() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +describe('POST /api/bookkeeping/fiscal-periods/[id]/close-external', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams) + expect(res.status).toBe(401) + }) + + it('returns 403 when the caller lacks write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'forbidden' }, { status: 403 }), + }) + const res = await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams) + expect(res.status).toBe(403) + expect(mockMark).not.toHaveBeenCalled() + }) + + it('maps a service refusal to 400 with a safe message', async () => { + mockMark.mockRejectedValue(new Error('Period is already closed')) + const { status, body } = await parseJsonResponse<{ error: string }>( + await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams) + ) + expect(status).toBe(400) + expect(typeof body.error).toBe('string') + expect(body.error.length).toBeGreaterThan(0) + }) + + it('marks the period on the happy path', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockMark.mockResolvedValue({ id: 'period-1', is_closed: true, closed_externally: true } as any) + const { status, body } = await parseJsonResponse<{ + data: { is_closed: boolean; closed_externally: boolean } + }>(await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams)) + expect(status).toBe(200) + expect(body.data.is_closed).toBe(true) + expect(body.data.closed_externally).toBe(true) + expect(mockMark).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'period-1') + }) +}) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/close-external/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/close-external/route.ts new file mode 100644 index 00000000..be626428 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/close-external/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { markPeriodClosedExternally } from '@/lib/core/bookkeeping/period-service' +import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' + +// "Klarmarkera": mark an imported historical year as closed in a previous +// bookkeeping system. Same legacy `{ error: string }` failure shape as the +// sibling close route: the year-end UI reads it directly. +export const POST = withRouteContext( + 'period.close_external', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId } = ctx + + try { + const period = await markPeriodClosedExternally(supabase, companyId, user.id, id) + return NextResponse.json({ data: period }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? getUserErrorMessage(err) : 'Failed to mark period as closed' }, + { status: 400 } + ) + } + }, + { requireWrite: true }, +) diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index a464276b..f222543c 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -221,6 +221,7 @@ describe('POST /api/supplier-invoices', () => { const createdInvoice = makeSupplierInvoice({ id: 'si-1' }) // Fetch supplier + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) // RPC get_next_arrival_number enqueue({ data: 5 }) @@ -269,6 +270,7 @@ describe('POST /api/supplier-invoices', () => { const createdInvoice = makeSupplierInvoice({ id: 'si-deferred' }) // Fetch supplier + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) // RPC get_next_arrival_number enqueue({ data: 5 }) @@ -315,6 +317,7 @@ describe('POST /api/supplier-invoices', () => { enqueue({ data: { id: DOCUMENT_UUID, journal_entry_id: null }, error: null }) enqueue({ data: null, error: null }) + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) enqueue({ data: 6 }) enqueue({ data: createdInvoice, error: null }) @@ -384,6 +387,7 @@ describe('POST /api/supplier-invoices', () => { const supplier = makeSupplier({ id: VALID_UUID }) const createdInvoice = makeSupplierInvoice({ id: 'si-1' }) + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) enqueue({ data: 5 }) enqueue({ data: createdInvoice, error: null }) @@ -423,6 +427,7 @@ describe('POST /api/supplier-invoices', () => { const supplier = makeSupplier({ id: VALID_UUID }) const createdInvoice = makeSupplierInvoice({ id: 'si-1' }) + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) enqueue({ data: 6 }) enqueue({ data: createdInvoice, error: null }) @@ -453,6 +458,7 @@ describe('POST /api/supplier-invoices', () => { const supplier = makeSupplier({ id: VALID_UUID }) const createdInvoice = makeSupplierInvoice({ id: 'si-1' }) + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) enqueue({ data: 7 }) enqueue({ data: createdInvoice, error: null }) @@ -483,6 +489,7 @@ describe('POST /api/supplier-invoices', () => { const createdInvoice = makeSupplierInvoice({ id: 'si-1', invoice_date: '2099-06-01' }) // Fetch supplier + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) // RPC get_next_arrival_number enqueue({ data: 9 }) @@ -521,6 +528,7 @@ describe('POST /api/supplier-invoices', () => { const supplier = makeSupplier({ id: VALID_UUID }) // Fetch supplier + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) // RPC get_next_arrival_number enqueue({ data: 8 }) @@ -573,6 +581,7 @@ describe('POST /api/supplier-invoices', () => { it('returns 409 without credit_note_id when existing invoice is not credited', async () => { const supplier = makeSupplier({ id: VALID_UUID }) + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) enqueue({ data: 9 }) enqueue({ @@ -616,6 +625,7 @@ describe('POST /api/supplier-invoices', () => { it('returns generic 409 when existing row lookup races to nothing', async () => { const supplier = makeSupplier({ id: VALID_UUID }) + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) enqueue({ data: 10 }) enqueue({ @@ -652,6 +662,7 @@ describe('POST /api/supplier-invoices', () => { it('falls through to 500 for non-23505 insert errors', async () => { const supplier = makeSupplier({ id: VALID_UUID }) + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) enqueue({ data: 11 }) enqueue({ data: null, error: { code: '23502', message: 'NOT NULL violation' } }) @@ -678,6 +689,7 @@ describe('POST /api/supplier-invoices', () => { const createdInvoice = makeSupplierInvoice({ id: 'si-priv-1', status: 'paid' }) // Fetch supplier + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) // Fetch company.entity_type (paidPrivately branch) enqueue({ data: { entity_type: 'aktiebolag' }, error: null }) @@ -734,6 +746,7 @@ describe('POST /api/supplier-invoices', () => { const supplier = makeSupplier({ id: VALID_UUID }) const createdInvoice = makeSupplierInvoice({ id: 'si-priv-2', status: 'paid' }) + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) enqueue({ data: { entity_type: 'enskild_firma' }, error: null }) enqueue({ data: 13 }) @@ -779,6 +792,7 @@ describe('POST /api/supplier-invoices', () => { const supplier = makeSupplier({ id: VALID_UUID }) const createdInvoice = makeSupplierInvoice({ id: 'si-1' }) + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) enqueue({ data: 7 }) enqueue({ data: createdInvoice, error: null }) @@ -825,6 +839,7 @@ describe('POST /api/supplier-invoices', () => { const supplier = makeSupplier({ id: VALID_UUID }) const createdInvoice = makeSupplierInvoice({ id: 'si-1' }) + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: supplier, error: null }) enqueue({ data: 8 }) enqueue({ data: createdInvoice, error: null }) @@ -954,6 +969,7 @@ describe('POST /api/supplier-invoices: exchange rate + SEK amounts', () => { captured.find((c) => c.table === 'supplier_invoices')?.payload function enqueueHappyPath() { + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null }) // supplier lookup enqueue({ data: 7 }) // get_next_arrival_number enqueue({ data: makeSupplierInvoice({ id: 'si-fx' }), error: null }) // insert invoice @@ -1064,6 +1080,7 @@ describe('POST /api/supplier-invoices: exchange rate + SEK amounts', () => { }) it('refuses the create with SI_FX_RATE_MISSING when no rate can be resolved', async () => { + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null }) mockFetchExchangeRate.mockResolvedValue(null) @@ -1206,6 +1223,7 @@ describe('POST /api/supplier-invoices: särskild löneskatt (apply_slp)', () => }) it('happy path: apply_slp on a 7412 line is stored on the item and reaches the generator', async () => { + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null }) // supplier lookup enqueue({ data: 9 }) // get_next_arrival_number enqueue({ data: makeSupplierInvoice({ id: 'si-slp' }), error: null }) // insert invoice @@ -1240,6 +1258,7 @@ describe('POST /api/supplier-invoices: särskild löneskatt (apply_slp)', () => }) it('defaults apply_slp to false when omitted', async () => { + enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null }) enqueue({ data: 10 }) enqueue({ data: makeSupplierInvoice({ id: 'si-noslp' }), error: null }) @@ -1262,3 +1281,85 @@ describe('POST /api/supplier-invoices: särskild löneskatt (apply_slp)', () => expect(rows[0].apply_slp).toBe(false) }) }) + +describe('POST /api/supplier-invoices: icke momsregistrerad (vat_registered=false)', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + function vrBody(items: Record[], overrides: Record = {}) { + return { + supplier_id: VALID_UUID, + supplier_invoice_number: 'LF-VR', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items, + ...overrides, + } + } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('rejects a line carrying moms with SI_CREATE_INVALID_INPUT', async () => { + enqueue({ data: { vat_registered: false }, error: null }) // vat_registered guard + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: vrBody([ + { description: 'Material', amount: 1000, account_number: '4010', vat_rate: 0.25 }, + ]), + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT') + expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled() + }) + + it('lets reverse charge pass the guard (self-assessment is separate from deduction)', async () => { + enqueue({ data: { vat_registered: false }, error: null }) // vat_registered guard + enqueue({ data: null, error: { message: 'Not found' } }) // supplier lookup fails + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: vrBody( + [{ description: 'EU-tjänst', amount: 1000, account_number: '4531', vat_rate: 0 }], + { reverse_charge: true }, + ), + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + // Reaching SUPPLIER_NOT_FOUND proves the moms guard did not fire. + expect(status).toBe(404) + expect(body.error.code).toBe('SUPPLIER_NOT_FOUND') + }) + + it('defaults an omitted vat_rate to 0 instead of 25 %', async () => { + enqueue({ data: { vat_registered: false }, error: null }) // vat_registered guard + enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null }) // supplier lookup + enqueue({ data: 5 }) // get_next_arrival_number + enqueue({ data: makeSupplierInvoice({ id: 'si-vr' }), error: null }) // insert invoice + enqueue({ data: [], error: null }) // insert items + enqueue({ data: { accounting_method: 'accrual' }, error: null }) // company settings + mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-vr' }) + enqueue({ data: null, error: null }) // update registration_journal_entry_id + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: vrBody([{ description: 'Material', amount: 1000, account_number: '4010' }]), + }) + const response = await POST(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + const itemsInsert = findCall('supplier_invoice_items', 'insert') + const rows = itemsInsert![0] as Array> + expect(rows[0].vat_rate).toBe(0) + expect(rows[0].vat_amount).toBe(0) + }) +}) diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index c702e68a..cc524414 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -174,6 +174,30 @@ export const POST = withRouteContext( } } + // Icke momsregistrerad verksamhet has no deduction right for input VAT + // (avdragsrätt, 13 kap. ML 2023:200): a line carrying moms would book + // 2641 the company can never reclaim. The form hides the moms controls; + // this guard covers THIS route only. The v1 REST route, the inbox convert + // route and the MCP staged executor still default 25 % and need the same + // treatment in a follow-up sweep. Reverse charge stays allowed: + // self-assessment is a separate obligation from deduction. + const { data: vatSettings } = await supabase + .from('company_settings') + .select('vat_registered') + .eq('company_id', companyId) + .single() + const vatRegistered = vatSettings?.vat_registered !== false + if ( + !vatRegistered && + !body.reverse_charge && + body.items.some((item) => (item.vat_rate ?? 0) > 0 || (item.vat_amount ?? 0) > 0) + ) { + return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, { + requestId, + details: { reason: 'company is not VAT-registered; supplier invoice lines cannot carry moms' }, + }) + } + const { data: supplier, error: supplierError } = await supabase .from('suppliers') .select('*') @@ -236,7 +260,9 @@ export const POST = withRouteContext( } const items = body.items.map((item, index) => { - const vatRate = item.vat_rate ?? 0.25 + // An omitted rate defaults to 25 % only for VAT-registered companies; + // icke momsregistrerade book the gross amount with no moms line. + const vatRate = item.vat_rate ?? (vatRegistered ? 0.25 : 0) const lineTotal = item.amount != null ? Math.round(item.amount * 100) / 100 : Math.round((item.quantity ?? 1) * (item.unit_price ?? 0) * 100) / 100 diff --git a/components/invoices/ArticleCombobox.tsx b/components/invoices/ArticleCombobox.tsx new file mode 100644 index 00000000..3397eca5 --- /dev/null +++ b/components/invoices/ArticleCombobox.tsx @@ -0,0 +1,252 @@ +'use client' + +import { useState, useRef, useEffect, useMemo, useCallback } from 'react' +import { Input } from '@/components/ui/input' +import { foldText } from '@/lib/bookkeeping/account-search' + +export interface ArticleComboboxItem { + id: string + article_number: string | null + name: string +} + +interface ArticleComboboxProps { + /** Selected article id, or null for a free-text line. */ + value: string | null + /** Already sorted by the caller (sortArticles). */ + articles: ArticleComboboxItem[] + /** Receives the picked article id, or 'none' for the free-text option. */ + onChange: (value: string) => void + /** Label for the pinned free-text option ("Egen rad"). */ + freeTextLabel: string + /** Trigger placeholder when nothing is selected. */ + placeholder: string + /** Empty-state text when the search matches nothing. */ + emptyLabel: string + disabled?: boolean + ariaLabel?: string +} + +function articleLabel(a: ArticleComboboxItem): string { + return a.article_number ? `${a.article_number}: ${a.name}` : a.name +} + +/** + * Searchable article picker for invoice lines. Replaces the plain Select whose + * only matching was Radix's label-prefix typeahead: for numbered articles that + * meant number-only lookup, and typing "skruv" found nothing. Free-text search + * here matches both name and article number, diacritics-folded (foldText), on + * the already-loaded article list. Same input-trigger dropdown pattern as + * AccountCombobox. + */ +export default function ArticleCombobox({ + value, + articles, + onChange, + freeTextLabel, + placeholder, + emptyLabel, + disabled = false, + ariaLabel, +}: ArticleComboboxProps) { + const selected = value ? articles.find((a) => a.id === value) ?? null : null + // Free-text lines display the "Egen rad" label, matching the Select this + // replaces (which pinned value 'none' and never showed a placeholder). + const selectedLabel = selected ? articleLabel(selected) : freeTextLabel + + const [search, setSearch] = useState(selectedLabel) + const [isOpen, setIsOpen] = useState(false) + const [highlightedIndex, setHighlightedIndex] = useState(0) + // Typing narrows the list; a fresh focus shows everything so the field also + // works as a browse dropdown, exactly like the Select it replaces. + const [hasTyped, setHasTyped] = useState(false) + const containerRef = useRef(null) + const listRef = useRef(null) + // True while a pointer interaction is what is about to focus the field. + // Keyboard focus (Tab) must NOT auto-open: with the list open a bare Enter + // would select the highlighted row, and the old Select treated Tab+Enter as + // a no-op. Pointer focus keeps the click-to-browse behavior. + const pointerDownRef = useRef(false) + + // Sync external value changes (applyArticle, draft restore) into the field. + useEffect(() => { + setSearch(selectedLabel) + // selectedLabel is derived from value + articles; both belong here. + }, [selectedLabel]) + + const filtered = useMemo(() => { + const q = foldText(search.trim()) + // A committed selection sits in the field as its full label; treating it + // as a filter would show exactly one row. Browse instead. + if (!q || !hasTyped) return articles + return articles.filter((a) => + foldText(`${a.article_number ?? ''} ${a.name}`).includes(q), + ) + }, [articles, search, hasTyped]) + + // Options list: the free-text "Egen rad" choice stays pinned on top. + type Option = { key: string; label: string; muted: boolean } + const options = useMemo( + () => [ + { key: 'none', label: freeTextLabel, muted: true }, + ...filtered.map((a) => ({ key: a.id, label: articleLabel(a), muted: false })), + ], + [filtered, freeTextLabel], + ) + + // While typing, highlight the first actual match (index 1: index 0 is the + // pinned "Egen rad"), so type-and-Enter picks the searched article instead + // of silently detaching the line to free text. + useEffect(() => { + setHighlightedIndex(hasTyped && filtered.length > 0 ? 1 : 0) + }, [options.length, search, hasTyped, filtered.length]) + + // Opening on a committed selection starts the highlight ON that selection, + // like the Select this replaces, so Enter re-confirms instead of switching. + const openList = useCallback(() => { + const currentKey = value ?? 'none' + const idx = options.findIndex((o) => o.key === currentKey) + setHighlightedIndex(idx >= 0 ? idx : 0) + setIsOpen(true) + }, [options, value]) + + useEffect(() => { + if (!isOpen || !listRef.current) return + listRef.current + .querySelector('[data-highlighted="true"]') + ?.scrollIntoView({ block: 'nearest' }) + }, [highlightedIndex, isOpen]) + + useEffect(() => { + function handleClickOutside(e: MouseEvent | TouchEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('touchstart', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('touchstart', handleClickOutside) + } + }, []) + + const select = useCallback( + (option: Option) => { + // Re-selecting the committed value is a no-op close: applyArticle + // re-applies the article's description/price/unit, which would clobber + // per-line edits, and re-selecting "Egen rad" on a free-text line would + // needlessly null the article link. The old Radix Select behaved the + // same (onValueChange only fires on an actual change). + if (option.key !== (value ?? 'none')) { + onChange(option.key) + } + setSearch(option.label) + setHasTyped(false) + setIsOpen(false) + }, + [onChange, value], + ) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') { + e.preventDefault() + openList() + } + return + } + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setHighlightedIndex((prev) => Math.min(prev + 1, options.length - 1)) + break + case 'ArrowUp': + e.preventDefault() + setHighlightedIndex((prev) => Math.max(prev - 1, 0)) + break + case 'Enter': + e.preventDefault() + if (options[highlightedIndex]) select(options[highlightedIndex]) + break + case 'Escape': + e.preventDefault() + setIsOpen(false) + break + } + } + + const handleBlur = () => { + setIsOpen(false) + // Delay so a dropdown mousedown wins, then snap the field back to the + // committed selection: a half-typed search must not linger as a label. + setTimeout(() => { + setSearch(selectedLabel) + setHasTyped(false) + }, 150) + } + + return ( +
+ { + setSearch(e.target.value) + setHasTyped(true) + if (!isOpen) setIsOpen(true) + }} + onPointerDown={() => { + pointerDownRef.current = true + }} + onClick={() => { + // Clicking an already-focused field reopens the list (onFocus will + // not fire again in that case). + if (!isOpen) openList() + }} + onFocus={(e) => { + setHasTyped(false) + if (pointerDownRef.current) openList() + pointerDownRef.current = false + // Typing should replace the committed label, not append to it. + e.currentTarget.select() + }} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + placeholder={placeholder} + autoComplete="off" + disabled={disabled} + role="combobox" + aria-expanded={isOpen} + aria-label={ariaLabel} + /> + + {isOpen && !disabled && ( +
+ {options.map((option, index) => ( + + ))} + {filtered.length === 0 && ( +

{emptyLabel}

+ )} +
+ )} +
+ ) +} diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index ff6f4e33..1e635f79 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -31,6 +31,7 @@ import { } from '@/components/invoices/line-vat-rates' import { AttnLine } from '@/components/ui/attn-line' import { sortArticles } from '@/lib/articles/sort' +import ArticleCombobox from '@/components/invoices/ArticleCombobox' import { getAmountToPay } from '@/lib/invoices/rounding' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, Copy } from 'lucide-react' @@ -1243,6 +1244,24 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat } } + // A failed Zod validation is otherwise invisible: handleSubmit never reaches + // onSubmit, the buttons stay enabled and look normal, and the only signal is + // inline error text the user may have scrolled past. Toast + scroll so a + // blocked "Granska & skapa" / "Spara som utkast" never reads as a dead button. + function onInvalidSubmit(_errors: unknown, event?: React.BaseSyntheticEvent) { + toast({ + title: t('validation_toast_title'), + description: t('validation_toast_description'), + variant: 'destructive', + }) + const root = (event?.target as HTMLElement | null)?.closest('form') + // The inline error paragraphs render on the next React commit; scroll after. + setTimeout(() => { + const firstError = (root ?? document).querySelector('p.text-destructive') + firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }, 100) + } + // "Spara som utkast": save an unnumbered draft (save_as_draft) without the // review dialog. The invoice gets no F-number and fires no invoice.created // until the user opens it and clicks "Granska & skapa" (finalize). Same @@ -1539,7 +1558,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat )} -
+
{/* Main content */}
@@ -1777,22 +1796,15 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat name={`items.${index}.article_id`} control={control} render={({ field }) => ( - + applyArticle(index, v)} + freeTextLabel={t('article_free_text')} + placeholder={t('article_placeholder')} + emptyLabel={t('article_search_empty')} + ariaLabel={t('article_label')} + /> )} />
@@ -1843,6 +1855,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat className="text-right tabular-nums" {...register(`items.${index}.quantity`, { valueAsNumber: true })} /> + {errors.items?.[index]?.quantity && ( +

+ {errors.items[index].quantity?.message} +

+ )}
@@ -1864,6 +1881,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat )} /> + {errors.items?.[index]?.unit && ( +

+ {errors.items[index].unit?.message} +

+ )}
@@ -2544,7 +2566,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat size="lg" disabled={isSubmitting || isSavingDraft || isFormSubmitting || !canWrite} title={!canWrite ? t('viewer_disabled_tooltip') : t('save_as_draft_tooltip')} - onClick={handleSubmit(saveDraftData)} + onClick={handleSubmit(saveDraftData, onInvalidSubmit)} > {isSavingDraft ? : null} {t('save_as_draft')} @@ -2572,7 +2594,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat type="button" variant="outline" disabled={isSubmitting || isSavingDraft || isFormSubmitting || !canWrite} - onClick={handleSubmit(saveDraftData)} + onClick={handleSubmit(saveDraftData, onInvalidSubmit)} > {isSavingDraft ? : t('save_as_draft_short')} diff --git a/components/invoices/NewRecurringScheduleDialog.tsx b/components/invoices/NewRecurringScheduleDialog.tsx index 8e5cd8fc..5c1f5e0b 100644 --- a/components/invoices/NewRecurringScheduleDialog.tsx +++ b/components/invoices/NewRecurringScheduleDialog.tsx @@ -219,6 +219,21 @@ function NewRecurringScheduleForm({ } } + // Mirror InvoiceEditor: a failed validation must never look like a dead + // button. Toast, then scroll the first inline error into view once rendered. + function onInvalidSubmit(_errors: unknown, event?: React.BaseSyntheticEvent) { + toast({ + title: t('validation_toast_title'), + description: t('validation_toast_description'), + variant: 'destructive', + }) + const root = (event?.target as HTMLElement | null)?.closest('form') + setTimeout(() => { + const firstError = (root ?? document).querySelector('p.text-destructive') + firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }, 100) + } + const items = watch('items') const watchCurrency = watch('currency') // Automatic sending requires a customer email; without one the cron would @@ -246,7 +261,7 @@ function NewRecurringScheduleForm({ const subtotal = Math.round(subtotalRaw * 100) / 100 return ( - + {t('schedule_card_title')} @@ -459,6 +474,11 @@ function NewRecurringScheduleForm({ placeholder={t('description_placeholder')} {...register(`items.${index}.description`)} /> + {errors.items?.[index]?.description && ( +

+ {errors.items[index].description?.message} +

+ )}
+ {errors.items?.[index]?.quantity && ( +

+ {errors.items[index].quantity?.message} +

+ )}
)} /> + {errors.items?.[index]?.unit && ( +

+ {errors.items[index].unit?.message} +

+ )}
>({}) const [periods, setPeriods] = useState([]) const [periodsLoaded, setPeriodsLoaded] = useState(false) @@ -579,6 +584,9 @@ export default function NewSupplierInvoiceForm({ // time) and a silent default misbooks: leave empty so the user // (or the supplier default) makes the call. account_number: '', + // Deliberately unconditional: for icke momsregistrerade the + // zeroing effect below grosses the net amount up by this rate + // before forcing it to 0, so the rate must arrive intact. vat_rate: vatRateFromAi(li.vatRate), accrual_period_start: withAccrual ? (sps as string) : undefined, accrual_period_end: withAccrual ? (spe as string) : undefined, @@ -812,6 +820,9 @@ export default function NewSupplierInvoiceForm({ } if (typeof data?.ore_rounding === 'boolean') setOreRounding(data.ore_rounding) setDimensionsEnabled(data?.dimensions_enabled === true) + // Only an explicit false gates: a missing column or failed fetch keeps + // the registered-company behavior. + if (data?.vat_registered === false) setVatRegistered(false) } catch { // Default to enskild_firma / accrual, dimension affordances hidden } @@ -845,7 +856,7 @@ export default function NewSupplierInvoiceForm({ // (inferVatTreatment) expect a number. const acct = accounts.find((a) => a.account_number === accountNumber) const defaultRate = acct?.default_vat_rate == null ? null : Number(acct.default_vat_rate) - if (!watchedReverseCharge && defaultRate != null && Number.isFinite(defaultRate)) { + if (vatRegistered && !watchedReverseCharge && defaultRate != null && Number.isFinite(defaultRate)) { setValue(`items.${index}.vat_rate`, defaultRate, { shouldDirty: true }) } // Särskild löneskatt only applies to 741x pension premiums: leaving the @@ -882,6 +893,31 @@ export default function NewSupplierInvoiceForm({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [watchedReverseCharge]) + // Force every line to 0 % moms for icke momsregistrerade companies: the + // default line, AI prefills and konto defaults all assume 25 % otherwise. + // Re-runs after the inbox prefill lands so a late extraction can't + // reintroduce a rate. + // + // The amount is grossed up in the same pass: an amount paired with a + // non-zero rate is a NET amount (AI line totals are exkl moms, and the + // visible column said "Belopp (exkl.)" while the rate stood). For a company + // with no deduction right the moms is part of the cost, so net at 25 % + // becomes gross at 0 %; zeroing the rate alone would understate both the + // expense and 2440 by exactly the moms. + useEffect(() => { + if (vatRegistered) return + const items = getValues('items') ?? [] + items.forEach((item, index) => { + if (item.vat_rate !== 0) { + if (item.amount) { + setValue(`items.${index}.amount`, roundOre(item.amount * (1 + item.vat_rate))) + } + setValue(`items.${index}.vat_rate`, 0) + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vatRegistered, hasPrefilled]) + function isAccrualOpen(index: number): boolean { return watchedItems?.[index]?.accrual_balance_account != null } @@ -1056,6 +1092,11 @@ export default function NewSupplierInvoiceForm({ ) } + // Reverse charge keeps its rate controls even for icke momsregistrerade + // (self-assessment is a separate obligation from deduction); everything + // else moms-related disappears when the company isn't VAT-registered. + const vatColsVisible = vatRegistered || watchedReverseCharge + const itemTotals = (watchedItems || []).map((item) => { const lineTotal = Math.round((item.amount || 0) * 100) / 100 // Reverse charge: VAT is self-assessed at reverse_charge_rate (25% default), @@ -1889,7 +1930,7 @@ export default function NewSupplierInvoiceForm({ size="sm" className="w-full sm:w-auto" onClick={() => - append({ description: '', amount: 0, account_number: '', vat_rate: 0.25, reverse_charge_rate: 0.25 }) + append({ description: '', amount: 0, account_number: '', vat_rate: vatRegistered ? 0.25 : 0, reverse_charge_rate: 0.25 }) } > @@ -2017,9 +2058,13 @@ export default function NewSupplierInvoiceForm({ {t('col_account')} {t('col_description')} - {t('col_amount_excl')} - {watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')} - {watchedReverseCharge ? t('col_rc_vat') : t('col_vat')} + {vatColsVisible ? t('col_amount_excl') : t('col_amount')} + {vatColsVisible && ( + <> + {watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')} + {watchedReverseCharge ? t('col_rc_vat') : t('col_vat')} + + )} @@ -2072,28 +2117,32 @@ export default function NewSupplierInvoiceForm({ )} /> - - {watchedReverseCharge ? ( - ( - + {vatColsVisible && ( + <> + + {watchedReverseCharge ? ( + ( + + )} + /> + ) : ( + ( + + )} + /> )} - /> - ) : ( - ( - - )} - /> - )} - - - {formatAmount(itemTotals[index]?.vatAmount ?? 0)} - + + + {formatAmount(itemTotals[index]?.vatAmount ?? 0)} + + + )}
{dimensionsEnabled && ( @@ -2142,21 +2191,21 @@ export default function NewSupplierInvoiceForm({ {canUseAccrual && isAccrualOpen(index) && ( - + {renderAccrualPanel(index, `accrual-desktop-${index}`)} )} {dimensionsEnabled && isDimOpen(index) && ( - + {renderDimensionsPanel(index)} )} {slpRowVisible(index) && ( - + {renderSlpPanel(index)} @@ -2243,9 +2292,9 @@ export default function NewSupplierInvoiceForm({ )} />
-
+
- +
-
- - {watchedReverseCharge ? ( - ( - - )} - /> - ) : ( - ( - - )} - /> - )} + {vatColsVisible && ( +
+ + {watchedReverseCharge ? ( + ( + + )} + /> + ) : ( + ( + + )} + /> + )} +
+ )} +
+ {vatColsVisible && ( +
+ {watchedReverseCharge ? t('col_rc_vat') : t('col_vat')} + + {formatAmount(itemTotals[index]?.vatAmount ?? 0)} +
-
-
- {watchedReverseCharge ? t('col_rc_vat') : t('col_vat')} - - {formatAmount(itemTotals[index]?.vatAmount ?? 0)} - -
+ )} {canUseAccrual && isAccrualOpen(index) && renderAccrualPanel(index, `accrual-mobile-${index}`)} {dimensionsEnabled && isDimOpen(index) && renderDimensionsPanel(index)} @@ -2321,16 +2374,20 @@ export default function NewSupplierInvoiceForm({ {/* Computed totals */}
-
- {t('net_excl_vat')} - {formatCurrency(subtotal, watchedCurrency)} -
-
- - {watchedReverseCharge ? t('vat_reverse_charge') : t('vat_label_short')} - - {formatCurrency(totalVat, watchedCurrency)} -
+ {vatColsVisible && ( + <> +
+ {t('net_excl_vat')} + {formatCurrency(subtotal, watchedCurrency)} +
+
+ + {watchedReverseCharge ? t('vat_reverse_charge') : t('vat_label_short')} + + {formatCurrency(totalVat, watchedCurrency)} +
+ + )} {displayRounding.applies && (
{t('ore_rounding_label')} diff --git a/lib/core/bookkeeping/__tests__/period-service.test.ts b/lib/core/bookkeeping/__tests__/period-service.test.ts index 4f94190c..0abd6e62 100644 --- a/lib/core/bookkeeping/__tests__/period-service.test.ts +++ b/lib/core/bookkeeping/__tests__/period-service.test.ts @@ -33,6 +33,7 @@ import { lockPeriod, unlockPeriod, closePeriod, + markPeriodClosedExternally, createNextPeriod, findNextPeriod, resolvePeriodStatusForDate, @@ -649,6 +650,122 @@ describe('closePeriod', () => { }) }) +describe('markPeriodClosedExternally', () => { + it('closes, locks and stamps closed_externally without a closing entry', async () => { + const period = makeFiscalPeriod({ + id: 'fp-1', + locked_at: null, + is_closed: false, + closing_entry_id: null, + period_end: '2024-12-31', + }) + const updated = { + ...period, + is_closed: true, + closed_at: '2025-01-15T10:00:00Z', + closed_externally: true, + locked_at: '2025-01-15T10:00:00Z', + } + + results = [ + { data: period, error: null }, // fetch + { count: 3, data: null, error: null }, // imported-verifikat count (migrated year) + { count: 0, data: null, error: null }, // guard leg 1: untriaged count + { data: [], error: null }, // guard leg 2: business-unbooked candidates + { data: updated, error: null }, // update + { data: null, error: null }, // audit_log insert + ] + + const supabase = makeClient() + const result = await markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1') + + expect(result.is_closed).toBe(true) + expect(result.closed_externally).toBe(true) + expect(result.locked_at).toBeTruthy() + }) + + it('allows an empty period (year closed elsewhere, never imported)', async () => { + const period = makeFiscalPeriod({ id: 'fp-1', period_end: '2024-12-31' }) + const updated = { ...period, is_closed: true, closed_externally: true } + results = [ + { data: period, error: null }, // fetch + { count: 0, data: null, error: null }, // imported-verifikat count + { count: 0, data: null, error: null }, // total-verifikat count + { count: 0, data: null, error: null }, // guard leg 1: untriaged count + { data: [], error: null }, // guard leg 2: business-unbooked candidates + { data: updated, error: null }, // update + { data: null, error: null }, // audit_log insert + ] + + const supabase = makeClient() + const result = await markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1') + expect(result.closed_externally).toBe(true) + }) + + it('refuses a period bookkept natively in Accounted (no imported verifikat)', async () => { + const period = makeFiscalPeriod({ id: 'fp-1', period_end: '2024-12-31' }) + results = [ + { data: period, error: null }, // fetch + { count: 0, data: null, error: null }, // imported-verifikat count + { count: 7, data: null, error: null }, // total-verifikat count: native entries + ] + + const supabase = makeClient() + await expect( + markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1') + ).rejects.toThrow('vanliga årsbokslutet') + }) + + it('rejects an already-closed period', async () => { + const period = makeFiscalPeriod({ id: 'fp-1', is_closed: true }) + results = [{ data: period, error: null }] + + const supabase = makeClient() + await expect( + markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1') + ).rejects.toThrow('already closed') + }) + + it('rejects a period with its own closing entry (normal close applies)', async () => { + const period = makeFiscalPeriod({ id: 'fp-1', closing_entry_id: 'ce-1' }) + results = [{ data: period, error: null }] + + const supabase = makeClient() + await expect( + markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1') + ).rejects.toThrow('closing entry') + }) + + it('rejects a period that has not ended yet', async () => { + const period = makeFiscalPeriod({ + id: 'fp-1', + period_start: '2999-01-01', + period_end: '2999-12-31', + }) + results = [{ data: period, error: null }] + + const supabase = makeClient() + await expect( + markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1') + ).rejects.toThrow('has not ended') + }) + + it('blocks when the period still holds unbooked bank transactions', async () => { + const period = makeFiscalPeriod({ id: 'fp-1', period_end: '2024-12-31' }) + results = [ + { data: period, error: null }, + { count: 1, data: null, error: null }, // imported-verifikat count + { count: 2, data: null, error: null }, // untriaged + { data: [], error: null }, // business-unbooked candidates + ] + + const supabase = makeClient() + await expect( + markPeriodClosedExternally(supabase as never, 'company-1', 'user-1', 'fp-1') + ).rejects.toThrow('Kan inte klarmarkera period') + }) +}) + describe('unlockPeriod', () => { it('clears locked_at and emits period.unlocked', async () => { const period = makeFiscalPeriod({ diff --git a/lib/core/bookkeeping/period-service.ts b/lib/core/bookkeeping/period-service.ts index 6688bc97..14d8c67c 100644 --- a/lib/core/bookkeeping/period-service.ts +++ b/lib/core/bookkeeping/period-service.ts @@ -374,6 +374,175 @@ export async function closePeriod( return updated as FiscalPeriod } +/** + * Mark a fiscal period as closed in a previous bookkeeping system + * ("klarmarkera"). Imported historical years (SIE) arrive with + * is_closed = false and no closing entry, so the year-end page lists them as + * pending bokslut even though the bokslut was already done in the old + * software. + * + * Deliberately bypasses closePeriod's locked_at/closing_entry_id + * preconditions: the closing entry lives in the previous system. Everything + * else stays strict: + * - the period must have ended (a running year cannot be done elsewhere) + * - a period with its own closing entry goes through the normal close + * - already-closed periods are refused + * - the same unbooked-bank-transactions guard as lockPeriod applies, because + * closing strands them exactly the way locking would (BFL 5 kap 2 §) + * + * Sets locked_at too (when missing) so the period carries the full + * closed+locked state the enforcement triggers and readers expect, and writes + * the immutable audit_log entry (BFNAR 2013:2 kap. 8: this is a control + * decision made by a person, not a year-end run). + */ +export async function markPeriodClosedExternally( + supabase: SupabaseClient, + companyId: string, + userId: string, + fiscalPeriodId: string +): Promise { + const { data: period, error: fetchError } = await supabase + .from('fiscal_periods') + .select('*') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .single() + + if (fetchError || !period) { + throw new Error('Fiscal period not found') + } + + if (period.is_closed) { + throw new Error('Period is already closed') + } + + if (period.closing_entry_id) { + throw new Error( + 'Period has a closing entry in Accounted: use the normal year-end close instead' + ) + } + + const today = new Date().toISOString().slice(0, 10) + if (period.period_end > today) { + throw new Error('Cannot mark a period that has not ended yet as closed') + } + + // Klarmarkera exists for MIGRATED years. A period bookkept natively in + // Accounted must go through the real year-end: closing it without a + // bokslutsverifikat leaves 3xxx-8xxx untransferred (BFL 5-6 kap) with no + // clean way back once locked. "Migrated" is read from the ledger itself: + // the period either contains SIE-imported verifikat (source_type='import') + // or no verifikat at all (year closed elsewhere and never imported here). + const { count: importedCount, error: importedError } = await supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('source_type', 'import') + .gte('entry_date', period.period_start) + .lte('entry_date', period.period_end) + if (importedError) { + throw new Error('Kunde inte kontrollera periodens verifikat. Försök igen.') + } + if ((importedCount ?? 0) === 0) { + const { count: totalCount, error: totalError } = await supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .gte('entry_date', period.period_start) + .lte('entry_date', period.period_end) + if (totalError) { + throw new Error('Kunde inte kontrollera periodens verifikat. Försök igen.') + } + if ((totalCount ?? 0) > 0) { + throw new Error( + 'Perioden innehåller bokföring skapad i Accounted och inga importerade verifikat. Använd det vanliga årsbokslutet i stället.' + ) + } + } + + // Same stranding guard as lockPeriod: closing makes unbooked + // affärshändelser in the period unbookable in place. Fail closed if the + // guard cannot run. + let unbooked: UnbookedInPeriod + try { + unbooked = await countUnbookedInPeriod( + supabase, + companyId, + period.period_start, + period.period_end, + ) + } catch (err) { + log.error('unbooked-transaction guard failed, refusing to close externally', { + companyId, + fiscalPeriodId, + reason: err instanceof Error ? err.message : String(err), + }) + throw new Error( + 'Kunde inte kontrollera obokförda banktransaktioner i perioden. Perioden lämnas öppen. Försök igen.' + ) + } + + const blockingCount = unbooked.untriaged + unbooked.businessUnbooked + if (blockingCount > 0) { + const breakdown = [ + unbooked.untriaged > 0 ? `${unbooked.untriaged} ej hanterade` : null, + unbooked.businessUnbooked > 0 + ? `${unbooked.businessUnbooked} markerade som affärshändelse men utan verifikat` + : null, + ] + .filter(Boolean) + .join(', ') + throw new Error( + `Kan inte klarmarkera period: ${blockingCount} banktransaktion(er) i perioden saknar bokföring ` + + `(${breakdown}). Alla affärstransaktioner måste vara bokförda innan perioden stängs. ` + + `Gå till Transaktioner, bokför dem eller markera dem som privata eller ignorerade, och klarmarkera därefter.` + ) + } + + const now = new Date().toISOString() + const { data: updated, error: updateError } = await supabase + .from('fiscal_periods') + .update({ + is_closed: true, + closed_at: now, + closed_externally: true, + locked_at: period.locked_at ?? now, + }) + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + // TOCTOU guard: a concurrent normal close between the fetch above and + // this update must not be overwritten with closed_externally=true (and a + // clobbered closed_at). The predicate makes that race a 0-row update, + // which .single() surfaces as an error. + .eq('is_closed', false) + .select() + .single() + + if (updateError || !updated) { + throw new Error(`Failed to mark period as externally closed: ${updateError?.message}`) + } + + const result = updated as FiscalPeriod + + await supabase.from('audit_log').insert({ + user_id: userId, + company_id: companyId, + action: 'UPDATE', + table_name: 'fiscal_periods', + record_id: fiscalPeriodId, + description: `Period marked as closed in previous system: ${result.name} (${result.period_start} to ${result.period_end})`, + old_state: { is_closed: false, closed_at: null, locked_at: period.locked_at }, + new_state: { + is_closed: true, + closed_at: result.closed_at, + closed_externally: true, + locked_at: result.locked_at, + }, + }) + + return result +} + /** * Create the next fiscal period following the current one. * Computes dates based on the current period's length (handles brutet räkenskapsår). diff --git a/messages/en.json b/messages/en.json index 916e16bf..2d449517 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3548,6 +3548,9 @@ "validation_invoice_date_required": "Invoice date required", "validation_due_date_required": "Due date required", "validation_min_one_row": "At least one row required", + "validation_toast_title": "Check the details", + "validation_toast_description": "A field is missing or invalid. Fix the highlighted fields and try again.", + "article_search_empty": "No article matches your search", "deduction_menu_label": "Tax reduction", "deduction_none": "None", "deduction_rot": "ROT (30%)", @@ -3998,6 +4001,8 @@ "validation_customer_required": "Select a customer", "validation_name_required": "Name required", "validation_min_one_row": "At least one row required", + "validation_toast_title": "Check the details", + "validation_toast_description": "A field is missing or invalid. Fix the highlighted fields and try again.", "send_hour_label": "Send at", "send_hour_hint": "Swedish time", "auto_send_requires_subscription": "Automatic sending requires a subscription. Invoices are still created as drafts each period.", diff --git a/messages/sv.json b/messages/sv.json index 97dd3c09..c634e62e 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3548,6 +3548,9 @@ "validation_invoice_date_required": "Fakturadatum krävs", "validation_due_date_required": "Förfallodatum krävs", "validation_min_one_row": "Minst en rad krävs", + "validation_toast_title": "Kontrollera uppgifterna", + "validation_toast_description": "Något fält saknas eller är felaktigt. Rätta de markerade fälten och försök igen.", + "article_search_empty": "Ingen artikel matchar sökningen", "deduction_menu_label": "Skattereduktion", "deduction_none": "Ingen", "deduction_rot": "ROT (30%)", @@ -3998,6 +4001,8 @@ "validation_customer_required": "Välj en kund", "validation_name_required": "Namn krävs", "validation_min_one_row": "Minst en rad krävs", + "validation_toast_title": "Kontrollera uppgifterna", + "validation_toast_description": "Något fält saknas eller är felaktigt. Rätta de markerade fälten och försök igen.", "send_hour_label": "Skicka klockan", "send_hour_hint": "Svensk tid", "auto_send_requires_subscription": "Automatiskt utskick kräver ett abonnemang. Fakturorna skapas ändå som utkast varje period.", diff --git a/supabase/migrations/20260817110000_add_fiscal_periods_closed_externally.sql b/supabase/migrations/20260817110000_add_fiscal_periods_closed_externally.sql new file mode 100644 index 00000000..45697298 --- /dev/null +++ b/supabase/migrations/20260817110000_add_fiscal_periods_closed_externally.sql @@ -0,0 +1,11 @@ +-- Fiscal years imported from a previous bookkeeping system (SIE) arrive with +-- is_closed = false, so the year-end page lists them as pending bokslut even +-- though the bokslut was already done in the old software. closed_externally +-- marks a period closed via the "klarmarkera" action: is_closed/closed_at are +-- set alongside, but the period has no closing entry of its own. Kept as a +-- separate column for audit clarity: it distinguishes "closed by a year-end +-- run here" from "closed in a previous system". +ALTER TABLE public.fiscal_periods + ADD COLUMN closed_externally boolean NOT NULL DEFAULT false; + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 57b0a76a..4efcd998 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1763,6 +1763,10 @@ export interface FiscalPeriod { period_end: string is_closed: boolean closed_at: string | null + // Closed via "klarmarkera": the bokslut was done in a previous bookkeeping + // system, so the period is closed here without a closing entry of its own. + // Optional: rows predate the column on some cached readers. + closed_externally?: boolean locked_at: string | null retention_expires_at: string | null opening_balances_set: boolean