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
@@ -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).