Files
accounted/app/api/transactions/[id]/attach-document/route.ts
T
MattssonandClaude Opus 4.8 abe9ac9d8c Fix/attributes config (#926)
* fix(git): pin LF on generated extension registry and vitest snapshots

setup:extensions and vitest write these files with LF; with
core.autocrlf=true git expects CRLF and flags them as phantom
modifications on every dev/build run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): enforce MFA on mcp-oauth consent and gate viewer storno route

mcp-oauth/authorize renders an HTML consent page and issues 303 redirects that withRouteContext cannot express, so it kept raw getUser() and thereby skipped the AAL2 gate: a password-only (AAL1) session could approve consent that mints a long-lived, MFA-bypassing API key. Add a route-local requireAal2() step-up on GET and POST; AAL1 sessions redirect to /mfa/verify, BankID users are exempt.

Separately, POST /api/reports/vat-declaration/rc-basis-gaps/fix calls correctEntry() (storno of a posted entry) but lacked requireWrite, so viewer-role members could trigger it. Add { requireWrite: true }.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route transactions endpoints through withRouteContext

Migrate the transactions routes off hand-rolled supabase.auth.getUser() onto the MFA-enforcing withRouteContext wrapper; add requireWrite on mutating handlers (book, uncategorize, attach-document, ignore, batch-match, create-from-document). Behavior and response shapes preserved; tests updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route SIE import and bank reconciliation through withRouteContext

Migrate import/sie and reconciliation/bank routes onto the MFA-enforcing wrapper; requireWrite on mutations (import execute, create-accounts, mappings write verbs, link/unlink/run/mark-opening-balance). Reads (status, unmatched-entries) stay ungated. Response shapes preserved; tests added/updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route salary endpoints through withRouteContext

Migrate salary employees and runs routes (plus ku, payroll-config, tax-tables) onto the MFA-enforcing wrapper; requireWrite on mutations. Personnummer masking/encryption untouched; file downloads (AGI XML, payslip PDF, payment files) keep their headers. Two payment-file GETs retain requireWrite because they stamp *_file_generated_at and previously gated viewers. Tests added/updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route report endpoints through withRouteContext

Migrate the read-only report routes (trial balance, balansrapport, resultatrapport, income statement, ledgers, KPI, VAT declaration, salary journal, monthly breakdown, journal register, continuity check, full archive, etc.) onto the MFA-enforcing wrapper. All read-only, no requireWrite. JSON/XLSX/PDF/ZIP response bodies and headers preserved byte-for-byte; tests updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route invoices, skatteverket, agent and extension endpoints through withRouteContext

