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:
co-authored by
Claude Fable 5
parent
caa0c3b41d
commit
dfb34a01d9
@@ -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({
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user