Fix/mcp and req (#753)

* feat(api): test-mode API keys force dry-run on the v1 REST API

A key created with mode='test' (prefix gnubok_sk_test_) binds to the real
company, but the v1 wrapper forces dry_run on every write so nothing is
persisted or sent. Mutations on endpoints that can't be simulated
(dryRunSupported=false or unregistered) are refused with 403
TEST_KEY_WRITE_BLOCKED — fail-closed. Reads pass through unchanged and every
test-key response carries X-Gnubok-Mode: test. Live keys are unaffected
(mode defaults to 'live').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(invoices): company default "Vår referens" + per-line sales-account override

Add company_settings.default_our_reference (settings form, schema, type); the
invoice editor pre-fills our_reference from it on new invoices only, never
overwriting an edited draft. Separately, add an optional per-line
försäljningskonto (class-3) override in the editor — left blank, the engine
still derives the revenue account from the VAT rate, and reverse-charge/export
lines ignore the override.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(invoices): render a Swish payment QR on invoice PDFs

Build the Swish "Type C" QR payload offline (no Swish API call) and embed it as
a PNG in the invoice PDF payment box when Swish display is enabled, the invoice
is in SEK, and the amount is positive. Also surface the invoice number in the
payment box. Wired through every PDF render path: send, mark-sent and pdf
routes (both legacy and v1), the recurring-schedule sender, and the staged-send
commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): draft exclusion + correction-chain collapse on verifikationslista

Extend list_fiscal_period_entries_with_related with two opt-in params:
p_exclude_draft (keep drafts off the committed list — they get their own
surface) and p_collapse_corrections (render a correction group as the single
live correction, hiding the mechanical storno and the reversed original).
Both default false; nothing is deleted, every voucher keeps its number, and a
"show all" toggle exposes the full chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reports): link multi-year SIE periods so resultatrapport shows the prior year

SIE import now sets fiscal_periods.previous_period_id in both directions when
creating a period, so multi-year files chain correctly regardless of #RAR order.
A backfill migration repairs periods imported before this (idempotent; only
touches NULL links on first-of-month periods). generateResultatrapport falls
back to the date-adjacent prior period when the chain is still null, so the
comparison column works for legacy data too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(articles): hide the VAT field for non-momsregistrerade companies

The article form reads company_settings.vat_registered and, when false, hides
the moms field and forces vat_rate to 0 on submit — mirroring the invoice
editor so a non-VAT-registered company never sets a rate it can't charge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(import): allow file-based imports in the sandbox

Bank-file, CSV/Excel and SIE imports run entirely on uploaded data with no
external service, so they're now reachable in the sandbox. Only the API-backed
options that need live third-party credentials (PSD2 bank connection, provider
migration) stay disabled. Updates the sandbox notice copy to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): add edit draft functionality for journal entries

* feat(database): add default "Vår referens" column to company_settings for invoicing

* fix(tests): set SHOW_SWISH_ON_INVOICE to false in PDF template mocks

* @
fix(payments): use roundOre for Swish amount formatting

