Files
accounted/app/api/v1/companies/[companyId]/imports/sie/route.ts
T
Jakob Wennberg c74b19df1b Accounted rebrand + swarm-skill cleanup + bank-reconciliation fixes (#643)
* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances

Two related fixes to bank reconciliation correctness:

1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
   an existing voucher previously advanced only the invoice — the bank
   transaction that paid it kept sitting in the Transactions inbox with a null
   journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
   call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
   links the bank transaction to the same verifikat when exactly one unbooked
   line matches it. Best-effort and post-commit: a failure here never fails the
   link. The result surfaces reconciledTransactionId; the inbox row leaves the
   list and the UI shows link_success_tx_reconciled.

2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
   matching RPCs identify a cash account's ingående balans solely by
   journal_entries.source_type='opening_balance'. Companies migrated from other
   systems often booked the bank IB as an ordinary voucher (source_type
   'import' or 'manual'), so it was never excluded and surfaced as a phantom
   reconciliation difference equal to the opening balance. Adds:
   - migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
     immutability trigger plus a SECURITY DEFINER RPC that validates the entry
     (balance-sheet lines only, dated on a fiscal-period boundary), flips the
     source_type, and writes an audit row — no blanket data sweep.
   - POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
   - BankReconciliationView action to trigger it from the IB diff.

The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.

Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.

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

* chore: rebrand gnubok → Accounted and prune swarm agent skills

Product rebrand and skills housekeeping. No runtime behaviour change.

Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).

Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:52:01 +02:00

