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>
This commit is contained in:
Mattsson
2026-07-08 09:54:46 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 63e05c4eec
commit abe9ac9d8c
204 changed files with 9841 additions and 6362 deletions
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
parseJsonResponse,
createMockRouteParams,
@@ -6,8 +7,10 @@ import {
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
@@ -15,8 +18,9 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({
@@ -30,7 +34,8 @@ const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
function makeReq(body: unknown, method: 'POST' | 'DELETE' = 'POST') {
@@ -43,13 +48,31 @@ function makeReq(body: unknown, method: 'POST' | 'DELETE' = 'POST') {
describe('POST /api/transactions/[id]/attach-document', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await POST(makeReq({ document_id: 'doc-1' }), createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 403 when the caller is a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const res = await POST(
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
createMockRouteParams({ id: 'tx-1' }),
)
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(403)
expect(body).toEqual({ error: 'Forbidden' })
})
it('returns 400 when document_id missing', async () => {
const res = await POST(makeReq({}), createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse(res)
@@ -234,13 +257,28 @@ describe('POST /api/transactions/[id]/attach-document', () => {
describe('DELETE /api/transactions/[id]/attach-document', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 403 when the caller is a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(403)
expect(body).toEqual({ error: 'Forbidden' })
})
it('returns 404 when transaction not in company', async () => {
enqueue({ data: null, error: null }) // tx fetch
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
+204 -222
View File
@@ -1,10 +1,8 @@
import { createClient } from '@/lib/supabase/server'
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 { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { appendProcessingHistory } from '@/lib/processing-history/append'
ensureInitialized()
@@ -21,183 +19,175 @@ ensureInitialized()
*
* Idempotent: overwrites any existing link.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: transactionId } = await params
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'transaction.attach_document',
async (request, { supabase, user, companyId }, { params }) => {
const { id: transactionId } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const validation = await validateBody(request, AttachDocumentSchema)
if (!validation.success) return validation.response
const { document_id } = validation.data
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id, document_id, journal_entry_id')
.eq('id', transactionId)
.eq('company_id', companyId)
.maybeSingle()
const companyId = await requireCompanyId(supabase, user.id)
if (txError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
const validation = await validateBody(request, AttachDocumentSchema)
if (!validation.success) return validation.response
const { document_id } = validation.data
const previousDocumentId = (transaction.document_id as string | null) ?? null
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id, document_id, journal_entry_id')
.eq('id', transactionId)
.eq('company_id', companyId)
.maybeSingle()
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 (txError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
if (docError || !document) {
return NextResponse.json({ error: 'Document 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')) {
// 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:
'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.',
},
{ error: 'Underlaget är redan kopplat till en annan verifikation.' },
{ 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)
// 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)
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.
.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 kopplades till transaktionen men verifikationens period är låst: den kunde inte länkas till verifikationen.',
'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.',
},
{ 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 },
)
console.error('[attach-document] Failed to attach:', updateError)
return NextResponse.json({ error: 'Failed to attach document' }, { 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)
if (!postUpdate) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
}
return NextResponse.json({
data: {
transaction_id: transactionId,
document_id,
previous_document_id: previousDocumentId,
journal_entry_id: journalEntryId,
},
})
}
// 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
@@ -208,75 +198,67 @@ export async function POST(
* räkenskapsinformation immutability): at that point the doc is the
* verifikation's underlag and can only be undone by reversing the entry.
*/
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: transactionId } = await params
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'transaction.detach_document',
async (_request, { supabase, companyId }, { params }) => {
const { id: transactionId } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
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)
const { data: tx, error: fetchError } = await supabase
.from('transactions')
.select('id, document_id')
.eq('id', transactionId)
.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 },
)
if (fetchError || !tx) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
}
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 },
)
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 },
)
}
}
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 } })
}
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 },
)
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
@@ -10,8 +11,10 @@ import {
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/init', () => ({
@@ -23,8 +26,9 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
const mockCreateJournalEntry = vi.fn()
@@ -70,14 +74,19 @@ describe('POST /api/transactions/[id]/book', () => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
requireWriteMock.mockResolvedValue({ ok: true })
// No booking-duplicate by default; guard tests override per-case.
mockDetectDup.mockResolvedValue(null)
mockAppendProcessingHistory.mockResolvedValue('evt-1')
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
@@ -90,6 +99,24 @@ describe('POST /api/transactions/[id]/book', () => {
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 403 when the caller is a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(403)
expect(body).toEqual({ error: 'Forbidden' })
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 when missing required fields', async () => {
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
+159 -173
View File
@@ -1,201 +1,187 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { validateBody } from '@/lib/api/validate'
import { BookTransactionSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createLogger } from '@/lib/logger'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import type { Transaction } from '@/types'
ensureInitialized()
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'transaction.book',
async (request, { supabase, user, companyId, log }, { params }) => {
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
const validation = await validateBody(request, BookTransactionSchema)
if (!validation.success) return validation.response
const { fiscal_period_id, entry_date, description, lines, force, expected_duplicate_transaction_id, expected_duplicate_journal_entry_id } = validation.data
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch transaction (validates ownership)
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
if (fetchError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
const companyId = await requireCompanyId(supabase, user.id)
// Reject if already booked
if (transaction.journal_entry_id) {
return NextResponse.json(
{ error: 'Transaction already has a journal entry' },
{ status: 409 }
)
}
const validation = await validateBody(request, BookTransactionSchema)
if (!validation.success) return validation.response
const { fiscal_period_id, entry_date, description, lines, force, expected_duplicate_transaction_id, expected_duplicate_journal_entry_id } = validation.data
// Fetch transaction (validates ownership)
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// Reject if already booked
if (transaction.journal_entry_id) {
return NextResponse.json(
{ error: 'Transaction already has a journal entry' },
{ status: 409 }
)
}
// Booking-time duplicate guard: if another transaction with the same
// date+amount+account is already booked, booking this one would double-count
// one real event (two verifikationer: felaktig bokföring per BFL). Warn; the
// user confirms with force=true bound to the reviewed sibling. Mirrors the
// match-invoice soft-duplicate guard.
const dupLog = createLogger('transactions.book', { companyId, userId: user.id })
try {
const candidate = await detectBookingDuplicate(supabase, companyId, {
id,
date: transaction.date,
amount: transaction.amount,
cash_account_id: transaction.cash_account_id ?? null,
})
if (!force) {
if (candidate) {
return errorResponseFromCode('TRANSACTION_BOOK_POSSIBLE_DUPLICATE', dupLog, {
details: { candidate },
// Booking-time duplicate guard: if another transaction with the same
// date+amount+account is already booked, booking this one would double-count
// one real event (two verifikationer: felaktig bokföring per BFL). Warn; the
// user confirms with force=true bound to the reviewed sibling. Mirrors the
// match-invoice soft-duplicate guard.
const dupLog = log
try {
const candidate = await detectBookingDuplicate(supabase, companyId, {
id,
date: transaction.date,
amount: transaction.amount,
cash_account_id: transaction.cash_account_id ?? null,
})
if (!force) {
if (candidate) {
return errorResponseFromCode('TRANSACTION_BOOK_POSSIBLE_DUPLICATE', dupLog, {
details: { candidate },
})
}
} else if (
// force=true is bound to the reviewed candidate. A sibling-transaction
// candidate carries a transaction_id; a ledger-only voucher candidate does
// not, so both are bound by journal_entry_id. Either echoed id confirms.
// Re-detect and refuse the bypass unless it still matches, so a guessed id
// can't wave the guard.
!candidate ||
!(
(candidate.journal_entry_id && candidate.journal_entry_id === expected_duplicate_journal_entry_id) ||
(candidate.transaction_id && candidate.transaction_id === expected_duplicate_transaction_id)
)
) {
return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', dupLog, {
details: {
expected_duplicate_transaction_id: expected_duplicate_transaction_id ?? null,
expected_duplicate_journal_entry_id: expected_duplicate_journal_entry_id ?? null,
detected_transaction_id: candidate?.transaction_id ?? null,
detected_journal_entry_id: candidate?.journal_entry_id ?? null,
},
})
} else {
dupLog.warn('booking-time duplicate guard bypassed', {
reason: 'force=true',
transactionId: id,
dismissedTransactionId: candidate.transaction_id,
})
// Persist the dismissal to behandlingshistorik (BFNAR 2013:2 kap 8): the
// decision to book over a DETECTED possible double-booking is a
// bookkeeping act that must leave a durable, queryable record; a warn in
// the application log is ephemeral and does not satisfy the requirement.
// Best-effort: a logging failure must never block a legitimate booking.
try {
await appendProcessingHistory({
companyId,
correlationId: id,
aggregateType: 'BankTransaction',
aggregateId: id,
eventType: 'BankTransactionDuplicateDismissed',
payload: {
transaction_id: id,
dismissed_transaction_id: candidate.transaction_id,
dismissed_journal_entry_id: candidate.journal_entry_id,
amount_ore: Math.round(candidate.amount * 100),
entry_date: candidate.entry_date,
},
actor: { type: 'user', id: user.id },
occurredAt: new Date(),
})
} catch (logErr) {
dupLog.error('failed to append duplicate-dismissal behandlingshistorik', logErr as Error)
}
}
} catch (err) {
// Detection is fail-open for the non-force path; force requires a confirmed
// candidate, so a detection failure under force is rejected as a mismatch.
if (force) {
return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', dupLog, {
details: { detection_failed: true },
})
}
} else if (
// force=true is bound to the reviewed candidate. A sibling-transaction
// candidate carries a transaction_id; a ledger-only voucher candidate does
// not, so both are bound by journal_entry_id. Either echoed id confirms.
// Re-detect and refuse the bypass unless it still matches, so a guessed id
// can't wave the guard.
!candidate ||
!(
(candidate.journal_entry_id && candidate.journal_entry_id === expected_duplicate_journal_entry_id) ||
(candidate.transaction_id && candidate.transaction_id === expected_duplicate_transaction_id)
dupLog.warn('booking-time duplicate detection failed (continuing)', err as Error)
}
// Create journal entry via the engine
let journalEntry
try {
journalEntry = await createJournalEntry(supabase, companyId, user.id, {
fiscal_period_id,
entry_date,
description,
source_type: 'bank_transaction',
source_id: id,
lines,
})
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create journal entry' },
{ status: 400 }
)
) {
return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', dupLog, {
details: {
expected_duplicate_transaction_id: expected_duplicate_transaction_id ?? null,
expected_duplicate_journal_entry_id: expected_duplicate_journal_entry_id ?? null,
detected_transaction_id: candidate?.transaction_id ?? null,
detected_journal_entry_id: candidate?.journal_entry_id ?? null,
}
// Link transaction to the journal entry
const { error: updateError } = await supabase
.from('transactions')
.update({
journal_entry_id: journalEntry.id,
is_business: true,
category: 'uncategorized',
})
.eq('id', id)
if (updateError) {
return NextResponse.json(
{ error: 'Failed to update transaction' },
{ status: 500 }
)
}
// Emit event (non-blocking)
try {
await eventBus.emit({
type: 'transaction.categorized',
payload: {
transaction: transaction as Transaction,
account: lines[0]?.account_number || '',
taxCode: '',
userId: user.id,
companyId,
},
})
} else {
dupLog.warn('booking-time duplicate guard bypassed', {
reason: 'force=true',
transactionId: id,
dismissedTransactionId: candidate.transaction_id,
})
// Persist the dismissal to behandlingshistorik (BFNAR 2013:2 kap 8): the
// decision to book over a DETECTED possible double-booking is a
// bookkeeping act that must leave a durable, queryable record; a warn in
// the application log is ephemeral and does not satisfy the requirement.
// Best-effort: a logging failure must never block a legitimate booking.
try {
await appendProcessingHistory({
companyId,
correlationId: id,
aggregateType: 'BankTransaction',
aggregateId: id,
eventType: 'BankTransactionDuplicateDismissed',
payload: {
transaction_id: id,
dismissed_transaction_id: candidate.transaction_id,
dismissed_journal_entry_id: candidate.journal_entry_id,
amount_ore: Math.round(candidate.amount * 100),
entry_date: candidate.entry_date,
},
actor: { type: 'user', id: user.id },
occurredAt: new Date(),
})
} catch (logErr) {
dupLog.error('failed to append duplicate-dismissal behandlingshistorik', logErr as Error)
}
} catch {
// Non-critical
}
} catch (err) {
// Detection is fail-open for the non-force path; force requires a confirmed
// candidate, so a detection failure under force is rejected as a mismatch.
if (force) {
return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', dupLog, {
details: { detection_failed: true },
})
}
dupLog.warn('booking-time duplicate detection failed (continuing)', err as Error)
}
// Create journal entry via the engine
let journalEntry
try {
journalEntry = await createJournalEntry(supabase, companyId, user.id, {
fiscal_period_id,
entry_date,
description,
source_type: 'bank_transaction',
source_id: id,
lines,
})
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create journal entry' },
{ status: 400 }
)
}
// Link transaction to the journal entry
const { error: updateError } = await supabase
.from('transactions')
.update({
return NextResponse.json({
data: journalEntry,
journal_entry_id: journalEntry.id,
is_business: true,
category: 'uncategorized',
success: true,
})
.eq('id', id)
if (updateError) {
return NextResponse.json(
{ error: 'Failed to update transaction' },
{ status: 500 }
)
}
// Emit event (non-blocking)
try {
await eventBus.emit({
type: 'transaction.categorized',
payload: {
transaction: transaction as Transaction,
account: lines[0]?.account_number || '',
taxCode: '',
userId: user.id,
companyId,
},
})
} catch {
// Non-critical
}
return NextResponse.json({
data: journalEntry,
journal_entry_id: journalEntry.id,
success: true,
})
}
},
{ requireWrite: true },
)
@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
import { POST, DELETE } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
describe('POST /api/transactions/[id]/ignore', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 403 when the caller is a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(403)
expect(body).toEqual({ error: 'Forbidden' })
// No DB read/write happens when the role gate rejects.
expect(mockSupabase.from).not.toHaveBeenCalled()
})
it('returns 404 when transaction not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const request = createMockRequest('/api/transactions/tx-999/ignore', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-999' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(404)
expect(body).toEqual({ error: 'Transaction not found' })
})
it('returns 409 when the transaction is already booked', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1', is_ignored: false }, error: null })
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('redan bokförd')
})
it('is idempotent when the transaction is already ignored', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: true }, error: null })
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(body).toEqual({ success: true, already_ignored: true })
})
it('marks the transaction ignored (happy path)', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: false }, error: null }) // fetch
enqueue({ data: null, error: null }) // update
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(body).toEqual({ success: true })
})
it('returns 500 when the update fails', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: false }, error: null }) // fetch
enqueue({ data: null, error: { message: 'db down' } }) // update fails
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(500)
expect(body).toEqual({ error: 'db down' })
})
})
describe('DELETE /api/transactions/[id]/ignore', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 403 when the caller is a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(403)
expect(body).toEqual({ error: 'Forbidden' })
})
it('clears the ignore flag (happy path)', async () => {
enqueue({ data: null, error: null }) // update
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(body).toEqual({ success: true })
})
it('returns 500 when the update fails', async () => {
enqueue({ data: null, error: { message: 'db down' } }) // update fails
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(500)
expect(body).toEqual({ error: 'db down' })
})
})
+51 -73
View File
@@ -1,7 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
/**
* POST /api/transactions/[id]/ignore
@@ -15,57 +13,47 @@ import { requireWritePermission } from '@/lib/auth/require-write'
* Refuses when the transaction is already booked; once a verifikation exists,
* the proper way to revisit it is /uncategorize (storno).
*/
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'transaction.ignore',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('id, journal_entry_id, is_ignored')
.eq('id', id)
.eq('company_id', companyId)
.single()
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
if (fetchError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
const companyId = await requireCompanyId(supabase, user.id)
if (transaction.journal_entry_id) {
return NextResponse.json(
{ error: 'Transaktionen är redan bokförd: använd Avmatcha eller backa verifikationen för att ändra status.' },
{ status: 409 }
)
}
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('id, journal_entry_id, is_ignored')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (transaction.is_ignored) {
return NextResponse.json({ success: true, already_ignored: true })
}
if (fetchError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
const { error: updateError } = await supabase
.from('transactions')
.update({ is_ignored: true })
.eq('id', id)
.eq('company_id', companyId)
if (transaction.journal_entry_id) {
return NextResponse.json(
{ error: 'Transaktionen är redan bokförd: använd Avmatcha eller backa verifikationen för att ändra status.' },
{ status: 409 }
)
}
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
if (transaction.is_ignored) {
return NextResponse.json({ success: true, already_ignored: true })
}
const { error: updateError } = await supabase
.from('transactions')
.update({ is_ignored: true })
.eq('id', id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)
/**
* DELETE /api/transactions/[id]/ignore
@@ -74,32 +62,22 @@ export async function POST(
* no further side effects: we never created a verifikation, so there's
* nothing to storno.
*/
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'transaction.unignore',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { error: updateError } = await supabase
.from('transactions')
.update({ is_ignored: false })
.eq('id', id)
.eq('company_id', companyId)
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { error: updateError } = await supabase
.from('transactions')
.update({ is_ignored: false })
.eq('id', id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
@@ -8,8 +9,10 @@ import {
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/init', () => ({
@@ -21,8 +24,9 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
const mockReverseEntry = vi.fn()
@@ -39,11 +43,16 @@ describe('POST /api/transactions/[id]/uncategorize', () => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
@@ -53,6 +62,21 @@ describe('POST /api/transactions/[id]/uncategorize', () => {
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 403 when the caller is a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(403)
expect(body).toEqual({ error: 'Forbidden' })
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('returns 404 when transaction not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
+57 -69
View File
@@ -1,86 +1,74 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
ensureInitialized()
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'transaction.uncategorize',
async (_request, { supabase, user, companyId }, { params }) => {
const { id } = await params
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
// Fetch transaction
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id, journal_entry_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
const companyId = await requireCompanyId(supabase, user.id)
if (txError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
const { id } = await params
if (!transaction.journal_entry_id) {
return NextResponse.json({ error: 'Transaction has no journal entry' }, { status: 400 })
}
// Fetch transaction
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id, journal_entry_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
// Verify journal entry is posted
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.select('id, status')
.eq('id', transaction.journal_entry_id)
.eq('company_id', companyId)
.single()
if (txError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
if (entryError || !entry) {
return NextResponse.json({ error: 'Journal entry not found' }, { status: 400 })
}
if (!transaction.journal_entry_id) {
return NextResponse.json({ error: 'Transaction has no journal entry' }, { status: 400 })
}
if (entry.status !== 'posted') {
return NextResponse.json({ error: 'Journal entry is not posted' }, { status: 400 })
}
// Verify journal entry is posted
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.select('id, status')
.eq('id', transaction.journal_entry_id)
.eq('company_id', companyId)
.single()
// Storno reversal (legally compliant: never deletes)
try {
await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id)
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
const message = err instanceof Error ? err.message : 'Reversal failed'
return NextResponse.json({ error: message }, { status: 500 })
}
if (entryError || !entry) {
return NextResponse.json({ error: 'Journal entry not found' }, { status: 400 })
}
// Reset transaction categorization
const { error: updateError } = await supabase
.from('transactions')
.update({
is_business: null,
category: null,
journal_entry_id: null,
})
.eq('id', id)
.eq('company_id', companyId)
if (entry.status !== 'posted') {
return NextResponse.json({ error: 'Journal entry is not posted' }, { status: 400 })
}
if (updateError) {
return NextResponse.json({ error: 'Failed to reset transaction' }, { status: 500 })
}
// Storno reversal (legally compliant: never deletes)
try {
await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id)
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
const message = err instanceof Error ? err.message : 'Reversal failed'
return NextResponse.json({ error: message }, { status: 500 })
}
// Reset transaction categorization
const { error: updateError } = await supabase
.from('transactions')
.update({
is_business: null,
category: null,
journal_entry_id: null,
})
.eq('id', id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: 'Failed to reset transaction' }, { status: 500 })
}
return NextResponse.json({ success: true })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)
@@ -1,9 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { parseJsonResponse, createQueuedMockSupabase, makeTransaction } from '@/tests/helpers'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse, createQueuedMockSupabase, makeTransaction } from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
const mockGetBestInvoiceMatch = vi.fn()
@@ -13,33 +16,60 @@ vi.mock('@/lib/invoices/invoice-matching', () => ({
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
import { POST } from '../route'
function makeReq() {
return createMockRequest('/api/transactions/batch-match-invoices', { method: 'POST' })
}
const routeParams = { params: Promise.resolve({}) }
describe('POST /api/transactions/batch-match-invoices', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST()
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 403 when the caller is a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(403)
expect(body).toEqual({ error: 'Forbidden' })
expect(mockGetBestInvoiceMatch).not.toHaveBeenCalled()
})
it('calls the invoice matcher with companyId (not user.id) and records the match', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 12500 })
enqueue({ data: [tx], error: null }) // fetch uncategorized income transactions
@@ -47,7 +77,7 @@ describe('POST /api/transactions/batch-match-invoices', () => {
mockGetBestInvoiceMatch.mockResolvedValue({ invoice: { id: 'inv-1' }, confidence: 0.9 })
const response = await POST()
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse<{ processed: number; matched: number }>(response)
expect(status).toBe(200)
@@ -75,7 +105,7 @@ describe('POST /api/transactions/batch-match-invoices', () => {
mockGetBestInvoiceMatch.mockResolvedValue(null)
const response = await POST()
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse<{ processed: number; matched: number }>(response)
expect(status).toBe(200)
@@ -85,7 +115,7 @@ describe('POST /api/transactions/batch-match-invoices', () => {
it('returns 500 when the transaction fetch fails', async () => {
enqueue({ data: null, error: { message: 'boom' } })
const response = await POST()
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
@@ -1,72 +1,61 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { Transaction } from '@/types'
/**
* POST /api/transactions/batch-match-invoices
* Run invoice matching for all uncategorized income transactions without potential_invoice_id
*/
export async function POST() {
const supabase = await createClient()
export const POST = withRouteContext(
'transaction.batch_match_invoices',
async (_request, { supabase, companyId }) => {
// Fetch uncategorized income transactions without a potential match
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.is('is_business', null)
.gt('amount', 0)
.is('potential_invoice_id', null)
.is('invoice_id', null)
.order('date', { ascending: false })
.limit(50)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Fetch uncategorized income transactions without a potential match
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.is('is_business', null)
.gt('amount', 0)
.is('potential_invoice_id', null)
.is('invoice_id', null)
.order('date', { ascending: false })
.limit(50)
if (txError || !transactions) {
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
}
let matched = 0
const matchedInvoiceIds = new Set<string>()
for (const tx of transactions) {
try {
const bestMatch = await getBestInvoiceMatch(
supabase,
companyId,
tx as Transaction,
0.50
)
if (bestMatch && !matchedInvoiceIds.has(bestMatch.invoice.id)) {
await supabase
.from('transactions')
.update({ potential_invoice_id: bestMatch.invoice.id })
.eq('id', tx.id)
matchedInvoiceIds.add(bestMatch.invoice.id)
matched++
}
} catch {
// Continue with other transactions
if (txError || !transactions) {
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
}
}
return NextResponse.json({
processed: transactions.length,
matched,
})
}
let matched = 0
const matchedInvoiceIds = new Set<string>()
for (const tx of transactions) {
try {
const bestMatch = await getBestInvoiceMatch(
supabase,
companyId,
tx as Transaction,
0.50
)
if (bestMatch && !matchedInvoiceIds.has(bestMatch.invoice.id)) {
await supabase
.from('transactions')
.update({ potential_invoice_id: bestMatch.invoice.id })
.eq('id', tx.id)
matchedInvoiceIds.add(bestMatch.invoice.id)
matched++
}
} catch {
// Continue with other transactions
}
}
return NextResponse.json({
processed: transactions.length,
matched,
})
},
{ requireWrite: true },
)
@@ -1,9 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
@@ -11,8 +14,9 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({
@@ -26,11 +30,14 @@ const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
const VALID_UUID = '11111111-1111-4111-8111-111111111111'
const routeParams = { params: Promise.resolve({}) }
function makeReq(body: unknown) {
return new Request('http://localhost/api/transactions/create-from-document', {
method: 'POST',
@@ -56,28 +63,43 @@ function validBody(overrides: Partial<{
describe('POST /api/transactions/create-from-document', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const res = await POST(makeReq(validBody()))
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await POST(makeReq(validBody()), routeParams)
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 403 when the caller is a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const res = await POST(makeReq(validBody()), routeParams)
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(403)
expect(body).toEqual({ error: 'Forbidden' })
})
it('returns 400 when the body is invalid', async () => {
const res = await POST(makeReq({ inbox_item_id: 'not-a-uuid', amount: 0 }))
const res = await POST(makeReq({ inbox_item_id: 'not-a-uuid', amount: 0 }), routeParams)
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 400 when amount is zero (schema refine)', async () => {
const res = await POST(makeReq(validBody({ amount: 0 })))
const res = await POST(makeReq(validBody({ amount: 0 })), routeParams)
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 404 when the inbox item is not in the user company', async () => {
enqueue({ data: null, error: null }) // inbox item lookup misses
const res = await POST(makeReq(validBody()))
const res = await POST(makeReq(validBody()), routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(404)
expect(body.error).toBe('Inbox item not found')
@@ -94,7 +116,7 @@ describe('POST /api/transactions/create-from-document', () => {
},
error: null,
})
const res = await POST(makeReq(validBody()))
const res = await POST(makeReq(validBody()), routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(409)
expect(body.error).toMatch(/redan kopplad/)
@@ -111,7 +133,7 @@ describe('POST /api/transactions/create-from-document', () => {
},
error: null,
})
const res = await POST(makeReq(validBody()))
const res = await POST(makeReq(validBody()), routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(409)
expect(body.error).toMatch(/redan bokförd/)
@@ -131,7 +153,7 @@ describe('POST /api/transactions/create-from-document', () => {
enqueue({ data: { id: 'new-tx-1' }, error: null }) // insert
enqueue({ data: [{ id: VALID_UUID }], error: null }) // inbox update: one row affected
const res = await POST(makeReq(validBody()))
const res = await POST(makeReq(validBody()), routeParams)
const { status, body } = await parseJsonResponse<{
data: { transaction_id: string; inbox_item_id: string; document_id: string }
}>(res)
@@ -159,7 +181,7 @@ describe('POST /api/transactions/create-from-document', () => {
enqueue({ data: [], error: null }) // inbox update affects zero rows: lost the race
enqueue({ data: null, error: null }) // rollback delete of the orphan
const res = await POST(makeReq(validBody()))
const res = await POST(makeReq(validBody()), routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(409)
expect(body.error).toMatch(/parallell begäran/)
@@ -182,7 +204,7 @@ describe('POST /api/transactions/create-from-document', () => {
enqueue({ data: { id: 'new-tx-3' }, error: null })
enqueue({ data: [{ id: VALID_UUID }], error: null })
const res = await POST(makeReq(validBody()))
const res = await POST(makeReq(validBody()), routeParams)
const { status } = await parseJsonResponse(res)
expect(status).toBe(200)
})
@@ -201,7 +223,7 @@ describe('POST /api/transactions/create-from-document', () => {
enqueue({ data: null, error: { message: 'db down' } }) // insert fails
// Silence the console.error
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const res = await POST(makeReq(validBody()))
const res = await POST(makeReq(validBody()), routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(500)
expect(body.error).toMatch(/Kunde inte skapa transaktion/)
@@ -222,7 +244,7 @@ describe('POST /api/transactions/create-from-document', () => {
enqueue({ data: { id: 'new-tx-2' }, error: null }) // insert ok
enqueue({ data: null, error: { message: 'rls' } }) // link update fails
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const res = await POST(makeReq(validBody()))
const res = await POST(makeReq(validBody()), routeParams)
const { status, body } = await parseJsonResponse<{
data: { transaction_id: string; inbox_link_failed?: boolean }
}>(res)
+101 -109
View File
@@ -1,10 +1,8 @@
import { createClient } from '@/lib/supabase/server'
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 { CreateTransactionFromDocumentSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -20,120 +18,114 @@ ensureInitialized()
* Use case: receipt in the inbox has no matching bank transaction
* (cash purchase, personal-card expense, missed sync).
*/
export async function POST(request: Request) {
const supabase = await createClient()
export const POST = withRouteContext(
'transaction.create_from_document',
async (request, { supabase, user, companyId }) => {
const validation = await validateBody(request, CreateTransactionFromDocumentSchema)
if (!validation.success) return validation.response
const { inbox_item_id, amount, transaction_date, description } = validation.data
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: item, error: itemError } = await supabase
.from('invoice_inbox_items')
.select('id, document_id, matched_transaction_id, created_supplier_invoice_id, extracted_data')
.eq('id', inbox_item_id)
.eq('company_id', companyId)
.maybeSingle()
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
if (itemError || !item) {
return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
}
if (item.matched_transaction_id) {
return NextResponse.json(
{ error: 'Inkorgsposten är redan kopplad till en transaktion.' },
{ status: 409 },
)
}
if (item.created_supplier_invoice_id) {
return NextResponse.json(
{ error: 'Inkorgsposten är redan bokförd som leverantörsfaktura.' },
{ status: 409 },
)
}
const companyId = await requireCompanyId(supabase, user.id)
// Allowlist the currency: extracted_data.invoice.currency comes from the
// (deterministic, but still untrusted) PDF extractor, so an arbitrary
// string like "XYZ" or '"SEK\'"' could otherwise be persisted directly to
// the transactions table and break later formatCurrency / journal-entry
// bookings (BFL 5 kap 6 §). Coerce anything outside the supported set
// to SEK; the user can change it manually on the transaction.
const ALLOWED_CURRENCIES = new Set(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'])
const extractedCurrency = (
item.extracted_data as { invoice?: { currency?: string } } | null
)?.invoice?.currency
const currency =
extractedCurrency && ALLOWED_CURRENCIES.has(extractedCurrency)
? extractedCurrency
: 'SEK'
const validation = await validateBody(request, CreateTransactionFromDocumentSchema)
if (!validation.success) return validation.response
const { inbox_item_id, amount, transaction_date, description } = validation.data
const { data: newTx, error: insertError } = await supabase
.from('transactions')
.insert({
company_id: companyId,
user_id: user.id,
date: transaction_date,
description,
amount,
currency,
category: 'uncategorized',
is_business: null,
import_source: 'manual',
document_id: item.document_id,
})
.select('id')
.single()
const { data: item, error: itemError } = await supabase
.from('invoice_inbox_items')
.select('id, document_id, matched_transaction_id, created_supplier_invoice_id, extracted_data')
.eq('id', inbox_item_id)
.eq('company_id', companyId)
.maybeSingle()
if (insertError || !newTx) {
console.error('[create-from-document] Failed to insert transaction:', insertError)
return NextResponse.json({ error: 'Kunde inte skapa transaktion.' }, { status: 500 })
}
if (itemError || !item) {
return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
}
if (item.matched_transaction_id) {
return NextResponse.json(
{ error: 'Inkorgsposten är redan kopplad till en transaktion.' },
{ status: 409 },
)
}
if (item.created_supplier_invoice_id) {
return NextResponse.json(
{ error: 'Inkorgsposten är redan bokförd som leverantörsfaktura.' },
{ status: 409 },
)
}
// Concurrency guard: the .is('matched_transaction_id', null) predicate +
// the rows-affected check turn this into an optimistic-lock release. If
// two requests with the same inbox_item_id race past the earlier
// matched_transaction_id check, only the first UPDATE will match a row
// here. The loser's transaction insert is then an orphan we proactively
// delete so the user doesn't get a duplicate uncategorized row.
const { data: linked, error: linkError } = await supabase
.from('invoice_inbox_items')
.update({ matched_transaction_id: newTx.id })
.eq('id', inbox_item_id)
.eq('company_id', companyId)
.is('matched_transaction_id', null)
.select('id')
// Allowlist the currency: extracted_data.invoice.currency comes from the
// (deterministic, but still untrusted) PDF extractor, so an arbitrary
// string like "XYZ" or '"SEK\'"' could otherwise be persisted directly to
// the transactions table and break later formatCurrency / journal-entry
// bookings (BFL 5 kap 6 §). Coerce anything outside the supported set
// to SEK; the user can change it manually on the transaction.
const ALLOWED_CURRENCIES = new Set(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'])
const extractedCurrency = (
item.extracted_data as { invoice?: { currency?: string } } | null
)?.invoice?.currency
const currency =
extractedCurrency && ALLOWED_CURRENCIES.has(extractedCurrency)
? extractedCurrency
: 'SEK'
if (linkError) {
console.error('[create-from-document] Failed to link inbox item:', linkError)
// Transaction was created; surface a 200 with a warning so the user can
// still find it under Transaktioner: the inbox-link orphan is recoverable.
return NextResponse.json({
data: { transaction_id: newTx.id, inbox_link_failed: true },
})
}
const { data: newTx, error: insertError } = await supabase
.from('transactions')
.insert({
company_id: companyId,
user_id: user.id,
date: transaction_date,
description,
amount,
currency,
category: 'uncategorized',
is_business: null,
import_source: 'manual',
document_id: item.document_id,
})
.select('id')
.single()
if (!linked || linked.length === 0) {
// Lost a race: another concurrent request linked the inbox item first.
// Roll back our newly-created transaction (only safe because we own it
// and it has no journal_entry_id yet) and return 409 so the client can
// refetch and reuse the winning transaction instead of creating a dupe.
// Re-assert company_id on the delete (defence in depth: newTx.id is a
// fresh UUID from a company-scoped insert above, but scoping the rollback
// makes the invariant explicit).
await supabase.from('transactions').delete().eq('id', newTx.id).eq('company_id', companyId)
return NextResponse.json(
{ error: 'Inkorgsposten kopplades av en parallell begäran. Försök igen.' },
{ status: 409 },
)
}
if (insertError || !newTx) {
console.error('[create-from-document] Failed to insert transaction:', insertError)
return NextResponse.json({ error: 'Kunde inte skapa transaktion.' }, { status: 500 })
}
// Concurrency guard: the .is('matched_transaction_id', null) predicate +
// the rows-affected check turn this into an optimistic-lock release. If
// two requests with the same inbox_item_id race past the earlier
// matched_transaction_id check, only the first UPDATE will match a row
// here. The loser's transaction insert is then an orphan we proactively
// delete so the user doesn't get a duplicate uncategorized row.
const { data: linked, error: linkError } = await supabase
.from('invoice_inbox_items')
.update({ matched_transaction_id: newTx.id })
.eq('id', inbox_item_id)
.eq('company_id', companyId)
.is('matched_transaction_id', null)
.select('id')
if (linkError) {
console.error('[create-from-document] Failed to link inbox item:', linkError)
// Transaction was created; surface a 200 with a warning so the user can
// still find it under Transaktioner: the inbox-link orphan is recoverable.
return NextResponse.json({
data: { transaction_id: newTx.id, inbox_link_failed: true },
data: { transaction_id: newTx.id, inbox_item_id, document_id: item.document_id },
})
}
if (!linked || linked.length === 0) {
// Lost a race: another concurrent request linked the inbox item first.
// Roll back our newly-created transaction (only safe because we own it
// and it has no journal_entry_id yet) and return 409 so the client can
// refetch and reuse the winning transaction instead of creating a dupe.
// Re-assert company_id on the delete (defence in depth: newTx.id is a
// fresh UUID from a company-scoped insert above, but scoping the rollback
// makes the invariant explicit).
await supabase.from('transactions').delete().eq('id', newTx.id).eq('company_id', companyId)
return NextResponse.json(
{ error: 'Inkorgsposten kopplades av en parallell begäran. Försök igen.' },
{ status: 409 },
)
}
return NextResponse.json({
data: { transaction_id: newTx.id, inbox_item_id, document_id: item.document_id },
})
}
},
{ requireWrite: true },
)
+93 -101
View File
@@ -1,115 +1,107 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getSuggestedCategories, getSuggestedTemplates, buildMerchantHistory, merchantHistoryFor, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction, EntityType } from '@/types'
/**
* POST /api/transactions/suggest-categories
* Batch endpoint for getting category suggestions for multiple transactions
*/
export async function POST(request: Request) {
const supabase = await createClient()
export const POST = withRouteContext(
'transaction.suggest_categories',
async (request, { supabase, companyId }) => {
const { transaction_ids } = await request.json()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { transaction_ids } = await request.json()
if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) {
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
}
// Limit batch size
const ids = transaction_ids.slice(0, 50)
// Fetch transactions
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.in('id', ids)
if (txError || !transactions) {
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
}
// Fetch user's mapping rules (once, for all transactions)
const { data: mappingRules } = await supabase
.from('mapping_rules')
.select('*')
.or(`company_id.eq.${companyId},company_id.is.null`)
.eq('is_active', true)
.order('priority', { ascending: false })
// Counterparty-keyed history from past categorized transactions: the
// suggestion engine only surfaces history tied to the SAME merchant
// (global frequency padding produced identical low-confidence spreads).
const { data: historicalTxns } = await supabase
.from('transactions')
.select('category, merchant_name')
.eq('company_id', companyId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.neq('category', 'private')
.limit(200)
const merchantHistory = buildMerchantHistory(historicalTxns ?? [])
// Fetch entity type for template matching
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.single()
const entityType = (settings?.entity_type as EntityType) || undefined
// Batch counterparty template matching (1 DB query, in-memory matching)
const counterpartyMatches = await findCounterpartyTemplatesBatch(supabase, companyId, transactions as Transaction[])
// Generate initial suggestions for each transaction
const suggestions: Record<string, SuggestedCategory[]> = {}
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
for (const tx of transactions) {
suggestions[tx.id] = getSuggestedCategories(
tx as Transaction,
mappingRules || [],
merchantHistoryFor(merchantHistory, (tx as Transaction).merchant_name)
)
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
}
// Inject counterparty template matches as top suggestions
for (const tx of transactions) {
const cpMatch = counterpartyMatches.get(tx.id)
if (!cpMatch) continue
const tmpl = cpMatch.template
const cpSuggestion: SuggestedTemplate = {
template_id: toCounterpartyTemplateId(tmpl.id),
name_sv: formatCounterpartyName(tmpl.counterparty_name),
name_en: formatCounterpartyName(tmpl.counterparty_name),
group: 'counterparty',
debit_account: tmpl.debit_account,
credit_account: tmpl.credit_account,
confidence: cpMatch.confidence,
description_sv: `${tmpl.occurrence_count} tidigare bokföringar`,
risk_level: 'NONE',
requires_review: false,
line_pattern: tmpl.line_pattern ?? null,
if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) {
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
}
const existing = template_suggestions[tx.id] || []
template_suggestions[tx.id] = [cpSuggestion, ...existing]
}
// Limit batch size
const ids = transaction_ids.slice(0, 50)
// Fetch transactions
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.in('id', ids)
if (txError || !transactions) {
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
}
// Fetch user's mapping rules (once, for all transactions)
const { data: mappingRules } = await supabase
.from('mapping_rules')
.select('*')
.or(`company_id.eq.${companyId},company_id.is.null`)
.eq('is_active', true)
.order('priority', { ascending: false })
// Counterparty-keyed history from past categorized transactions: the
// suggestion engine only surfaces history tied to the SAME merchant
// (global frequency padding produced identical low-confidence spreads).
const { data: historicalTxns } = await supabase
.from('transactions')
.select('category, merchant_name')
.eq('company_id', companyId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.neq('category', 'private')
.limit(200)
const merchantHistory = buildMerchantHistory(historicalTxns ?? [])
// Fetch entity type for template matching
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.single()
const entityType = (settings?.entity_type as EntityType) || undefined
// Batch counterparty template matching (1 DB query, in-memory matching)
const counterpartyMatches = await findCounterpartyTemplatesBatch(supabase, companyId, transactions as Transaction[])
// Generate initial suggestions for each transaction
const suggestions: Record<string, SuggestedCategory[]> = {}
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
for (const tx of transactions) {
suggestions[tx.id] = getSuggestedCategories(
tx as Transaction,
mappingRules || [],
merchantHistoryFor(merchantHistory, (tx as Transaction).merchant_name)
)
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
}
// Inject counterparty template matches as top suggestions
for (const tx of transactions) {
const cpMatch = counterpartyMatches.get(tx.id)
if (!cpMatch) continue
const tmpl = cpMatch.template
const cpSuggestion: SuggestedTemplate = {
template_id: toCounterpartyTemplateId(tmpl.id),
name_sv: formatCounterpartyName(tmpl.counterparty_name),
name_en: formatCounterpartyName(tmpl.counterparty_name),
group: 'counterparty',
debit_account: tmpl.debit_account,
credit_account: tmpl.credit_account,
confidence: cpMatch.confidence,
description_sv: `${tmpl.occurrence_count} tidigare bokföringar`,
risk_level: 'NONE',
requires_review: false,
line_pattern: tmpl.line_pattern ?? null,
}
const existing = template_suggestions[tx.id] || []
template_suggestions[tx.id] = [cpSuggestion, ...existing]
}
return NextResponse.json({ suggestions, template_suggestions })
}
return NextResponse.json({ suggestions, template_suggestions })
},
)