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
co-authored by Claude Fable 5
parent caa0c3b41d
commit dfb34a01d9
16 changed files with 1075 additions and 92 deletions
@@ -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 },
)