feat(invoices,year-end): four byrå-feedback fixes (validation feedback, moms gate, klarmarkera, article search) (#1641)

* fix(invoices): surface validation errors instead of a silent dead submit button

A missing unit (or any other Zod failure) blocked both Granska & skapa and
Spara som utkast with zero feedback: handleSubmit had no onInvalid callback,
the buttons stayed enabled, and the unit field rendered no inline error.
Reported by a byra user whose client could not save any invoice.

- onInvalid handler on all three submit paths: destructive toast plus scroll
  to the first inline error
- inline error text under the unit select and quantity input (the only line
  fields that had none)
- same treatment in NewRecurringScheduleDialog, including inline errors on
  its item rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(supplier-invoices): stop defaulting 25 % moms for icke momsregistrerade companies

The registration form hard-coded vat_rate 0.25 on the initial line, added
rows, AI prefill fallback and konto defaults, regardless of
company_settings.vat_registered. A non-VAT-registered business that missed
the prefilled rate booked ingaende moms (2641) it has no right to deduct
(ML 8 kap. 3 \u00a7). The customer-invoice side already gates on the same flag;
the supplier side ignored it.

- form: read vat_registered from /api/settings; when false, all moms
  controls (rate cells, per-line moms, totals rows) are hidden and every
  line is forced to 0 %, including late AI prefills
- reverse charge keeps its rate controls: self-assessment is a separate
  obligation from deduction
- route: 400 SI_CREATE_INVALID_INPUT when a non-registered company posts a
  line with vat_rate/vat_amount > 0 (API/MCP defense in depth), and an
  omitted vat_rate now defaults to 0 instead of 25 % for those companies
- tests: guard rejection, reverse-charge pass-through, 0-default; existing
  POST tests updated for the new settings lookup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(year-end): klarmarkera imported years already closed in a previous system

SIE-imported historical fiscal years land with is_closed = false and no
closing entry, so the year-end page lists every migrated year as pending
bokslut even though the bokslut was done in the old software. There was no
sanctioned way to mark them done: closePeriod hard-requires locked_at and
closing_entry_id.

- migration: fiscal_periods.closed_externally boolean (audit clarity:
  distinguishes a year-end run here from a close done elsewhere)
- markPeriodClosedExternally(): closes + locks without a closing entry;
  refuses already-closed periods, periods with their own closing entry,
  periods that have not ended, and periods with unbooked bank transactions
  (same stranding guard as lockPeriod); writes the immutable audit_log entry
- POST /api/bookkeeping/fiscal-periods/[id]/close-external (requireWrite)
- year-end page: one attn line on the preflight step with a confirm dialog
  describing the outcome; the marked year drops out of the eligible list

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(invoices): searchable article picker on invoice lines

The article field was a plain Radix Select whose only matching is
label-prefix typeahead: for numbered articles that means number-only lookup,
and typing "skruv" found nothing. Byra feedback: name search would help a
lot for users with real article catalogs.

New ArticleCombobox (input-trigger dropdown, same pattern as
AccountCombobox): free-text search over name + article number,
diacritics-folded via foldText, keyboard navigation, pinned "Egen rad"
free-text option, browse-all on focus like the Select it replaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: log klarmarkera pg-test decision

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address skeptic and compliance-review findings on PR #1641

- ArticleCombobox: keyboard focus no longer auto-opens the list, opening
  highlights the committed selection, typing highlights the first match,
  and re-selecting the current value is a no-op. Previously Tab+Enter
  silently detached the article and wiped its revenue-account override.
- Supplier invoice prefill for icke momsregistrerade: the zeroing effect now
  grosses the net amount up by the extracted rate before forcing 0 %, so the
  booked cost and 2440 keep the full att-betala amount instead of
  understating both by the moms.
- markPeriodClosedExternally: only migrated periods qualify (must contain
  SIE-imported verifikat or no verifikat at all); the update carries an
  is_closed=false predicate so a concurrent normal close cannot be
  overwritten; confirm dialog now names the reporting consequences.
- Route comment: honest scope (this route only; v1/inbox/MCP sweep is a
  follow-up) and current-law citation (13 kap. ML 2023:200).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: use roundOre for the icke-momsregistrerad gross-up (ratchet guard)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-17 12:02:43 +02:00
committed by GitHub
parent caa0c3b41d
commit dfb34a01d9
16 changed files with 1075 additions and 92 deletions
+1
View File
@@ -1019,6 +1019,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+79 -4
View File
@@ -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 (
<div className="space-y-8">
<div className="flex items-center justify-between gap-3 flex-wrap">
@@ -242,6 +291,32 @@ export default function YearEndPage() {
</div>
</div>
{showWizard && step === 'preflight' && selectedOption?.eligible && !navigationBlocked && (
<AttnLine
action={{ label: 'Klarmarkera perioden', onClick: () => setConfirmExternalOpen(true) }}
>
Är bokslutet för {selectedOption.name} redan gjort i ett tidigare bokföringsprogram?
</AttnLine>
)}
<ConfirmDialog
open={confirmExternalOpen}
onOpenChange={setConfirmExternalOpen}
title="Klarmarkera perioden?"
description={
<>
{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 periodens bokslut (t.ex. jämförelseår i nästa
årsredovisning och INK2 för perioden) kan sakna uppgifter och behöver i fall
hämtas från det tidigare programmet. Åtgärden loggas i behandlingshistoriken.
</>
}
confirmLabel="Klarmarkera"
onConfirm={markClosedExternally}
/>
{periods === null && !periodsError && (
<Card>
<CardContent className="p-6 space-y-2">
@@ -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')
})
})
@@ -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 },
)
@@ -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<string, unknown>[], overrides: Record<string, unknown> = {}) {
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<Record<string, unknown>>
expect(rows[0].vat_rate).toBe(0)
expect(rows[0].vat_amount).toBe(0)
})
})
+27 -1
View File
@@ -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
+252
View File
@@ -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<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(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<Option[]>(
() => [
{ 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 (
<div ref={containerRef} className="relative">
<Input
value={search}
onChange={(e) => {
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 && (
<div
ref={listRef}
className="absolute z-50 top-full left-0 mt-1 w-full min-w-[16rem] max-h-[300px] overflow-y-auto rounded-lg border border-input bg-card shadow-md"
>
{options.map((option, index) => (
<button
key={option.key}
type="button"
data-highlighted={index === highlightedIndex}
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer ${
index === highlightedIndex ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
} ${option.muted ? 'text-muted-foreground' : ''}`}
onMouseDown={(e) => {
e.preventDefault()
select(option)
}}
onMouseEnter={() => setHighlightedIndex(index)}
>
{option.label}
</button>
))}
{filtered.length === 0 && (
<p className="px-2 py-1.5 text-sm text-muted-foreground">{emptyLabel}</p>
)}
</div>
)}
</div>
)
}
+41 -19
View File
@@ -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
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className={bare ? 'space-y-6' : 'space-y-6 pb-28 md:pb-0'}>
<form onSubmit={handleSubmit(onSubmit, onInvalidSubmit)} className={bare ? 'space-y-6' : 'space-y-6 pb-28 md:pb-0'}>
<div className="grid gap-6 lg:grid-cols-3 lg:items-start">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
@@ -1777,22 +1796,15 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
name={`items.${index}.article_id`}
control={control}
render={({ field }) => (
<Select
value={field.value ?? 'none'}
onValueChange={(v) => applyArticle(index, v)}
>
<SelectTrigger>
<SelectValue placeholder={t('article_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('article_free_text')}</SelectItem>
{articles.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.article_number ? `${a.article_number}: ${a.name}` : a.name}
</SelectItem>
))}
</SelectContent>
</Select>
<ArticleCombobox
value={field.value ?? null}
articles={articles}
onChange={(v) => applyArticle(index, v)}
freeTextLabel={t('article_free_text')}
placeholder={t('article_placeholder')}
emptyLabel={t('article_search_empty')}
ariaLabel={t('article_label')}
/>
)}
/>
</div>
@@ -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 && (
<p className="text-sm text-destructive">
{errors.items[index].quantity?.message}
</p>
)}
</div>
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">{t('unit_label')}</Label>
@@ -1864,6 +1881,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</Select>
)}
/>
{errors.items?.[index]?.unit && (
<p className="text-sm text-destructive">
{errors.items[index].unit?.message}
</p>
)}
</div>
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">{t('unit_price_label')}</Label>
@@ -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 ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : 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 ? <Loader2 className="h-4 w-4 animate-spin" /> : t('save_as_draft_short')}
</Button>
@@ -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 (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<form onSubmit={handleSubmit(onSubmit, onInvalidSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('schedule_card_title')}</CardTitle>
@@ -459,6 +474,11 @@ function NewRecurringScheduleForm({
placeholder={t('description_placeholder')}
{...register(`items.${index}.description`)}
/>
{errors.items?.[index]?.description && (
<p className="text-sm text-destructive mt-1">
{errors.items[index].description?.message}
</p>
)}
</div>
<div className="col-span-3 sm:col-span-2">
<Input
@@ -468,6 +488,11 @@ function NewRecurringScheduleForm({
className="tabular-nums"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
{errors.items?.[index]?.quantity && (
<p className="text-sm text-destructive mt-1">
{errors.items[index].quantity?.message}
</p>
)}
</div>
<div className="col-span-3 sm:col-span-1">
<Controller
@@ -488,6 +513,11 @@ function NewRecurringScheduleForm({
</Select>
)}
/>
{errors.items?.[index]?.unit && (
<p className="text-sm text-destructive mt-1">
{errors.items[index].unit?.message}
</p>
)}
</div>
<div className="col-span-4 sm:col-span-3">
<Input
@@ -334,6 +334,11 @@ export default function NewSupplierInvoiceForm({
// JournalEntryForm. defaultDims is the invoice-level default bag; per-item
// bags live on the form's items and merge over it server-side.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
// Icke momsregistrerad verksamhet has no right to deduct input VAT: the
// moms controls disappear and every line books at 0 % (the gross amount IS
// the cost). Defaults true so registered companies keep the 25 % prefill
// while /api/settings is still in flight.
const [vatRegistered, setVatRegistered] = useState(true)
const [defaultDims, setDefaultDims] = useState<Record<string, string>>({})
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
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 })
}
>
<Plus className="mr-2 h-4 w-4" />
@@ -2017,9 +2058,13 @@ export default function NewSupplierInvoiceForm({
<tr className="border-b text-left">
<th className="pb-2 w-28">{t('col_account')}</th>
<th className="pb-2">{t('col_description')}</th>
<th className="pb-2 w-32">{t('col_amount_excl')}</th>
<th className="pb-2 w-36">{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}</th>
<th className="pb-2 w-24 text-right">{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}</th>
<th className="pb-2 w-32">{vatColsVisible ? t('col_amount_excl') : t('col_amount')}</th>
{vatColsVisible && (
<>
<th className="pb-2 w-36">{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}</th>
<th className="pb-2 w-24 text-right">{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}</th>
</>
)}
<th className="pb-2 w-8"></th>
</tr>
</thead>
@@ -2072,28 +2117,32 @@ export default function NewSupplierInvoiceForm({
)}
/>
</td>
<td className="py-2 pr-2">
{watchedReverseCharge ? (
<Controller
name={`items.${index}.reverse_charge_rate`}
control={control}
render={({ field: f }) => (
<RcRateSelect value={f.value ?? 0.25} onChange={f.onChange} />
{vatColsVisible && (
<>
<td className="py-2 pr-2">
{watchedReverseCharge ? (
<Controller
name={`items.${index}.reverse_charge_rate`}
control={control}
render={({ field: f }) => (
<RcRateSelect value={f.value ?? 0.25} onChange={f.onChange} />
)}
/>
) : (
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
)}
/>
) : (
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
)}
</td>
<td className="py-2 pr-2 text-right tabular-nums text-muted-foreground">
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
</td>
</td>
<td className="py-2 pr-2 text-right tabular-nums text-muted-foreground">
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
</td>
</>
)}
<td className="py-2 pt-3">
<div className="flex items-center">
{dimensionsEnabled && (
@@ -2142,21 +2191,21 @@ export default function NewSupplierInvoiceForm({
</tr>
{canUseAccrual && isAccrualOpen(index) && (
<tr className={cn(dimensionsEnabled && isDimOpen(index) ? 'border-0' : 'border-b last:border-0')}>
<td colSpan={6} className="pb-3">
<td colSpan={vatColsVisible ? 6 : 4} className="pb-3">
{renderAccrualPanel(index, `accrual-desktop-${index}`)}
</td>
</tr>
)}
{dimensionsEnabled && isDimOpen(index) && (
<tr className={cn(slpRowVisible(index) ? 'border-0' : 'border-b last:border-0')}>
<td colSpan={6} className="pb-3">
<td colSpan={vatColsVisible ? 6 : 4} className="pb-3">
{renderDimensionsPanel(index)}
</td>
</tr>
)}
{slpRowVisible(index) && (
<tr className="border-b last:border-0">
<td colSpan={6} className="pb-3">
<td colSpan={vatColsVisible ? 6 : 4} className="pb-3">
{renderSlpPanel(index)}
</td>
</tr>
@@ -2243,9 +2292,9 @@ export default function NewSupplierInvoiceForm({
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className={vatColsVisible ? 'grid grid-cols-2 gap-3' : 'grid grid-cols-1 gap-3'}>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('col_amount_excl')}</Label>
<Label className="text-xs text-muted-foreground">{vatColsVisible ? t('col_amount_excl') : t('col_amount')}</Label>
<Controller
name={`items.${index}.amount`}
control={control}
@@ -2262,33 +2311,37 @@ export default function NewSupplierInvoiceForm({
)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}</Label>
{watchedReverseCharge ? (
<Controller
name={`items.${index}.reverse_charge_rate`}
control={control}
render={({ field: f }) => (
<RcRateSelect value={f.value ?? 0.25} onChange={f.onChange} />
)}
/>
) : (
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
)}
{vatColsVisible && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}</Label>
{watchedReverseCharge ? (
<Controller
name={`items.${index}.reverse_charge_rate`}
control={control}
render={({ field: f }) => (
<RcRateSelect value={f.value ?? 0.25} onChange={f.onChange} />
)}
/>
) : (
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
)}
</div>
)}
</div>
{vatColsVisible && (
<div className="pt-1 border-t flex items-center justify-between">
<span className="text-xs text-muted-foreground">{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}</span>
<span className="tabular-nums text-muted-foreground">
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
</span>
</div>
</div>
<div className="pt-1 border-t flex items-center justify-between">
<span className="text-xs text-muted-foreground">{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}</span>
<span className="tabular-nums text-muted-foreground">
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
</span>
</div>
)}
{canUseAccrual && isAccrualOpen(index) &&
renderAccrualPanel(index, `accrual-mobile-${index}`)}
{dimensionsEnabled && isDimOpen(index) && renderDimensionsPanel(index)}
@@ -2321,16 +2374,20 @@ export default function NewSupplierInvoiceForm({
{/* Computed totals */}
<div className="mt-4 pt-4 border-t space-y-2">
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(subtotal, watchedCurrency)}</span>
</div>
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">
{watchedReverseCharge ? t('vat_reverse_charge') : t('vat_label_short')}
</span>
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(totalVat, watchedCurrency)}</span>
</div>
{vatColsVisible && (
<>
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(subtotal, watchedCurrency)}</span>
</div>
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">
{watchedReverseCharge ? t('vat_reverse_charge') : t('vat_label_short')}
</span>
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(totalVat, watchedCurrency)}</span>
</div>
</>
)}
{displayRounding.applies && (
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">{t('ore_rounding_label')}</span>
@@ -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({
+169
View File
@@ -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<FiscalPeriod> {
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).
+5
View File
@@ -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.",
+5
View File
@@ -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.",
@@ -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';
+4
View File
@@ -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