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:
co-authored by
Claude Opus 4.8
parent
2d6ddeafc5
commit
241959513b
@@ -1061,6 +1061,7 @@ export const UpdateSettingsSchema = z.object({
|
||||
next_invoice_number: z.number().int().positive().optional(),
|
||||
invoice_default_days: z.number().int().positive().optional(),
|
||||
invoice_default_notes: z.string().nullable().optional(),
|
||||
default_our_reference: z.string().max(200).nullable().optional(),
|
||||
phone: z.string().optional(),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
website: z.string().optional().or(z.literal('')),
|
||||
|
||||
@@ -388,6 +388,76 @@ describe('withApiV1 — dry-run', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('withApiV1 — test mode', () => {
|
||||
it('blocks a test-key write on a non-simulatable endpoint (403 TEST_KEY_WRITE_BLOCKED)', async () => {
|
||||
// No route modules are imported here, so the endpoint registry is empty →
|
||||
// getEndpointByConcretePath returns undefined → the wrapper must refuse the
|
||||
// write rather than let a test key mutate real data.
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
scopes: ['invoices:write'],
|
||||
mode: 'test',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' }))
|
||||
|
||||
let handlerCalled = false
|
||||
const handler = withApiV1(
|
||||
'invoices.create',
|
||||
async (_req, ctx) => {
|
||||
handlerCalled = true
|
||||
return ok({ ok: true }, { requestId: ctx.requestId })
|
||||
},
|
||||
{ requireScope: 'invoices:write' },
|
||||
)
|
||||
|
||||
const res = await handler(
|
||||
makeRequest('https://x.test/api/v1/companies/company-1/invoices', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer gnubok_sk_x', 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
}),
|
||||
companyParams('company-1'),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('TEST_KEY_WRITE_BLOCKED')
|
||||
expect(handlerCalled).toBe(false)
|
||||
})
|
||||
|
||||
it('allows a test-key READ unchanged — no forced dry-run, real data, X-Gnubok-Mode header', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
scopes: ['companies:read'],
|
||||
mode: 'test',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' }))
|
||||
|
||||
let observedDryRun: boolean | null = null
|
||||
const handler = withApiV1(
|
||||
'companies.get',
|
||||
async (_req, ctx) => {
|
||||
observedDryRun = ctx.dryRun
|
||||
return ok({ ok: true }, { requestId: ctx.requestId })
|
||||
},
|
||||
{ requireScope: 'companies:read' },
|
||||
)
|
||||
|
||||
const res = await handler(
|
||||
makeRequest('https://x.test/api/v1/companies/company-1', {
|
||||
headers: { Authorization: 'Bearer gnubok_sk_x' },
|
||||
}),
|
||||
companyParams('company-1'),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(observedDryRun).toBe(false)
|
||||
expect(res.headers.get('X-Gnubok-Mode')).toBe('test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withApiV1 — public endpoints', () => {
|
||||
it('invokes the handler without authentication for /api/v1/health', async () => {
|
||||
let observedUserId: string | null = null
|
||||
|
||||
@@ -135,6 +135,26 @@ export function getEndpoint(method: HttpMethod, path: string): EndpointDefinitio
|
||||
return ENDPOINTS.get(`${method} ${path}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the registered endpoint for a CONCRETE request path (e.g.
|
||||
* `/api/v1/companies/abc/customers`) by matching it against the registered
|
||||
* `:param` patterns. Used by the wrapper to read an endpoint's `dryRunSupported`
|
||||
* flag at request time — the route module being served has already run its
|
||||
* `registerEndpoint()` call, so its pattern is present. Returns undefined when
|
||||
* no pattern matches (the wrapper treats that as "cannot be simulated").
|
||||
*/
|
||||
export function getEndpointByConcretePath(
|
||||
method: string,
|
||||
concretePath: string,
|
||||
): EndpointDefinition | undefined {
|
||||
for (const def of ENDPOINTS.values()) {
|
||||
if (def.method !== method) continue
|
||||
const regex = new RegExp('^' + def.path.replace(/:[^/]+/g, '[^/]+') + '$')
|
||||
if (regex.test(concretePath)) return def
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Minimal Zod → JSON Schema converter
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
// idempotent (guarded by a module-level boolean).
|
||||
ensureInitialized()
|
||||
import { resolveRequiredScope } from '@/lib/auth/scopes'
|
||||
import { getEndpointByConcretePath } from './registry'
|
||||
import {
|
||||
checkIdempotencyKey,
|
||||
hashRequest,
|
||||
@@ -79,7 +80,11 @@ export interface ApiV1Context {
|
||||
apiKeyName: string | undefined
|
||||
/** Scopes granted to the calling key. */
|
||||
scopes: ApiKeyScope[]
|
||||
/** test|live — handlers branch on this to short-circuit external providers in test mode. */
|
||||
/**
|
||||
* test|live. Test keys are simulation-only — the wrapper forces `dryRun` on
|
||||
* for every write, so handlers never need to special-case `mode`; they just
|
||||
* honor `dryRun` as usual.
|
||||
*/
|
||||
mode: ApiKeyMode
|
||||
/** Service-role Supabase client (no cookies). All queries MUST filter by company_id. */
|
||||
supabase: SupabaseClient
|
||||
@@ -353,6 +358,28 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
|
||||
const idempotencyKey = request.headers.get(IDEMPOTENCY_HEADER)
|
||||
const isMutation = REQUIRES_IDEMPOTENCY.has(request.method)
|
||||
|
||||
// Test keys are simulation-only: every write is forced to dry-run so
|
||||
// nothing persists (the credential bakes in `?dry_run=true`). A mutating
|
||||
// endpoint that can't be simulated (dryRunSupported=false, or unregistered)
|
||||
// would otherwise write for real — block it outright so a test key can
|
||||
// never touch real data. Reads pass through unchanged (real data, no write).
|
||||
let forceDryRun = false
|
||||
if (auth.mode === 'test' && isMutation) {
|
||||
const endpoint = getEndpointByConcretePath(request.method, path)
|
||||
if (!endpoint || !endpoint.dryRunSupported) {
|
||||
userLog.warn('test key blocked from non-simulatable endpoint', {
|
||||
path,
|
||||
method: request.method,
|
||||
...forensic,
|
||||
})
|
||||
return await v1ErrorResponseFromCode('TEST_KEY_WRITE_BLOCKED', userLog, {
|
||||
requestId,
|
||||
details: { path, method: request.method },
|
||||
})
|
||||
}
|
||||
forceDryRun = true
|
||||
}
|
||||
|
||||
if (options.requireIdempotencyKey && isMutation && !idempotencyKey) {
|
||||
userLog.warn('missing idempotency key on mutating request')
|
||||
return await v1ErrorResponseFromCode('VALIDATION_ERROR', userLog, {
|
||||
@@ -391,8 +418,8 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Dry-run resolution.
|
||||
const dryRun = isDryRun(workingRequest, url)
|
||||
// 8. Dry-run resolution. Test keys force it on regardless of the flag.
|
||||
const dryRun = isDryRun(workingRequest, url) || forceDryRun
|
||||
|
||||
const ctx: ApiV1Context = {
|
||||
requestId,
|
||||
@@ -411,6 +438,12 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
|
||||
// 9. Invoke handler.
|
||||
const response = await handler(workingRequest, ctx, params)
|
||||
|
||||
// Signal test mode on every test-key response so integrators can see the
|
||||
// request was simulation-only without inspecting the body.
|
||||
if (ctx.mode === 'test') {
|
||||
response.headers.set('X-Gnubok-Mode', 'test')
|
||||
}
|
||||
|
||||
// 10. Persist idempotency cache (best-effort).
|
||||
if (idempotencyKey && isMutation && companyId && response.status < 500) {
|
||||
try {
|
||||
|
||||
+11
-2
@@ -291,10 +291,19 @@ export function createServiceClientNoCookies() {
|
||||
)
|
||||
}
|
||||
|
||||
export function generateApiKey(): { key: string; hash: string; prefix: string } {
|
||||
export function generateApiKey(mode: ApiKeyMode = 'live'): { key: string; hash: string; prefix: string } {
|
||||
const random = crypto.randomBytes(32).toString('base64url')
|
||||
const key = `${KEY_PREFIX}${random}`
|
||||
// Test keys carry an explicit `test_` infix so integrators can tell at a
|
||||
// glance which environment a key targets (matches the llms.txt contract:
|
||||
// `gnubok_sk_test_<random>`). The infix is purely cosmetic — the authoritative
|
||||
// mode is the `mode` column on api_keys, read back by hash in validateApiKey,
|
||||
// so nothing trusts the key string. Both variants keep the `gnubok_sk_`
|
||||
// prefix so the `startsWith(KEY_PREFIX)` check in validateApiKey still holds.
|
||||
const key = mode === 'test' ? `${KEY_PREFIX}test_${random}` : `${KEY_PREFIX}${random}`
|
||||
const hash = hashApiKey(key)
|
||||
// First 18 chars: 'gnubok_sk_test_xyz' for test keys, 'gnubok_sk_xxxxxxxx'
|
||||
// for live — the stored prefix is what the settings UI shows, so the test_
|
||||
// infix is visible in the key list without exposing the secret.
|
||||
const prefix = key.slice(0, KEY_PREFIX.length + 8)
|
||||
return { key, hash, prefix }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -117,6 +117,16 @@ const GENERIC: Record<string, StructuredErrorEntry> = {
|
||||
resource: 'Accounted://capabilities',
|
||||
},
|
||||
},
|
||||
TEST_KEY_WRITE_BLOCKED: {
|
||||
httpStatus: 403,
|
||||
message_sv:
|
||||
'Den här åtgärden kan inte simuleras och är därför inte tillgänglig med en testnyckel. Använd en live-nyckel.',
|
||||
message_en:
|
||||
'This endpoint cannot be simulated, so it is not available with a test key. Test keys force dry-run on every write; use a live key for endpoints that do not support dry-run.',
|
||||
remediation: {
|
||||
description: 'Use a live key for this endpoint, or pick an endpoint that supports dry-run.',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -370,7 +370,9 @@ describe('ensureFiscalPeriod validation', () => {
|
||||
{ data: null, error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null }, // no earlier period
|
||||
{ data: [], error: null }, // no predecessor in the continuity chain
|
||||
{ data: { id: 'new-period-id' }, error: null }, // insert result
|
||||
{ data: [], error: null }, // no successor to relink
|
||||
])
|
||||
|
||||
const id = await ensureFiscalPeriod(
|
||||
@@ -393,7 +395,9 @@ describe('ensureFiscalPeriod validation', () => {
|
||||
{ data: null, error: null }, // containing check — no match
|
||||
{ data: [], error: null }, // overlapping check — none (2017 vs 2026)
|
||||
{ data: [], error: null }, // no earlier period than 2017-07-28
|
||||
{ data: [], error: null }, // no predecessor in the continuity chain
|
||||
{ data: { id: 'retro-first-year-id' }, error: null }, // insert
|
||||
{ data: [], error: null }, // no successor to relink
|
||||
])
|
||||
|
||||
const id = await ensureFiscalPeriod(
|
||||
@@ -482,7 +486,9 @@ describe('ensureFiscalPeriod validation', () => {
|
||||
{ data: [], error: null }, // journal_entries — none
|
||||
{ data: [], error: null }, // earlier-period check — none (mid-month start)
|
||||
{ data: null, error: null }, // delete result
|
||||
{ data: [], error: null }, // no predecessor in the continuity chain
|
||||
{ data: { id: 'replaced-id' }, error: null }, // insert result
|
||||
{ data: [], error: null }, // no successor to relink
|
||||
])
|
||||
|
||||
const id = await ensureFiscalPeriod(
|
||||
|
||||
@@ -487,6 +487,20 @@ export async function ensureFiscalPeriod(
|
||||
? `Räkenskapsår ${startYear}`
|
||||
: `Räkenskapsår ${startYear}/${endYear}`
|
||||
|
||||
// Link the BFNAR 2013:2 continuity chain so the resultatrapport can find the
|
||||
// prior year for its comparison column. Mirrors the manual fiscal-periods
|
||||
// route: point this period at its closest predecessor, then relink the
|
||||
// immediate successor (if any) to follow this one — so multi-year SIE files
|
||||
// chain correctly regardless of the order #RAR years are processed in.
|
||||
const { data: predecessors } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.lt('period_end', startDate)
|
||||
.order('period_end', { ascending: false })
|
||||
.limit(1)
|
||||
const previousPeriodId = predecessors && predecessors.length > 0 ? predecessors[0].id : null
|
||||
|
||||
const { data: newPeriod, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.insert({
|
||||
@@ -496,6 +510,7 @@ export async function ensureFiscalPeriod(
|
||||
period_end: endDate,
|
||||
is_closed: false,
|
||||
opening_balances_set: false,
|
||||
previous_period_id: previousPeriodId,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
@@ -504,6 +519,24 @@ export async function ensureFiscalPeriod(
|
||||
throw new Error(`Failed to create fiscal period: ${error?.message}`)
|
||||
}
|
||||
|
||||
// Relink the immediate successor (e.g. when an earlier year is imported after
|
||||
// a later one) so the chain holds in both directions.
|
||||
const { data: successors } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.gt('period_start', endDate)
|
||||
.neq('id', newPeriod.id)
|
||||
.order('period_start', { ascending: true })
|
||||
.limit(1)
|
||||
if (successors && successors.length > 0) {
|
||||
await supabase
|
||||
.from('fiscal_periods')
|
||||
.update({ previous_period_id: newPeriod.id })
|
||||
.eq('id', successors[0].id)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
|
||||
return newPeriod.id
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
* Shared helpers for invoice PDF render call sites.
|
||||
*
|
||||
* Wraps `brandingFromCompanySettings` so every PDF-rendering route gets a
|
||||
* consistent branding object.
|
||||
* consistent branding object, and builds the optional Swish payment QR.
|
||||
*/
|
||||
|
||||
import type { CompanySettings } from '@/types'
|
||||
import { brandingFromCompanySettings, type InvoiceBranding } from '@/lib/invoices/pdf-template'
|
||||
import QRCode from 'qrcode'
|
||||
import type { CompanySettings, Invoice } from '@/types'
|
||||
import { brandingFromCompanySettings, SHOW_SWISH_ON_INVOICE, type InvoiceBranding } from '@/lib/invoices/pdf-template'
|
||||
import { buildSwishQrPayload } from '@/lib/payments/swish'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('invoice.swish-qr')
|
||||
|
||||
export interface InvoicePdfRenderExtras {
|
||||
branding: InvoiceBranding
|
||||
@@ -15,3 +21,46 @@ export interface InvoicePdfRenderExtras {
|
||||
export function prepareInvoicePdfRender(company: CompanySettings): InvoicePdfRenderExtras {
|
||||
return { branding: brandingFromCompanySettings(company) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Swish payment QR for an invoice as a PNG data URL, or null when:
|
||||
* Swish display is off, there's no/invalid Swish number, the invoice isn't in
|
||||
* SEK (Swish is SEK-only), or the amount is not positive. Generated locally with
|
||||
* the `qrcode` lib — no call to any Swish API. Pass the result to InvoicePDF's
|
||||
* `swishQrDataUrl` prop; the template gates rendering on the same payment box
|
||||
* that already shows the Swish number.
|
||||
*/
|
||||
export async function buildSwishQrDataUrl(
|
||||
company: CompanySettings,
|
||||
invoice: Invoice,
|
||||
): Promise<string | null> {
|
||||
// Swish on invoices is "coming soon" — gated off in pdf-template. Bail before
|
||||
// any work while the feature is disabled.
|
||||
if (!SHOW_SWISH_ON_INVOICE) return null
|
||||
// Swish display off is the normal "no QR" case — stay quiet. Every other
|
||||
// skip is logged so a missing QR is diagnosable instead of silent.
|
||||
if (!(company.invoice_show_swish ?? false)) return null
|
||||
if ((invoice.currency ?? 'SEK') !== 'SEK') {
|
||||
log.info('swish QR skipped: invoice not in SEK', { invoiceId: invoice.id, currency: invoice.currency })
|
||||
return null
|
||||
}
|
||||
const amount = getDisplayTotal(invoice, company).displayed
|
||||
const payload = buildSwishQrPayload(company.swish, amount, invoice.invoice_number ?? '')
|
||||
if (!payload) {
|
||||
log.warn('swish QR skipped: invalid number or non-positive amount', {
|
||||
invoiceId: invoice.id,
|
||||
hasSwish: !!company.swish,
|
||||
amount,
|
||||
})
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return await QRCode.toDataURL(payload, { margin: 1, width: 240, errorCorrectionLevel: 'M' })
|
||||
} catch (err) {
|
||||
log.warn('swish QR generation failed', {
|
||||
invoiceId: invoice.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ const LABELS = {
|
||||
bic: 'BIC/SWIFT:',
|
||||
ocr: 'OCR/Referens:',
|
||||
paymentReference: 'Betalningsreferens:',
|
||||
invoiceNumber: 'Fakturanummer:',
|
||||
swishQrCaption: 'Skanna för att betala med Swish',
|
||||
// Footer
|
||||
orgNoLong: 'Org.nr:',
|
||||
vatRegNo: 'Momsreg.nr:',
|
||||
@@ -146,6 +148,8 @@ const LABELS = {
|
||||
bic: 'BIC/SWIFT:',
|
||||
ocr: 'Reference:',
|
||||
paymentReference: 'Payment reference:',
|
||||
invoiceNumber: 'Invoice number:',
|
||||
swishQrCaption: 'Scan to pay with Swish',
|
||||
orgNoLong: 'Reg. no.:',
|
||||
vatRegNo: 'VAT reg. no.:',
|
||||
// Statutory Swedish phrase — kept verbatim in both locales. Peppol SE-R-005
|
||||
@@ -155,6 +159,11 @@ const LABELS = {
|
||||
},
|
||||
} as const
|
||||
|
||||
// Swish on invoices (the number row + the payment QR) is "coming soon" — gated
|
||||
// off until the QR flow is finished. Flip to true to re-enable both at once;
|
||||
// the settings "Visa Swish" toggle is disabled while this is false.
|
||||
export const SHOW_SWISH_ON_INVOICE = false
|
||||
|
||||
// Labor-only disclaimer for the ROT/RUT block. Kept Swedish-only in both
|
||||
// locales — references Skatteverket's fakturamodell directly, which is a
|
||||
// statutory Swedish concept and has no formal English equivalent.
|
||||
@@ -622,9 +631,12 @@ interface InvoicePDFProps {
|
||||
* suite and for callers that haven't yet been migrated to forward branding.
|
||||
*/
|
||||
branding?: InvoiceBranding
|
||||
/** Pre-rendered Swish payment QR (PNG data URL). Built offline in
|
||||
* pdf-render-helpers; null/omitted renders no QR. */
|
||||
swishQrDataUrl?: string | null
|
||||
}
|
||||
|
||||
export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview, language, branding }: InvoicePDFProps) {
|
||||
export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview, language, branding, swishQrDataUrl }: InvoicePDFProps) {
|
||||
const lang: PdfLang = language ?? customer.language ?? 'sv'
|
||||
const L = LABELS[lang]
|
||||
// Build the stylesheet per-render so each invoice picks up its company's
|
||||
@@ -1039,7 +1051,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text style={styles.paymentValue}>{company.plusgiro}</Text>
|
||||
</View>
|
||||
)}
|
||||
{company.swish && (company.invoice_show_swish ?? false) && (
|
||||
{SHOW_SWISH_ON_INVOICE && company.swish && (company.invoice_show_swish ?? false) && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>{L.swish}</Text>
|
||||
<Text style={styles.paymentValue}>{company.swish}</Text>
|
||||
@@ -1061,16 +1073,22 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text style={styles.paymentLabel}>{L.dueDate}</Text>
|
||||
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{formatDate(invoice.due_date)}</Text>
|
||||
</View>
|
||||
{invoice.invoice_number && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>{L.invoiceNumber}</Text>
|
||||
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{invoice.invoice_number}</Text>
|
||||
</View>
|
||||
)}
|
||||
{(company.invoice_show_ocr ?? true) && (company.bankgiro || company.plusgiro) && lang === 'sv' && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>{L.ocr}</Text>
|
||||
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{invoice.invoice_number ? generateOcrReference(invoice.invoice_number) : '—'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{lang !== 'sv' && invoice.invoice_number && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>{L.paymentReference}</Text>
|
||||
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{invoice.invoice_number}</Text>
|
||||
{swishQrDataUrl && (
|
||||
<View style={{ marginTop: 10, alignItems: 'center' }}>
|
||||
<Image src={swishQrDataUrl} style={{ width: 96, height: 96 }} />
|
||||
<Text style={[styles.paymentLabel, { marginTop: 2 }]}>{L.swishQrCaption}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import {
|
||||
generateInvoiceEmailHtml,
|
||||
@@ -385,6 +385,7 @@ async function sendInvoiceFromSchedule(
|
||||
// receive a "UTKAST" stamp.
|
||||
const renderableInvoice = { ...invoice, status: 'sent' as const }
|
||||
const { branding } = prepareInvoicePdfRender(company)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(company, renderableInvoice)
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: renderableInvoice,
|
||||
@@ -392,6 +393,7 @@ async function sendInvoiceFromSchedule(
|
||||
items,
|
||||
company,
|
||||
branding,
|
||||
swishQrDataUrl,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { normaliseSwish, isValidSwish } from '../swish'
|
||||
import { normaliseSwish, isValidSwish, buildSwishQrPayload } from '../swish'
|
||||
|
||||
describe('normaliseSwish', () => {
|
||||
it('strips whitespace and hyphens', () => {
|
||||
@@ -37,3 +37,37 @@ describe('isValidSwish', () => {
|
||||
expect(isValidSwish('123abc4567')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSwishQrPayload', () => {
|
||||
it('builds a fully-locked Type C payload for a Swish-företag number', () => {
|
||||
expect(buildSwishQrPayload('1234567890', 1250, 'Faktura 100')).toBe('C1234567890;1250.00;Faktura 100;0')
|
||||
})
|
||||
|
||||
it('works for a mobile-number payee with the same syntax', () => {
|
||||
expect(buildSwishQrPayload('0701234567', 99.5, 'F-1')).toBe('C0701234567;99.50;F-1;0')
|
||||
})
|
||||
|
||||
it('normalises spaces/hyphens in the number', () => {
|
||||
expect(buildSwishQrPayload('123 456 78 90', 10, 'x')).toBe('C1234567890;10.00;x;0')
|
||||
})
|
||||
|
||||
it('strips the ; field delimiter from the message and trims it', () => {
|
||||
expect(buildSwishQrPayload('1234567890', 10, ' a;b ')).toBe('C1234567890;10.00;a b;0')
|
||||
})
|
||||
|
||||
it('formats the amount with two decimals', () => {
|
||||
expect(buildSwishQrPayload('1234567890', 100, 'x')).toBe('C1234567890;100.00;x;0')
|
||||
expect(buildSwishQrPayload('1234567890', 1234.5, 'x')).toBe('C1234567890;1234.50;x;0')
|
||||
})
|
||||
|
||||
it('returns null for an invalid or empty number', () => {
|
||||
expect(buildSwishQrPayload('12345', 10, 'x')).toBeNull()
|
||||
expect(buildSwishQrPayload('', 10, 'x')).toBeNull()
|
||||
expect(buildSwishQrPayload(null, 10, 'x')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a non-positive amount', () => {
|
||||
expect(buildSwishQrPayload('1234567890', 0, 'x')).toBeNull()
|
||||
expect(buildSwishQrPayload('1234567890', -5, 'x')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* canonicalised.
|
||||
*/
|
||||
|
||||
import { roundOre } from '@/lib/money'
|
||||
|
||||
const SWISH_FORETAG = /^123\d{7}$/
|
||||
const SWEDISH_MOBILE = /^07\d{8}$/
|
||||
|
||||
@@ -21,3 +23,29 @@ export function normaliseSwish(value: string | null | undefined): string {
|
||||
export function isValidSwish(normalised: string): boolean {
|
||||
return normalised === '' || SWISH_FORETAG.test(normalised) || SWEDISH_MOBILE.test(normalised)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Swish "Type C" QR payload — `C<payee>;<amount>;<message>;<editmask>`.
|
||||
*
|
||||
* editmask 0 locks payee, amount and message, so the Swish app opens prefilled
|
||||
* and uneditable. This is the documented format the Swish app scans directly,
|
||||
* so the QR can be generated entirely offline (no call to Swish's QR API).
|
||||
* Works for both Swish-företag (123XXXXXXX) and mobile (07XXXXXXXX) payees.
|
||||
*
|
||||
* Returns null when the number is missing/invalid or the amount is not positive.
|
||||
* Spec: Swish QR Code Design Specification (Getswish AB).
|
||||
*/
|
||||
export function buildSwishQrPayload(
|
||||
swishNumber: string | null | undefined,
|
||||
amount: number,
|
||||
message: string,
|
||||
): string | null {
|
||||
const number = normaliseSwish(swishNumber)
|
||||
if (!number || !isValidSwish(number)) return null
|
||||
if (!(amount > 0)) return null
|
||||
// Amount uses a dot decimal with at most two decimals. The message must not
|
||||
// contain the ';' field delimiter; cap its length to keep the QR scannable.
|
||||
const amt = roundOre(amount).toFixed(2)
|
||||
const msg = (message ?? '').replace(/;/g, ' ').trim().slice(0, 50)
|
||||
return `C${number};${amt};${msg};0`
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
import { uploadDocument, linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
@@ -996,6 +996,7 @@ async function commitSendInvoice(
|
||||
// would stamp the customer's PDF with "UTKAST – inte en giltig faktura".
|
||||
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
|
||||
const { branding } = prepareInvoicePdfRender(company as CompanySettings)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, renderableInvoice)
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: renderableInvoice,
|
||||
@@ -1004,6 +1005,7 @@ async function commitSendInvoice(
|
||||
company: company as CompanySettings,
|
||||
originalInvoiceNumber,
|
||||
branding,
|
||||
swishQrDataUrl,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -219,6 +219,57 @@ describe('generateResultatrapport', () => {
|
||||
expect(report.groups[0].rows[0].account_number).toBe('3001')
|
||||
})
|
||||
|
||||
it('falls back to the date-adjacent prior period when previous_period_id is null', async () => {
|
||||
// Reproduces the multi-year-SIE bug: the continuity chain was never linked,
|
||||
// so the comparison must resolve the prior year by date instead.
|
||||
const q = createQueuedMockSupabase()
|
||||
q.enqueue({
|
||||
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
|
||||
error: null,
|
||||
})
|
||||
// Date-range fallback finds the immediately-preceding period.
|
||||
q.enqueue({ data: [{ id: 'period-0' }], error: null })
|
||||
// Prior-period dates.
|
||||
q.enqueue({ data: { period_start: '2025-01-01', period_end: '2025-12-31' }, error: null })
|
||||
|
||||
mockTrialBalance
|
||||
.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 200000 })])
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 150000 })])
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
|
||||
|
||||
expect(report.groups[0].rows[0].current_period).toBe(200000)
|
||||
expect(report.groups[0].rows[0].prior_period).toBe(150000)
|
||||
expect(report.prior_period).toEqual({ start: '2025-01-01', end: '2025-12-31' })
|
||||
// The fallback resolved 'period-0' and the prior TB was fetched for it.
|
||||
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-0')
|
||||
})
|
||||
|
||||
it('leaves the prior column empty when there is no earlier period at all', async () => {
|
||||
const q = createQueuedMockSupabase()
|
||||
q.enqueue({
|
||||
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
|
||||
error: null,
|
||||
})
|
||||
q.enqueue({ data: [], error: null }) // no date-adjacent predecessor
|
||||
|
||||
mockTrialBalance.mockResolvedValueOnce(
|
||||
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 100000 })])
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
|
||||
|
||||
expect(report.prior_period).toBeNull()
|
||||
expect(report.net_result_prior).toBe(0)
|
||||
expect(mockTrialBalance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws when fiscal period not found', async () => {
|
||||
const q = createQueuedMockSupabase()
|
||||
q.enqueue({ data: null, error: null })
|
||||
|
||||
@@ -61,18 +61,36 @@ export async function generateResultatrapport(
|
||||
let priorRows: TrialBalanceRow[] = []
|
||||
let priorPeriodInfo: { start: string; end: string } | null = null
|
||||
const isFullPeriod = !options?.fromDate && !options?.toDate
|
||||
if (isFullPeriod && period.previous_period_id) {
|
||||
const { data: prior } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', period.previous_period_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (isFullPeriod) {
|
||||
// Prefer the explicit continuity chain; fall back to the period that ends
|
||||
// immediately before this one. The fallback keeps the comparison working
|
||||
// for companies whose chain was never linked — e.g. multi-year SIE imports
|
||||
// created before the importer started setting previous_period_id.
|
||||
let priorPeriodId: string | null = period.previous_period_id ?? null
|
||||
if (!priorPeriodId) {
|
||||
const { data: priorByDate } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.lt('period_end', period.period_start)
|
||||
.order('period_end', { ascending: false })
|
||||
.limit(1)
|
||||
priorPeriodId = priorByDate && priorByDate.length > 0 ? priorByDate[0].id : null
|
||||
}
|
||||
|
||||
if (prior) {
|
||||
const priorTb = await generateTrialBalance(supabase, companyId, period.previous_period_id)
|
||||
priorRows = filterPnl(priorTb.rows)
|
||||
priorPeriodInfo = { start: prior.period_start, end: prior.period_end }
|
||||
if (priorPeriodId) {
|
||||
const { data: prior } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', priorPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (prior) {
|
||||
const priorTb = await generateTrialBalance(supabase, companyId, priorPeriodId)
|
||||
priorRows = filterPnl(priorTb.rows)
|
||||
priorPeriodInfo = { start: prior.period_start, end: prior.period_end }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user