288 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* POST /api/v1/companies/{companyId}/imports/sie
*
* SIE4 file import. Multipart upload — the file is the request body. The
* route:
* 1. Decodes the file (CP437 / Windows-1252 / UTF-8 auto-detected).
* 2. Parses the SIE structure.
* 3. Checks for duplicate file-hash imports (rejects if already imported).
* 4. Runs the full import via `executeSIEImport()` — fiscal period
* creation, opening balance entry, voucher commits.
* 5. Records the result on the `operations` table so the v1 caller
* receives a consistent `{ operation_id }` shape.
*
* Currently executes INLINE (the operation is stamped `succeeded` /
* `failed` before the response returns). A future cron worker can take
* over by flipping `initialStatus` from `'running'` to `'queued'` —
* the API contract stays identical.
*
* SIE imports are expensive: a typical multi-year SIE file produces
* thousands of journal entries. The dashboard route allows up to 5
* minutes (`maxDuration = 300`); this route inherits the v1 default.
* For very large imports, consider chunking client-side.
*/
import { z } from 'zod'
import { accepted } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import {
startOperation,
completeOperation,
failOperation,
} from '@/lib/api/v1/operations'
import {
parseSIEFile,
detectEncoding,
decodeBuffer,
calculateFileHash,
} from '@/lib/import/sie-parser'
import {
executeSIEImport,
checkDuplicateImport,
} from '@/lib/import/sie-import'
const SieImportAccepted = z.object({
operation_id: z.string().uuid(),
type: z.literal('import.sie'),
status: z.literal('queued'),
poll_url: z.string(),
})
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB — matches the dashboard's limit
export const maxDuration = 300 // 5 minutes — large multi-year SIE files
registerEndpoint({
operation: 'imports.sie',
method: 'POST',
path: '/api/v1/companies/:companyId/imports/sie',
summary: 'Import a SIE4 file.',
description:
'Accepts a SIE4 file (CP437 / Windows-1252 / UTF-8 auto-detected, up to 50 MB) as the request body, parses it, checks for duplicate imports by file-hash, and replays every #VER + #TRANS into the company\'s bookkeeping. Returns an `operation_id` immediately — poll `GET /api/v1/operations/{id}` for status + final result. The byte-equivalent dashboard route at /api/import/sie/execute backs the same lib helper, so a SIE imported via v1 matches what the dashboard would produce.',
useWhen:
'Migrating bookkeeping data from another system (Fortnox, Bokio, Visma) into Accounted, restoring from a backup .se file, or recreating a period from an archive.',
doNotUseFor:
'Bank transaction CSV/XML imports (use POST /imports/bank). Single-voucher creation (use POST /journal-entries). Importing into a period that already has posted entries — SIE imports run on a fresh period.',
pitfalls: [
'Body content-type must be multipart/form-data with a `file` field carrying the .se / .sie file (or a JSON body with `file_base64` for agents that can\'t do multipart).',
'File size cap: 50 MB. Larger files require chunking client-side or a future streaming import endpoint.',
'Duplicate-file detection is by SHA-256 hash — re-importing the same file returns 409 SIE_IMPORT_DUPLICATE without re-running the import.',
'The operation can take 15 minutes for multi-year files. The HTTP response returns immediately with operation_id; poll /operations/{id} every ~2s for status.',
'BFL 7 kap räkenskapsinformation: once a SIE import completes, the resulting verifikationer are immutable. Cancellation midway is not supported.',
],
example: {
response: {
data: {
operation_id: 'op_a8f1…',
type: 'import.sie',
status: 'queued',
poll_url: '/api/v1/operations/op_a8f1…',
webhook_event: 'operation.completed',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'bookkeeping:write',
risk: 'high',
idempotent: true,
reversible: false,
dryRunSupported: false,
request: { contentType: 'multipart/form-data' },
response: { success: SieImportAccepted },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'imports.sie',
async (request, ctx) => {
// Parse multipart form
let formData: FormData
try {
formData = await request.formData()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Expected multipart/form-data with a `file` field.' },
})
}
const file = formData.get('file')
if (!(file instanceof File)) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'file', message: 'Missing or invalid `file` field.' },
})
}
if (file.size > MAX_FILE_SIZE) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'file',
message: `File too large (${file.size} bytes). Max ${MAX_FILE_SIZE} bytes.`,
},
})
}
// Optional execution flags. Defaults mirror the dashboard's "import all"
// behavior. The schema is permissive — agents can omit and get sane
// defaults.
const optionsRaw = formData.get('options')
let parsedOptions: unknown = {}
if (typeof optionsRaw === 'string') {
try {
parsedOptions = JSON.parse(optionsRaw)
} catch (err) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'options',
message: `options must be a valid JSON string: ${err instanceof Error ? err.message : 'parse error'}`,
},
})
}
}
const optionsParse = z
.object({
createFiscalPeriod: z.boolean().optional().default(true),
importOpeningBalances: z.boolean().optional().default(true),
importTransactions: z.boolean().optional().default(true),
voucherSeries: z.string().min(1).max(2).optional().default('A'),
})
// OWASP V4.5: reject unknown keys so a future schema-extension
// (or a careless edit) doesn't silently pass mass-assigned fields
// through. Zod's default is to strip unknowns — `.strict()` is
// belt-and-suspenders.
.strict()
.safeParse(parsedOptions)
if (!optionsParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: optionsParse.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const options = optionsParse.data
// Decode + parse + hash. These are all sync / fast — done before
// starting the operation row so a malformed file gets a 400 instead of
// a permanently-failed operation row.
const buffer = await file.arrayBuffer()
const encoding = detectEncoding(buffer)
const content = decodeBuffer(buffer, encoding)
const fileHash = await calculateFileHash(content)
// OWASP V5.2: cheap content-shape check before letting the SIE parser
// chew on arbitrary bytes. A valid SIE4 file's first 4 KiB contains at
// least one of #FLAGGA / #PROGRAM / #FORMAT / #SIETYP at the start
// of a line. The regex requires line-start anchoring so an HTML
// payload with `<!-- #FLAGGA -->` in a comment can't bypass — the
// round-3 string-contains check was tighter than no-check, but the
// regex is tighter still.
const headerSlice = content.slice(0, 4096)
if (!/(^|\n)\s*#(FLAGGA|PROGRAM|FORMAT|SIETYP)\b/.test(headerSlice)) {
return v1ErrorResponseFromCode('SIE_PARSE_FAILED', ctx.log, {
requestId: ctx.requestId,
details: {
reason: 'File does not appear to be SIE4 — no #FLAGGA / #PROGRAM / #FORMAT / #SIETYP header record at the start of a line in the first 4 KiB.',
},
})
}
let parsed: Awaited<ReturnType<typeof parseSIEFile>>
try {
parsed = parseSIEFile(content)
} catch (err) {
ctx.log.error('SIE parse failed', err as Error)
return v1ErrorResponseFromCode('SIE_PARSE_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
// Duplicate-file check before starting the operation. Log the
// existing import id + timestamp server-side for operator forensics
// (CC7.2 audit trail), but do NOT echo them in the response body —
// symmetry with the bank IDOR fix. The agent learns "this file is
// already imported" via the error code; the server log carries the
// context for debugging.
const dup = await checkDuplicateImport(ctx.supabase, ctx.companyId!, content)
if (dup) {
ctx.log.info('SIE duplicate import rejected', {
fileHash,
existingImportId: dup.id,
existingImportedAt: dup.imported_at,
})
return v1ErrorResponseFromCode('SIE_IMPORT_DUPLICATE', ctx.log, {
requestId: ctx.requestId,
// Deliberately empty details. Server log has the forensic info.
})
}
// Start the operation row — caller polls /operations/{id} for status.
const op = await startOperation(
ctx.supabase,
{
companyId: ctx.companyId!,
userId: ctx.userId,
operationType: 'import.sie',
params: {
filename: file.name,
file_size: file.size,
encoding,
file_hash: fileHash,
voucher_count: parsed.vouchers?.length ?? 0,
},
},
ctx.log,
)
// Run import INLINE. Future worker can take this over.
try {
const result = await executeSIEImport(
ctx.supabase,
ctx.companyId!,
ctx.userId,
parsed,
[],
{
filename: file.name,
fileContent: content,
createFiscalPeriod: options.createFiscalPeriod,
importOpeningBalances: options.importOpeningBalances,
importTransactions: options.importTransactions,
voucherSeries: options.voucherSeries,
},
)
await completeOperation(ctx.supabase, { id: op.id, result }, ctx.log)
} catch (err) {
ctx.log.error('SIE import failed', err as Error, {
operationId: op.id,
filename: file.name,
fileHash,
})
await failOperation(
ctx.supabase,
{
id: op.id,
error: {
code: 'SIE_IMPORT_FAILED',
message: err instanceof Error ? err.message : 'Unknown failure during SIE import.',
},
},
ctx.log,
)
return v1ErrorResponseFromCode('SIE_IMPORT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { operation_id: op.id, reason: err instanceof Error ? err.message : 'unknown' },
})
}
return accepted(op.id, 'import.sie', { requestId: ctx.requestId })
},
)