Migrate invoices, supplier-invoices, skatteverket tax-payments, and dynamic extension routes onto the MFA-enforcing wrapper with requireWrite on mutations. The two NDJSON streaming agent routes (invoke, onboarding/stream) use requireAuth() directly (the wrapper can't wrap a streaming response) so MFA is still enforced. skatteverket payment-file GET keeps requireWrite (stamps a generated-at field). Response shapes and file headers preserved; tests added/updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route documents, events, team and account endpoints through withRouteContext

Migrate documents, events, kpi/preferences, vat/validate, support/contact onto the MFA-enforcing wrapper with requireWrite on mutations. account/password, team/accept and team/members use requireAuth() directly (user-level or pre-membership flows with no active company context) so MFA is still enforced. events keeps its dual API-key-or-session auth. Document retention guard untouched; tests added/updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route settings and pending-operations endpoints through withRouteContext

Migrate settings (api-keys, oauth-clients, booking-templates, counterparty-templates, logo, company settings) and pending-operations (commit, bulk-commit, reject, edit-before-approve) onto the MFA-enforcing wrapper with requireWrite on mutations. Credential-guarding routes keep their per-user ownership filters. Response shapes preserved; tests added/updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(guards): ratchet raw-route-auth baseline 119->1 after A1 migration

Lock in the withRouteContext migration so the count cannot regress. The single remaining entry, mcp-oauth/authorize, is a documented exception (HTML consent + redirects, MFA enforced via route-local step-up). Record the campaign and requireWrite decisions in DECISIONS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(vat): add eSKD momsdeklaration file export for "Deklarera via fil"

Generate the Skatteverket eSKDUpload v6.0 XML file so users can file VAT by
upload instead of typing every ruta into the form. Extract buildFiledAmounts()
as the shared whole-krona source of truth (öre truncated per SFL 22 kap 1 §) so
the XML file and the manual-filing PDF can never disagree. Adds the /eskd API
route, an XML option in the report export menu, and the upload button on the
manual-filing card. Strings in sv + en.

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

* feat(vat): add 'vat_settlement' source type and update related components

* fix(booking): adjust search input layout and enable autofocus

* fix(vat): support 12-digit org numbers and adjust emission order for eSKD file

* fix(migration): add 'vat_settlement' to journal_entries.source_type CHECK

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:54:46 +02:00

265 lines
10 KiB
TypeScript

import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { AttachDocumentSchema } from '@/lib/api/schemas'
import { appendProcessingHistory } from '@/lib/processing-history/append'
ensureInitialized()
/**
* POST /api/transactions/[id]/attach-document
*
* Pin an unmatched document_attachments row to a bank transaction. Lets users
* (or AI agents via MCP) bind a forwarded/uploaded invoice or receipt before
* the transaction is categorized. When the transaction is later categorized,
* the categorize route propagates the link to document_attachments.journal_entry_id.
* If the transaction is ALREADY booked, the propagation happens here instead
* (mirroring commitAttachDocumentToTransaction in lib/pending-operations/commit.ts).
*
* Idempotent: overwrites any existing link.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'transaction.attach_document',
async (request, { supabase, user, companyId }, { params }) => {
const { id: transactionId } = await params
const validation = await validateBody(request, AttachDocumentSchema)
if (!validation.success) return validation.response
const { document_id } = validation.data
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id, document_id, journal_entry_id')
.eq('id', transactionId)
.eq('company_id', companyId)
.maybeSingle()
if (txError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
const previousDocumentId = (transaction.document_id as string | null) ?? null
const { data: document, error: docError } = await supabase
.from('document_attachments')
.select('id, journal_entry_id')
.eq('id', document_id)
.eq('company_id', companyId)
.maybeSingle()
if (docError || !document) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// A document that already serves as underlag for a DIFFERENT verifikation
// cannot be pinned here: propagating would either corrupt that link or be
// blocked by the document-metadata immutability trigger. Same verifikation
// is fine (idempotent re-attach; propagation below becomes a no-op).
const docJournalEntryId = (document.journal_entry_id as string | null) ?? null
if (docJournalEntryId && docJournalEntryId !== transaction.journal_entry_id) {
return NextResponse.json(
{ error: 'Underlaget är redan kopplat till en annan verifikation.' },
{ status: 409 },
)
}
// Race-free read of journal_entry_id: UPDATE ... RETURNING so the value we
// propagate against reflects any concurrent categorize that committed before
// our UPDATE acquired the row lock. Mirrors commitAttachDocumentToTransaction
// in lib/pending-operations/commit.ts so REST and MCP attaches converge.
const { data: postUpdate, error: updateError } = await supabase
.from('transactions')
.update({ document_id })
.eq('id', transactionId)
.eq('company_id', companyId)
.select('journal_entry_id')
.maybeSingle()
if (updateError) {
const errMsg = (updateError as { message?: string }).message ?? ''
if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) {
return NextResponse.json(
{
error:
'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.',
},
{ status: 409 },
)
}
console.error('[attach-document] Failed to attach:', updateError)
return NextResponse.json({ error: 'Failed to attach document' }, { status: 500 })
}
if (!postUpdate) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// If this document came from an invoice_inbox_items row, mark that row
// as matched so the inbox UI can show it as "Kopplad" + link back to the
// transaction. Best-effort: a failure here must not roll back the
// (compliant) document attach.
//
// The Supabase client resolves with { error } rather than rejecting on
// RLS/DB errors, so we destructure rather than try/catch.
const { error: inboxLinkErr } = await supabase
.from('invoice_inbox_items')
.update({ matched_transaction_id: transactionId })
.eq('document_id', document_id)
.eq('company_id', companyId)
.is('matched_transaction_id', null)
.is('created_supplier_invoice_id', null)
if (inboxLinkErr) {
console.error('[attach-document] Failed to link inbox item:', inboxLinkErr)
}
// If the transaction is already booked, propagate the link onto the
// verifikation immediately (BFL 5 kap 6 §: the verifikation must reference
// its underlag). Skipped when the doc already points at this verifikation
// (idempotent re-attach). Mirrors commitAttachDocumentToTransaction.
const journalEntryId = (postUpdate.journal_entry_id as string | null) ?? null
if (journalEntryId && docJournalEntryId !== journalEntryId) {
const { error: linkErr } = await supabase
.from('document_attachments')
.update({ journal_entry_id: journalEntryId })
.eq('id', document_id)
.eq('company_id', companyId)
if (linkErr) {
// The enforce_period_lock trigger blocks journal_entry_id writes when
// the target entry sits in a closed/locked period.
const linkMsg = (linkErr as { message?: string }).message ?? ''
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(linkMsg)) {
// Honest about the partial write: the pin on the transaction (and the
// inbox back-link) persisted; only the verifikat link was blocked.
return NextResponse.json(
{
error:
'Bilagan kopplades till transaktionen men verifikationens period är låst: den kunde inte länkas till verifikationen.',
},
{ status: 409 },
)
}
// Surface the propagation failure rather than logging-and-continuing:
// a "succeeded" attach that left document_attachments.journal_entry_id
// null would be a silent compliance gap. A retry is idempotent.
console.error('[attach-document] Failed to propagate to journal entry:', linkErr)
return NextResponse.json(
{
error:
'Bilagan kopplades till transaktionen men kunde inte länkas till verifikationen. Försök igen: operationen är idempotent.',
},
{ status: 500 },
)
}
}
// Rättelse audit trail (BFL 5 kap 5 §): record swaps where a non-null doc
// was replaced. Best-effort: a logging failure must not roll back the
// (compliant) attach.
if (previousDocumentId && previousDocumentId !== document_id) {
try {
await appendProcessingHistory({
companyId,
correlationId: transactionId,
aggregateType: 'BankTransaction',
aggregateId: transactionId,
eventType: 'TransactionDocumentReplaced',
payload: {
transaction_id: transactionId,
previous_document_id: previousDocumentId,
new_document_id: document_id,
journal_entry_id: journalEntryId,
},
actor: { type: 'user', id: user.id },
occurredAt: new Date(),
})
} catch (logErr) {
console.error('[attach-document] Failed to append rättelse event:', logErr)
}
}
return NextResponse.json({
data: {
transaction_id: transactionId,
document_id,
previous_document_id: previousDocumentId,
journal_entry_id: journalEntryId,
},
})
},
{ requireWrite: true },
)
/**
* DELETE /api/transactions/[id]/attach-document
*
* Detach a document from a transaction.
*
* Blocked once the document has propagated into a journal entry (BFL 5 kap 6 §
* räkenskapsinformation immutability): at that point the doc is the
* verifikation's underlag and can only be undone by reversing the entry.
*/
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'transaction.detach_document',
async (_request, { supabase, companyId }, { params }) => {
const { id: transactionId } = await params
const { data: tx, error: fetchError } = await supabase
.from('transactions')
.select('id, document_id')
.eq('id', transactionId)
.eq('company_id', companyId)
.maybeSingle()
if (fetchError || !tx) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
if (tx.document_id) {
const { data: doc } = await supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('id', tx.document_id)
.eq('company_id', companyId)
.maybeSingle()
if (doc?.journal_entry_id) {
return NextResponse.json(
{
error:
'Bilagan är kopplad till en bokförd verifikation och kan inte tas bort. Storno verifikationen först.',
},
{ status: 409 },
)
}
}
const { error: updateError } = await supabase
.from('transactions')
.update({ document_id: null })
.eq('id', transactionId)
.eq('company_id', companyId)
if (updateError) {
// The enforce_transactions_document_immutability trigger raises a
// P0001 exception with a stable BFL_DOCUMENT_IMMUTABILITY: prefix when the
// previously-attached doc has already become räkenskapsinformation.
// Match on the prefix (not on the generic SQLSTATE) so unrelated future
// exceptions don't get translated into the Swedish underlag message.
const errMsg = (updateError as { message?: string }).message ?? ''
if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) {
return NextResponse.json(
{
error:
'Bilagan är kopplad till en bokförd verifikation och kan inte tas bort. Storno verifikationen först.',
},
{ status: 409 },
)
}
console.error('[attach-document] Failed to detach:', updateError)
return NextResponse.json({ error: 'Failed to detach document' }, { status: 500 })
}
return NextResponse.json({ data: { transaction_id: transactionId, document_id: null } })
},
{ requireWrite: true },
)