Replace naive Math.round(x*100)/100 with roundOre from @/lib/money to
satisfy the antipattern guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-18 11:49:33 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2d6ddeafc5
commit 241959513b
54 changed files with 1634 additions and 120 deletions
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { updateDraftEntry } from '../engine'
import {
CannotEditNonDraftError,
JournalEntryNotFoundError,
JournalEntryNotBalancedError,
} from '../errors'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { CreateJournalEntryInput } from '@/types'
const balancedInput: CreateJournalEntryInput = {
fiscal_period_id: 'period-1',
entry_date: '2026-06-01',
description: 'Test draft',
source_type: 'manual',
voucher_series: 'A',
lines: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 100 },
],
}
describe('updateDraftEntry', () => {
it('throws JournalEntryNotFoundError when the entry does not exist', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: null, error: { message: 'not found' } })
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateDraftEntry(q.supabase as any, 'company-1', 'user-1', 'missing', balancedInput)
).rejects.toBeInstanceOf(JournalEntryNotFoundError)
})
it('refuses to edit a posted entry — only drafts are editable', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: { id: 'e1', status: 'posted', voucher_series: 'A' }, error: null })
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateDraftEntry(q.supabase as any, 'company-1', 'user-1', 'e1', balancedInput)
).rejects.toBeInstanceOf(CannotEditNonDraftError)
})
it('rejects an unbalanced draft before mutating anything', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: { id: 'e1', status: 'draft', voucher_series: 'A' }, error: null })
const unbalanced: CreateJournalEntryInput = {
...balancedInput,
lines: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 50 },
],
}
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateDraftEntry(q.supabase as any, 'company-1', 'user-1', 'e1', unbalanced)
).rejects.toBeInstanceOf(JournalEntryNotBalancedError)
})
})
+133
View File
@@ -4,6 +4,7 @@ import { createLogger } from '@/lib/logger'
import {
AccountsNotInChartError,
BookkeepingDatabaseError,
CannotEditNonDraftError,
CannotReverseNonPostedError,
EntryAlreadyReversedError,
EntryDateOutsideFiscalPeriodError,
@@ -352,6 +353,138 @@ export async function createDraftEntry(
return result
}
/**
* Update an existing DRAFT journal entry in place — header + lines. Only drafts
* are editable; committed entries (posted/reversed/cancelled) are immutable per
* BFL 5 kap. and rejected with CannotEditNonDraftError (the DB immutability
* trigger is the backstop). Mirrors createDraftEntry's validate-everything-first
* order so an unbalanced set, a bad period, or a locked period fails before any
* row is mutated — the header UPDATE is the first write, so a locked period
* aborts cleanly with the draft untouched.
*/
export async function updateDraftEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
entryId: string,
input: CreateJournalEntryInput
): Promise<JournalEntry> {
// Load the entry and assert it is an editable draft.
const { data: existing, error: loadError } = await supabase
.from('journal_entries')
.select('id, status, voucher_series')
.eq('id', entryId)
.eq('company_id', companyId)
.single()
if (loadError || !existing) {
throw new JournalEntryNotFoundError()
}
if (existing.status !== 'draft') {
throw new CannotEditNonDraftError(existing.status as string)
}
// Same balance gate as createDraftEntry.
const balance = validateBalance(input.lines)
if (!balance.valid) {
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'draft')
}
// Entry date must fall within the selected fiscal period.
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('name, period_start, period_end')
.eq('id', input.fiscal_period_id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
throw new FiscalPeriodNotFoundError()
}
if (input.entry_date < period.period_start || input.entry_date > period.period_end) {
throw new EntryDateOutsideFiscalPeriodError(
input.entry_date,
period.name,
period.period_start,
period.period_end
)
}
// Resolve account IDs (seeding standard BAS accounts on demand) up front, so
// the line insert below cannot fail on a missing account — same as create.
const accountIdMap = await resolveAccountIds(supabase, companyId, input.lines)
const allAccountNumbers = [...new Set(input.lines.map((l) => l.account_number))]
let missingAccounts = allAccountNumbers.filter((num) => !accountIdMap.has(num))
if (missingAccounts.length > 0) {
const seeded = await backfillStandardBASAccounts(supabase, companyId, userId, missingAccounts)
if (seeded.length > 0) {
const refreshed = await resolveAccountIds(supabase, companyId, input.lines)
for (const [num, id] of refreshed) accountIdMap.set(num, id)
missingAccounts = allAccountNumbers.filter((num) => !accountIdMap.has(num))
}
if (missingAccounts.length > 0) {
throw new AccountsNotInChartError(missingAccounts)
}
}
const resolvedSeries = input.voucher_series || (existing.voucher_series as string) || 'A'
// All validation passed — mutate. Update the header first; a locked/closed
// period blocks this write (enforce_period_lock) before any line is touched.
// source_type / source_id / status are intentionally preserved.
const { error: headerError } = await supabase
.from('journal_entries')
.update({
fiscal_period_id: input.fiscal_period_id,
entry_date: input.entry_date,
description: input.description,
voucher_series: resolvedSeries,
notes: input.notes || null,
})
.eq('id', entryId)
.eq('company_id', companyId)
if (headerError) {
throw new BookkeepingDatabaseError('create_draft_entry', headerError.message)
}
// Replace the lines: delete the old set, insert the new one.
const { error: deleteError } = await supabase
.from('journal_entry_lines')
.delete()
.eq('journal_entry_id', entryId)
if (deleteError) {
throw new BookkeepingDatabaseError('create_entry_lines', deleteError.message)
}
const lineInserts = buildLineInserts(entryId, input.lines, accountIdMap)
const { error: linesError } = await supabase
.from('journal_entry_lines')
.insert(lineInserts)
if (linesError) {
log.error('update draft: insert journal_entry_lines failed', linesError, {
operation: 'create_entry_lines',
companyId,
userId,
entityType: 'journal_entry',
entityId: entryId,
lineCount: lineInserts.length,
pgCode: (linesError as { code?: string }).code,
})
throw new BookkeepingDatabaseError('create_entry_lines', linesError.message)
}
const { data: completeEntry } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', entryId)
.single()
return completeEntry as JournalEntry
}
/**
* Commit a draft entry: assigns voucher number and transitions to 'posted'
* Uses the atomic commit_journal_entry RPC so the voucher number increment
+29
View File
@@ -11,6 +11,7 @@ export const ENTRY_DATE_OUTSIDE_FISCAL_PERIOD = 'ENTRY_DATE_OUTSIDE_FISCAL_PERIO
export const JOURNAL_ENTRY_NOT_FOUND = 'JOURNAL_ENTRY_NOT_FOUND' as const
export const CANNOT_REVERSE_NON_POSTED = 'CANNOT_REVERSE_NON_POSTED' as const
export const CANNOT_CORRECT_NON_POSTED = 'CANNOT_CORRECT_NON_POSTED' as const
export const CANNOT_EDIT_NON_DRAFT = 'CANNOT_EDIT_NON_DRAFT' as const
export const ENTRY_ALREADY_REVERSED = 'ENTRY_ALREADY_REVERSED' as const
export const CURRENCY_REVALUATION_ALREADY_EXISTS = 'CURRENCY_REVALUATION_ALREADY_EXISTS' as const
export const INVALID_MAPPING_RESULT = 'INVALID_MAPPING_RESULT' as const
@@ -126,6 +127,20 @@ export class CannotCorrectNonPostedError extends Error {
}
}
/**
* Raised when an edit is attempted on a committed entry. Only drafts are
* editable in place; posted/reversed/cancelled entries are immutable per BFL
* 5 kap. (corrections go through storno). The DB immutability trigger is the
* backstop — this gives a clean, translatable 409 before we reach it.
*/
export class CannotEditNonDraftError extends Error {
readonly code = CANNOT_EDIT_NON_DRAFT
constructor(public readonly currentStatus: string) {
super('Only draft entries can be edited')
this.name = 'CannotEditNonDraftError'
}
}
export class EntryAlreadyReversedError extends Error {
readonly code = ENTRY_ALREADY_REVERSED
constructor() {
@@ -269,6 +284,7 @@ export function isBookkeepingError(err: unknown): boolean {
err instanceof JournalEntryNotFoundError ||
err instanceof CannotReverseNonPostedError ||
err instanceof CannotCorrectNonPostedError ||
err instanceof CannotEditNonDraftError ||
err instanceof EntryAlreadyReversedError ||
err instanceof CurrencyRevaluationAlreadyExistsError ||
err instanceof InvalidMappingResultError ||
@@ -396,6 +412,19 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
)
}
if (err instanceof CannotEditNonDraftError) {
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: { currentStatus: err.currentStatus },
},
},
{ status: 409 }
)
}
if (err instanceof EntryAlreadyReversedError) {
return NextResponse.json(
{ error: { code: err.code, message: err.message } },