* feat(arcim-migration): Briox provider with SIE-over-API import - Briox auth via account ID + application token (no app-level credentials); both tokens rotate on refresh and are persisted - New sie-fetcher pulls the general ledger as SIE through the provider API for Fortnox, Briox and Bjorn Lunden - Wizard stops on a failed SIE import and surfaces the real errors instead of proceeding to the misleading migrate-guard message - PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED; new PROVIDER_TOKEN_INVALID for rejected provider credentials Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices Defer revenue/costs per invoice line to 29xx/17xx interim accounts with automatic monthly dissolution (nightly cron + catch-up at registration), schedule cancellation on credit, year-end auto-detect exclusion for already-scheduled invoices, invoice-inbox service-period extraction for prefill, and an MCP tool to list schedules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing Generate the annual report as iXBRL from a generated taxonomy registry (K2 element lists, taxonomy:generate/check scripts + CI guard), expose it via the fiscal-period API, and add the bolagsverket extension for digital submission to eget utrymme with webhook-driven status tracking (submissions table + pg tests, lifecycle events, year-end wizard UI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(mcp): raise origin-guard test timeout to 20s The dynamic import pulls in the full server module; the parse alone flirts with the 5s default under full-suite parallel load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add new scripts and documentation for K2 AB taxonomy generation and validation - Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models. - Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle. - Included new documentation files: - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx` - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx` - `taxonomi-paket-2024-09-12_rev20250312.zip` * Add tests for bookkeeping accruals dissolution and supplier invoices - Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios. - Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions. - Introduce tests for the Arcim migration provider client, ensuring token handling and error classification. - Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings. - Add Zod schemas for Bolagsverket response payloads to ensure proper validation. - Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping. - Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly. - Introduce typed domain errors for accrual schedules to improve error handling in the service. - Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling. * fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments * fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated * feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id * feat(bokslut): enhance compliance and financial processing features with new submission details and security measures --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2148 lines
86 KiB
TypeScript
2148 lines
86 KiB
TypeScript
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||
import { NextResponse } from 'next/server'
|
||
import { createClient } from '@supabase/supabase-js'
|
||
import { z } from 'zod'
|
||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||
import { extractInvoiceFields, ExtractionSchema, emptyResult } from './lib/extract-invoice-fields'
|
||
import {
|
||
verifyInboundWebhook,
|
||
fetchReceivingEmail,
|
||
fetchInboundAttachment,
|
||
extractLocalPartForDomain,
|
||
isEmailReceivedEvent,
|
||
ResendSignatureError,
|
||
} from './lib/resend-inbound'
|
||
import {
|
||
rotateCompanyInbox,
|
||
getActiveInbox,
|
||
composeInboxAddress,
|
||
} from './lib/inbox-provisioning'
|
||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||
import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema } from '@/lib/api/schemas'
|
||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
|
||
import { simpleParser } from 'mailparser'
|
||
import { PDFDocument } from 'pdf-lib'
|
||
import path from 'node:path'
|
||
|
||
/**
|
||
* Defensive filename sanitisation for content arriving from .eml inner
|
||
* attachments and rejected-attachment metadata. The document-service already
|
||
* sanitises before storage paths are built (lib/core/documents/document-service.ts),
|
||
* so this is defense-in-depth: strip directory traversal sequences and exotic
|
||
* characters before they ever flow into DB columns or downstream consumers.
|
||
*/
|
||
function sanitiseFilename(raw: string | null | undefined, fallback: string): string {
|
||
const base = path.basename(String(raw ?? '').trim())
|
||
const cleaned = base.replace(/[^\w.-]/g, '_').slice(0, 200)
|
||
return cleaned || fallback
|
||
}
|
||
|
||
function sanitiseMime(raw: string | null | undefined): string {
|
||
const value = String(raw ?? '').trim().slice(0, 120)
|
||
return /^[\w./+-]+$/.test(value) ? value : 'application/octet-stream'
|
||
}
|
||
import type { InvoiceExtractionResult, InvoiceInboxItem, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||
|
||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||
const MAX_ATTACHMENTS_PER_EMAIL = 20
|
||
|
||
// AI extraction is tuned for single-page receipts/invoices. Documents above
|
||
// this page count tend to be sales reports, bank statements, or contracts —
|
||
// Bedrock churns for minutes and still extracts nothing useful (issue #553).
|
||
// Above the limit we skip extraction entirely; the document still lands in
|
||
// the inbox and can be attached to a transaction or converted manually.
|
||
const MAX_PAGES_FOR_AUTO_EXTRACT = 3
|
||
|
||
// Returns the page count for a PDF buffer, or null if the buffer isn't a
|
||
// parseable PDF. Errors fall through so callers can treat "unknown" the same
|
||
// as "small enough" — preserves today's behavior on malformed inputs.
|
||
async function countPdfPages(buffer: ArrayBuffer): Promise<number | null> {
|
||
try {
|
||
const pdf = await PDFDocument.load(buffer, { updateMetadata: false })
|
||
return pdf.getPageCount()
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
// Sandbox companies (24h anonymous demo accounts) skip the Bedrock extraction
|
||
// pipeline entirely. The document still uploads, the inbox row still lands,
|
||
// and the user can fill the fields in by hand — but no Claude tokens are
|
||
// spent on a throwaway account. See migration 20260311120000 for the column.
|
||
async function isSandboxCompany(
|
||
supabase: import('@supabase/supabase-js').SupabaseClient,
|
||
companyId: string,
|
||
): Promise<boolean> {
|
||
const { data, error } = await supabase
|
||
.from('company_settings')
|
||
.select('is_sandbox')
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
if (error || !data) return false
|
||
return data.is_sandbox === true
|
||
}
|
||
|
||
// Partial-update schema for the /items/:id/fields PATCH route. Only the
|
||
// scalar fields the UI exposes for inline editing — line items and
|
||
// vatBreakdown stay AI-managed for now and are preserved by the merge.
|
||
const NullableString = z.string().trim().max(500).nullable()
|
||
const NullableDate = z
|
||
.string()
|
||
.regex(
|
||
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
|
||
'Invalid date — expected YYYY-MM-DD'
|
||
)
|
||
// Catch impossible calendar dates like 2026-02-30 that pass the regex.
|
||
.refine((v) => !Number.isNaN(Date.parse(v)), 'Invalid calendar date')
|
||
.nullable()
|
||
const NullableNumber = z.number().nullable()
|
||
|
||
const UpdateExtractedDataSchema = z.object({
|
||
supplier: z
|
||
.object({
|
||
name: NullableString,
|
||
orgNumber: NullableString,
|
||
vatNumber: NullableString,
|
||
address: NullableString,
|
||
bankgiro: NullableString,
|
||
plusgiro: NullableString,
|
||
})
|
||
.partial()
|
||
.optional(),
|
||
invoice: z
|
||
.object({
|
||
invoiceNumber: NullableString,
|
||
invoiceDate: NullableDate,
|
||
dueDate: NullableDate,
|
||
paymentReference: NullableString,
|
||
// ISO 4217 — three uppercase letters. We accept the user's edit only
|
||
// if it looks like a real currency code; loose strings would otherwise
|
||
// flow into the supplier-invoice-creation step and produce a faktura
|
||
// with an invalid currency (cf. ML 17 kap 24§ p.9).
|
||
currency: z.string().regex(/^[A-Z]{3}$/, 'Currency must be a 3-letter ISO 4217 code'),
|
||
})
|
||
.partial()
|
||
.optional(),
|
||
totals: z
|
||
.object({
|
||
subtotal: NullableNumber,
|
||
vatAmount: NullableNumber,
|
||
total: NullableNumber,
|
||
})
|
||
.partial()
|
||
.optional(),
|
||
})
|
||
|
||
const UPLOAD_ALLOWED_MIME_TYPES = new Set([
|
||
'application/pdf',
|
||
'image/jpeg',
|
||
'image/png',
|
||
'image/heic',
|
||
'image/heif',
|
||
'image/webp',
|
||
])
|
||
|
||
interface EmailMeta {
|
||
from?: string | null
|
||
subject?: string | null
|
||
receivedAt?: string | null
|
||
messageId?: string | null
|
||
bodyText?: string | null
|
||
resendEmailId?: string | null
|
||
resendAttachmentId?: string | null
|
||
}
|
||
|
||
// ── Shared helper: upload + extract + create inbox item ──────
|
||
|
||
async function uploadAndExtract(
|
||
supabase: import('@supabase/supabase-js').SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
file: { name: string; buffer: ArrayBuffer; type: string },
|
||
source: 'upload' | 'email',
|
||
emailMeta?: EmailMeta,
|
||
// Pre-match the new inbox item to a bank transaction. Set when the caller
|
||
// already knows which transaction this receipt belongs to (e.g. the
|
||
// VerifyAndBookOverlay opened from a transaction row's paperclip or from
|
||
// a transaction-anchored chat). Skipped silently if missing.
|
||
matchedTransactionId?: string | null,
|
||
opts: { skipExtraction?: boolean } = {},
|
||
) {
|
||
const correlationId = crypto.randomUUID()
|
||
|
||
const doc = await uploadDocument(supabase, userId, companyId, {
|
||
name: file.name,
|
||
buffer: file.buffer,
|
||
type: file.type,
|
||
}, {
|
||
upload_source: source === 'email' ? 'email' : 'file_upload',
|
||
})
|
||
|
||
try {
|
||
await appendProcessingHistory({
|
||
companyId,
|
||
correlationId,
|
||
aggregateType: 'Document',
|
||
aggregateId: doc.id,
|
||
eventType: 'DocumentIngested',
|
||
payload: {
|
||
channel: source,
|
||
document_id: doc.id,
|
||
mime_type: file.type,
|
||
size_bytes: file.buffer.byteLength,
|
||
},
|
||
actor: source === 'email' ? { type: 'system', id: 'resend-inbound' } : { type: 'user', id: userId },
|
||
occurredAt: new Date(),
|
||
})
|
||
} catch (err) {
|
||
console.error('[invoice-inbox] Failed to append DocumentIngested:', err)
|
||
}
|
||
|
||
// Page-count gate (issue #553): PDFs above MAX_PAGES_FOR_AUTO_EXTRACT
|
||
// skip extraction. Bedrock would otherwise block the upload response for
|
||
// minutes on a 6-page sales report and return nothing useful. Images and
|
||
// non-PDFs are never gated (single-page by definition). countPdfPages
|
||
// returns null on malformed PDFs — we treat null as "not gated" and fall
|
||
// through to the existing extraction path so today's behavior is preserved.
|
||
const pageCount =
|
||
file.type === 'application/pdf' ? await countPdfPages(file.buffer) : null
|
||
const gatedByPageCount =
|
||
pageCount != null && pageCount > MAX_PAGES_FOR_AUTO_EXTRACT
|
||
const sandbox = await isSandboxCompany(supabase, companyId)
|
||
// Skip-reason priority: sandbox > page-count > client opt-out. Sandbox
|
||
// wins because it's a hard cost-control rule, not a heuristic.
|
||
const skipReason: 'too_many_pages' | 'client_opt_out' | 'sandbox' | null = sandbox
|
||
? 'sandbox'
|
||
: gatedByPageCount
|
||
? 'too_many_pages'
|
||
: opts.skipExtraction
|
||
? 'client_opt_out'
|
||
: null
|
||
const skipExtraction = skipReason !== null
|
||
|
||
// Bring-your-own-extraction: skip the Bedrock call entirely and seed an
|
||
// empty extraction skeleton. The caller is expected to PUT the parsed
|
||
// fields via /items/:id/extracted-data before converting to a supplier
|
||
// invoice. extracted_data is never null in the DB; an empty skeleton
|
||
// keeps downstream readers (UI, MCP) happy.
|
||
const { data: extracted, rawText } = skipExtraction
|
||
? { data: emptyResult(), rawText: null }
|
||
: await extractInvoiceFields({
|
||
buffer: Buffer.from(file.buffer),
|
||
mimeType: file.type,
|
||
fileName: file.name,
|
||
})
|
||
|
||
// Supplier match by org-nr, then case-insensitive name (no AI fuzz).
|
||
let matchedSupplierId: string | null = null
|
||
if (extracted.supplier.orgNumber) {
|
||
const { data: s } = await supabase
|
||
.from('suppliers')
|
||
.select('id')
|
||
.eq('company_id', companyId)
|
||
.eq('org_number', extracted.supplier.orgNumber)
|
||
.limit(1)
|
||
.maybeSingle()
|
||
if (s) matchedSupplierId = s.id
|
||
}
|
||
if (!matchedSupplierId && extracted.supplier.name) {
|
||
const { data: s } = await supabase
|
||
.from('suppliers')
|
||
.select('id')
|
||
.eq('company_id', companyId)
|
||
.ilike('name', extracted.supplier.name)
|
||
.limit(1)
|
||
.maybeSingle()
|
||
if (s) matchedSupplierId = s.id
|
||
}
|
||
|
||
const { data: inbox, error: inboxError } = await supabase
|
||
.from('invoice_inbox_items')
|
||
.insert({
|
||
company_id: companyId,
|
||
user_id: userId,
|
||
status: 'received',
|
||
source,
|
||
document_id: doc.id,
|
||
extracted_data: extracted as unknown as Record<string, unknown>,
|
||
extraction_skipped: skipExtraction,
|
||
matched_supplier_id: matchedSupplierId,
|
||
email_from: emailMeta?.from || null,
|
||
email_subject: emailMeta?.subject || null,
|
||
email_received_at: emailMeta?.receivedAt || null,
|
||
email_body_text: emailMeta?.bodyText || null,
|
||
resend_email_id: emailMeta?.resendEmailId || null,
|
||
resend_attachment_id: emailMeta?.resendAttachmentId || null,
|
||
raw_email_payload: emailMeta?.messageId
|
||
? { messageId: emailMeta.messageId, filename: file.name }
|
||
: null,
|
||
correlation_id: correlationId,
|
||
matched_transaction_id: matchedTransactionId ?? null,
|
||
})
|
||
.select('*')
|
||
.single()
|
||
|
||
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
|
||
|
||
try {
|
||
await appendProcessingHistory({
|
||
companyId,
|
||
correlationId,
|
||
aggregateType: 'Document',
|
||
aggregateId: doc.id,
|
||
eventType: 'DocumentExtractionAttempted',
|
||
payload: {
|
||
document_id: doc.id,
|
||
inbox_item_id: inbox.id,
|
||
succeeded: rawText != null && rawText.length > 0,
|
||
extracted_total: extracted.totals.total,
|
||
has_org_number: extracted.supplier.orgNumber != null,
|
||
has_ocr: extracted.invoice.paymentReference != null,
|
||
skipped: skipExtraction,
|
||
skip_reason: skipReason,
|
||
page_count: pageCount,
|
||
},
|
||
actor: { type: 'system', id: 'invoice-inbox-extract' },
|
||
occurredAt: new Date(),
|
||
})
|
||
} catch (err) {
|
||
console.error('[invoice-inbox] Failed to append DocumentExtractionAttempted:', err)
|
||
}
|
||
|
||
return {
|
||
document_id: doc.id,
|
||
inbox_item_id: inbox.id,
|
||
status: inbox.status,
|
||
extracted_data: extracted,
|
||
matched_supplier_id: inbox.matched_supplier_id,
|
||
matched_transaction_id: inbox.matched_transaction_id,
|
||
extraction_skipped: skipExtraction,
|
||
skip_reason: skipReason,
|
||
page_count: pageCount,
|
||
}
|
||
}
|
||
|
||
// ── Admin/owner check helper ──────────────────────────────────
|
||
|
||
async function isCompanyAdmin(
|
||
supabase: import('@supabase/supabase-js').SupabaseClient,
|
||
userId: string,
|
||
companyId: string
|
||
): Promise<boolean> {
|
||
const { data } = await supabase
|
||
.from('company_members')
|
||
.select('role')
|
||
.eq('company_id', companyId)
|
||
.eq('user_id', userId)
|
||
.maybeSingle()
|
||
return !!data && ['owner', 'admin'].includes(data.role)
|
||
}
|
||
|
||
// ── Extension definition ─────────────────────────────────────
|
||
|
||
export const invoiceInboxExtension: Extension = {
|
||
id: 'invoice-inbox',
|
||
name: 'Dokumentinkorg',
|
||
version: '3.0.0',
|
||
|
||
apiRoutes: [
|
||
// ── Manual upload ───────────────────────────────────────
|
||
{
|
||
method: 'POST',
|
||
path: '/upload',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
// Per-company rate limit (30/min, 500/day). Defense against script
|
||
// floods and compromised sessions; never hit by real users in normal
|
||
// monthly receipt-clearing.
|
||
const limit = await checkInboxUploadRateLimit(ctx.supabase, ctx.companyId)
|
||
if (!limit.ok) {
|
||
return NextResponse.json(
|
||
{
|
||
error:
|
||
limit.scope === 'minute'
|
||
? 'För många uppladdningar på kort tid. Försök igen om en stund.'
|
||
: 'Dagsgränsen för uppladdningar är nådd. Försök igen imorgon.',
|
||
retry_after: limit.retryAfterSec,
|
||
},
|
||
{ status: 429, headers: { 'Retry-After': String(limit.retryAfterSec ?? 60) } },
|
||
)
|
||
}
|
||
|
||
const formData = await request.formData()
|
||
const file = formData.get('file') as File | null
|
||
const matchedTransactionIdRaw = formData.get('matched_transaction_id')
|
||
const matchedTransactionId =
|
||
typeof matchedTransactionIdRaw === 'string' && matchedTransactionIdRaw.length > 0
|
||
? matchedTransactionIdRaw
|
||
: null
|
||
// Opt-out of the built-in Claude/Bedrock OCR. Agents with their own
|
||
// extraction pipeline upload the document, get the inbox row, then
|
||
// PUT /items/:id/extracted-data with their parsed fields.
|
||
const skipExtraction =
|
||
formData.get('skip_extraction') === 'true' ||
|
||
formData.get('skip_extraction') === '1'
|
||
|
||
if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
return NextResponse.json({ error: `File too large (max ${MAX_FILE_SIZE / 1024 / 1024} MB)` }, { status: 400 })
|
||
}
|
||
if (!UPLOAD_ALLOWED_MIME_TYPES.has(file.type)) {
|
||
return NextResponse.json(
|
||
{ error: `Unsupported file type: ${file.type}. Allowed: PDF, JPEG, PNG, HEIC, WebP` },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// Validate matched_transaction_id belongs to this company before we
|
||
// spend the AI extraction budget. RLS would also catch a mismatch on
|
||
// the insert, but failing fast gives a clearer error and lets the
|
||
// caller distinguish "your context_ref pointed at a tx you don't own"
|
||
// from a generic upload failure.
|
||
if (matchedTransactionId) {
|
||
const { data: tx, error: txErr } = await ctx.supabase
|
||
.from('transactions')
|
||
.select('id')
|
||
.eq('id', matchedTransactionId)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
if (txErr) {
|
||
return NextResponse.json({ error: txErr.message }, { status: 500 })
|
||
}
|
||
if (!tx) {
|
||
return NextResponse.json(
|
||
{ error: 'matched_transaction_id refers to a transaction outside this company.' },
|
||
{ status: 400 },
|
||
)
|
||
}
|
||
}
|
||
|
||
try {
|
||
const buffer = await file.arrayBuffer()
|
||
const result = await uploadAndExtract(
|
||
ctx.supabase,
|
||
ctx.userId,
|
||
ctx.companyId,
|
||
{ name: file.name, buffer, type: file.type },
|
||
'upload',
|
||
undefined,
|
||
matchedTransactionId,
|
||
{ skipExtraction },
|
||
)
|
||
return NextResponse.json({ data: result })
|
||
} catch (error) {
|
||
console.error('[invoice-inbox/upload] Failed:', error)
|
||
return NextResponse.json(
|
||
{ error: error instanceof Error ? error.message : 'Upload failed' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
},
|
||
},
|
||
|
||
// ── List inbox items ────────────────────────────────────
|
||
{
|
||
method: 'GET',
|
||
path: '/items',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const url = new URL(request.url)
|
||
const status = url.searchParams.get('status')
|
||
// Cap raised from 50 → 500: the inbox is a workqueue and booked items
|
||
// now drop out of the default view client-side, so a low cap silently
|
||
// hid active underlag behind older booked ones. 500 covers realistic
|
||
// single-company volumes; pagination is the next step beyond that.
|
||
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 50), 500)
|
||
|
||
let query = ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.select(`
|
||
id, status, source, created_at, extracted_data,
|
||
matched_supplier_id, document_id, email_from, email_subject,
|
||
email_received_at, error_message, created_supplier_invoice_id,
|
||
matched_transaction_id, created_journal_entry_id,
|
||
resend_email_id, extraction_skipped
|
||
`)
|
||
.eq('company_id', ctx.companyId)
|
||
.order('created_at', { ascending: false })
|
||
.limit(limit)
|
||
|
||
if (status) query = query.eq('status', status)
|
||
|
||
const { data, error } = await query
|
||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||
|
||
return NextResponse.json({ data: { items: data, count: data?.length ?? 0 } })
|
||
},
|
||
},
|
||
|
||
// ── Get processing_history timeline for an inbox item ───
|
||
{
|
||
method: 'GET',
|
||
path: '/items/:id/history',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const url = new URL(request.url)
|
||
const id = url.searchParams.get('_id')
|
||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||
|
||
const { data: item } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.select('id, correlation_id, company_id')
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
|
||
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||
if (!item.correlation_id) {
|
||
return NextResponse.json({ data: { events: [] } })
|
||
}
|
||
|
||
const { data: events, error } = await ctx.supabase
|
||
.from('processing_history')
|
||
.select('event_id, event_type, occurred_at, payload, actor, causation_id')
|
||
.eq('company_id', ctx.companyId)
|
||
.eq('correlation_id', item.correlation_id)
|
||
.order('occurred_at', { ascending: true })
|
||
.limit(100)
|
||
|
||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||
return NextResponse.json({ data: { events: events ?? [] } })
|
||
},
|
||
},
|
||
|
||
// ── Get single inbox item ───────────────────────────────
|
||
{
|
||
method: 'GET',
|
||
path: '/items/:id',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const url = new URL(request.url)
|
||
const id = url.searchParams.get('_id')
|
||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||
|
||
const { data, error } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.select('*')
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.single()
|
||
|
||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||
|
||
return NextResponse.json({ data })
|
||
},
|
||
},
|
||
|
||
// ── Update extracted_data fields (manual user edits) ────
|
||
{
|
||
method: 'PATCH',
|
||
path: '/items/:id/fields',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const url = new URL(request.url)
|
||
const id = url.searchParams.get('_id')
|
||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||
|
||
let body: z.infer<typeof UpdateExtractedDataSchema>
|
||
try {
|
||
const json = await request.json()
|
||
body = UpdateExtractedDataSchema.parse(json)
|
||
} catch (err) {
|
||
return NextResponse.json(
|
||
{ error: err instanceof Error ? err.message : 'Invalid request body' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
const { data: item } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.select('id, extracted_data, created_supplier_invoice_id')
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
|
||
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||
if (item.created_supplier_invoice_id) {
|
||
return NextResponse.json(
|
||
{ error: 'Posten är redan kopplad till en leverantörsfaktura och kan inte ändras.' },
|
||
{ status: 409 }
|
||
)
|
||
}
|
||
|
||
// Merge user edits into existing extracted_data so we don't lose
|
||
// line items, vatBreakdown, or AI-confidence on partial updates.
|
||
const current = (item.extracted_data ?? {}) as InvoiceExtractionResult
|
||
const merged: InvoiceExtractionResult = {
|
||
supplier: { ...current.supplier, ...body.supplier },
|
||
invoice: { ...current.invoice, ...body.invoice },
|
||
totals: { ...current.totals, ...body.totals },
|
||
lineItems: current.lineItems ?? [],
|
||
vatBreakdown: current.vatBreakdown ?? [],
|
||
confidence: current.confidence ?? 0,
|
||
}
|
||
|
||
const { data: updated, error: updateError } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.update({ extracted_data: merged as unknown as Record<string, unknown> })
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.select('id, extracted_data')
|
||
.single()
|
||
|
||
if (updateError) {
|
||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||
}
|
||
|
||
return NextResponse.json({ data: updated })
|
||
},
|
||
},
|
||
|
||
// ── Replace extracted_data wholesale (BYO extraction) ────
|
||
// Used by agents that ran their own OCR/extraction pipeline. Validates
|
||
// the full InvoiceExtractionResult shape via the same Zod schema that
|
||
// gates Bedrock output, so downstream consumers (UI, supplier-invoice
|
||
// creation) cannot tell apart agent-supplied from AI-extracted data.
|
||
{
|
||
method: 'PUT',
|
||
path: '/items/:id/extracted-data',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const url = new URL(request.url)
|
||
const id = url.searchParams.get('_id')
|
||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||
|
||
// Rate-limit BYO extraction the same way fresh uploads are limited —
|
||
// both paths inject extracted_data into invoice_inbox_items, so an
|
||
// unbounded BYO loop is the same abuse surface as an upload flood
|
||
// (ISO 27001 A.8.12, data-injection guard).
|
||
const rl = await checkInboxUploadRateLimit(ctx.supabase, ctx.companyId)
|
||
if (!rl.ok) {
|
||
return NextResponse.json(
|
||
{ error: `För många förfrågningar — försök igen om en stund.` },
|
||
{
|
||
status: 429,
|
||
headers: rl.retryAfterSec ? { 'Retry-After': String(rl.retryAfterSec) } : undefined,
|
||
}
|
||
)
|
||
}
|
||
|
||
let extracted: InvoiceExtractionResult
|
||
try {
|
||
const json = await request.json()
|
||
// ExtractionSchema doesn't include `confidence` (the AI path tacks
|
||
// it on after parsing). BYO data gets 0.95 so downstream UI can
|
||
// distinguish it from a perfect AI parse — financial-data
|
||
// provenance per ISO 27001 A.8.12.
|
||
const parsed = ExtractionSchema.parse(json)
|
||
extracted = { ...parsed, confidence: 0.95 }
|
||
} catch (err) {
|
||
return NextResponse.json(
|
||
{ error: err instanceof Error ? err.message : 'Invalid extracted_data shape' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
const { data: item } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.select('id, company_id, created_supplier_invoice_id')
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
|
||
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||
// Explicit tenant boundary assertion alongside the .eq filter
|
||
// (V4.5.1 defense-in-depth). Surfaces any future change that
|
||
// accidentally bypasses the where-clause.
|
||
if (item.company_id !== ctx.companyId) {
|
||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||
}
|
||
if (item.created_supplier_invoice_id) {
|
||
return NextResponse.json(
|
||
{ error: 'Posten är redan kopplad till en leverantörsfaktura och kan inte ändras.' },
|
||
{ status: 409 }
|
||
)
|
||
}
|
||
|
||
// Re-run supplier match so the agent's parsed fields trigger the
|
||
// same auto-link the AI path uses. Skipped if neither key is present.
|
||
let matchedSupplierId: string | null = null
|
||
if (extracted.supplier.orgNumber) {
|
||
const { data: s } = await ctx.supabase
|
||
.from('suppliers')
|
||
.select('id')
|
||
.eq('company_id', ctx.companyId)
|
||
.eq('org_number', extracted.supplier.orgNumber)
|
||
.limit(1)
|
||
.maybeSingle()
|
||
if (s) matchedSupplierId = s.id
|
||
}
|
||
if (!matchedSupplierId && extracted.supplier.name) {
|
||
const { data: s } = await ctx.supabase
|
||
.from('suppliers')
|
||
.select('id')
|
||
.eq('company_id', ctx.companyId)
|
||
.ilike('name', extracted.supplier.name)
|
||
.limit(1)
|
||
.maybeSingle()
|
||
if (s) matchedSupplierId = s.id
|
||
}
|
||
|
||
const { data: updated, error: updateError } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.update({
|
||
extracted_data: extracted as unknown as Record<string, unknown>,
|
||
matched_supplier_id: matchedSupplierId,
|
||
})
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.select('id, extracted_data, matched_supplier_id')
|
||
.single()
|
||
|
||
if (updateError) {
|
||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||
}
|
||
|
||
// Audit the BYO override so financial-data provenance is traceable
|
||
// (GDPR Art. 5(1)(f), SOC 2 CC9.2). Failure logged but never blocks
|
||
// the response — the override has already happened.
|
||
try {
|
||
await appendProcessingHistory({
|
||
companyId: ctx.companyId,
|
||
correlationId: id,
|
||
aggregateType: 'Document',
|
||
aggregateId: id,
|
||
eventType: 'DocumentExtractionOverridden',
|
||
payload: {
|
||
inbox_item_id: id,
|
||
channel: 'rest_api',
|
||
has_supplier_org_number: extracted.supplier.orgNumber != null,
|
||
has_invoice_number: extracted.invoice.invoiceNumber != null,
|
||
extracted_total: extracted.totals.total,
|
||
matched_supplier_id: matchedSupplierId,
|
||
},
|
||
actor: { type: 'user', id: ctx.userId },
|
||
occurredAt: new Date(),
|
||
})
|
||
} catch (auditErr) {
|
||
console.error('[invoice-inbox] Failed to append DocumentExtractionOverridden:', auditErr)
|
||
}
|
||
|
||
return NextResponse.json({ data: updated })
|
||
},
|
||
},
|
||
|
||
// ── Attach a source document to an existing inbox item ──
|
||
{
|
||
method: 'POST',
|
||
path: '/items/:id/attach-document',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const url = new URL(request.url)
|
||
const id = url.searchParams.get('_id')
|
||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||
|
||
const formData = await request.formData()
|
||
const file = formData.get('file') as File | null
|
||
if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
return NextResponse.json({ error: `File too large (max ${MAX_FILE_SIZE / 1024 / 1024} MB)` }, { status: 400 })
|
||
}
|
||
if (!UPLOAD_ALLOWED_MIME_TYPES.has(file.type)) {
|
||
return NextResponse.json(
|
||
{ error: `Unsupported file type: ${file.type}. Allowed: PDF, JPEG, PNG, HEIC, WebP` },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
const { data: item } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.select('id, document_id, status, correlation_id, created_supplier_invoice_id')
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
|
||
if (!item) return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
|
||
if (item.created_supplier_invoice_id) {
|
||
return NextResponse.json({ error: 'Redan bokfört — kan inte ersätta bilden.' }, { status: 409 })
|
||
}
|
||
if (item.document_id) {
|
||
return NextResponse.json({ error: 'Posten har redan en bilaga.' }, { status: 409 })
|
||
}
|
||
|
||
try {
|
||
const buffer = await file.arrayBuffer()
|
||
const doc = await uploadDocument(ctx.supabase, ctx.userId, ctx.companyId, {
|
||
name: file.name,
|
||
buffer,
|
||
type: file.type,
|
||
}, {
|
||
upload_source: 'file_upload',
|
||
})
|
||
|
||
// Same page-count gate as /upload (issue #553) — attaching a 6-page
|
||
// sales report to an existing inbox row should not block on Bedrock.
|
||
// Sandbox companies skip Bedrock unconditionally.
|
||
const pageCount =
|
||
file.type === 'application/pdf' ? await countPdfPages(buffer) : null
|
||
const gatedByPageCount =
|
||
pageCount != null && pageCount > MAX_PAGES_FOR_AUTO_EXTRACT
|
||
const sandbox = await isSandboxCompany(ctx.supabase, ctx.companyId)
|
||
const skipReason: 'too_many_pages' | 'sandbox' | null = sandbox
|
||
? 'sandbox'
|
||
: gatedByPageCount
|
||
? 'too_many_pages'
|
||
: null
|
||
const skipExtraction = skipReason !== null
|
||
|
||
const { data: extracted } = skipExtraction
|
||
? { data: emptyResult() }
|
||
: await extractInvoiceFields({
|
||
buffer: Buffer.from(buffer),
|
||
mimeType: file.type,
|
||
fileName: file.name,
|
||
})
|
||
|
||
const { error: linkError } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.update({
|
||
document_id: doc.id,
|
||
extracted_data: extracted as unknown as Record<string, unknown>,
|
||
extraction_skipped: skipExtraction,
|
||
})
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
if (linkError) {
|
||
return NextResponse.json({ error: linkError.message }, { status: 500 })
|
||
}
|
||
|
||
if (item.correlation_id) {
|
||
try {
|
||
await appendProcessingHistory({
|
||
companyId: ctx.companyId,
|
||
correlationId: item.correlation_id,
|
||
aggregateType: 'Document',
|
||
aggregateId: doc.id,
|
||
eventType: 'DocumentIngested',
|
||
payload: {
|
||
channel: 'upload',
|
||
document_id: doc.id,
|
||
inbox_item_id: id,
|
||
mime_type: file.type,
|
||
size_bytes: file.size,
|
||
attached_to_existing: true,
|
||
},
|
||
actor: { type: 'user', id: ctx.userId },
|
||
occurredAt: new Date(),
|
||
})
|
||
} catch (err) {
|
||
console.error('[invoice-inbox/attach-document] appendProcessingHistory failed:', err)
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({
|
||
data: {
|
||
document_id: doc.id,
|
||
inbox_item_id: id,
|
||
extracted_data: extracted,
|
||
extraction_skipped: skipExtraction,
|
||
skip_reason: skipReason,
|
||
page_count: pageCount,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
console.error('[invoice-inbox/attach-document] Failed:', error)
|
||
return NextResponse.json(
|
||
{ error: error instanceof Error ? error.message : 'Attach failed' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
},
|
||
},
|
||
|
||
// ── Match a supplier to an inbox item ───────────────────
|
||
{
|
||
method: 'POST',
|
||
path: '/items/:id/match-supplier',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const url = new URL(request.url)
|
||
const id = url.searchParams.get('_id')
|
||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||
|
||
let body: { supplier_id?: string }
|
||
try {
|
||
body = await request.json()
|
||
} catch {
|
||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||
}
|
||
if (!body.supplier_id || typeof body.supplier_id !== 'string') {
|
||
return NextResponse.json({ error: 'supplier_id required' }, { status: 400 })
|
||
}
|
||
|
||
// Confirm supplier exists in this company before linking.
|
||
const { data: supplier } = await ctx.supabase
|
||
.from('suppliers')
|
||
.select('id')
|
||
.eq('id', body.supplier_id)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
if (!supplier) {
|
||
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
|
||
}
|
||
|
||
const { error: updateError } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.update({ matched_supplier_id: body.supplier_id })
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
|
||
if (updateError) {
|
||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||
}
|
||
return NextResponse.json({ data: { id, matched_supplier_id: body.supplier_id } })
|
||
},
|
||
},
|
||
|
||
// ── Match a bank transaction to an inbox item ──────────
|
||
// Sets invoice_inbox_items.matched_transaction_id. Used by the
|
||
// TransactionMatchPicker dialog after the user picks a candidate from
|
||
// the confidence-scored list. The transaction.categorization agent
|
||
// intent already reads this column in its capture() so the agent will
|
||
// see the inbox metadata as underlag on its next invocation.
|
||
{
|
||
method: 'POST',
|
||
path: '/items/:id/match-transaction',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const url = new URL(request.url)
|
||
const id = url.searchParams.get('_id')
|
||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||
|
||
let body: { transaction_id?: string }
|
||
try {
|
||
body = await request.json()
|
||
} catch {
|
||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||
}
|
||
if (!body.transaction_id || typeof body.transaction_id !== 'string') {
|
||
return NextResponse.json({ error: 'transaction_id required' }, { status: 400 })
|
||
}
|
||
|
||
// Confirm transaction belongs to this company before linking. RLS
|
||
// would also catch it on the update, but failing fast keeps the
|
||
// error specific. Also fetch the existing document_id so we can
|
||
// decide whether to backfill it from the inbox doc below.
|
||
const { data: tx } = await ctx.supabase
|
||
.from('transactions')
|
||
.select('id, document_id')
|
||
.eq('id', body.transaction_id)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
if (!tx) {
|
||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||
}
|
||
|
||
// Fetch the inbox item's document_id so we can mirror it to
|
||
// transactions.document_id below — the TransactionInboxCard reads
|
||
// that column to decide whether to show the paperclip/file-check
|
||
// indicators on the /transactions list. Without this, a row that
|
||
// has a matched inbox item still appears doc-less in the UI.
|
||
const { data: inboxItem } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.select('id, document_id')
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
|
||
const { data: updated, error: updateError } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.update({ matched_transaction_id: body.transaction_id })
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.select('id, matched_transaction_id')
|
||
.single()
|
||
|
||
if (updateError) {
|
||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||
}
|
||
|
||
// Mirror the inbox document onto the transaction so the list view
|
||
// reflects "underlag bifogat" immediately. Only when the tx has
|
||
// no other doc already (we never overwrite an existing link).
|
||
if (inboxItem?.document_id && !tx.document_id) {
|
||
const { error: txUpdateError } = await ctx.supabase
|
||
.from('transactions')
|
||
.update({ document_id: inboxItem.document_id })
|
||
.eq('id', body.transaction_id)
|
||
.eq('company_id', ctx.companyId)
|
||
.is('document_id', null)
|
||
if (txUpdateError) {
|
||
// Non-fatal: the match itself succeeded; the UI indicator just
|
||
// won't flip until next page refresh. Log but don't roll back.
|
||
console.error('[invoice-inbox/match-transaction] tx.document_id backfill failed:', txUpdateError)
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({ data: updated })
|
||
},
|
||
},
|
||
|
||
// ── Clear matched_transaction_id (user mistake / re-match) ────
|
||
{
|
||
method: 'POST',
|
||
path: '/items/:id/unmatch-transaction',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const url = new URL(request.url)
|
||
const id = url.searchParams.get('_id')
|
||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||
|
||
// Capture the current match before clearing so we can mirror the
|
||
// unmatch onto transactions.document_id below.
|
||
const { data: existing } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.select('id, document_id, matched_transaction_id')
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
|
||
const { data: updated, error: updateError } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.update({ matched_transaction_id: null })
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.select('id, matched_transaction_id')
|
||
.single()
|
||
|
||
if (updateError) {
|
||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||
}
|
||
|
||
// Clear the mirrored tx.document_id only when it currently points
|
||
// at the same doc this inbox item brought in. Guards against
|
||
// clobbering a doc that came from another source (paperclip
|
||
// upload, SIE import, etc.).
|
||
if (existing?.matched_transaction_id && existing.document_id) {
|
||
const { error: txUpdateError } = await ctx.supabase
|
||
.from('transactions')
|
||
.update({ document_id: null })
|
||
.eq('id', existing.matched_transaction_id)
|
||
.eq('company_id', ctx.companyId)
|
||
.eq('document_id', existing.document_id)
|
||
if (txUpdateError) {
|
||
console.error('[invoice-inbox/unmatch-transaction] tx.document_id clear failed:', txUpdateError)
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({ data: updated })
|
||
},
|
||
},
|
||
|
||
// ── Retry extraction on a stored document ──────────────
|
||
{
|
||
method: 'POST',
|
||
path: '/items/:id/retry-extraction',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
// Retry runs pdfjs extraction synchronously and is CPU-heavy; counts
|
||
// against the same per-company quota as a fresh upload so an
|
||
// attacker can't burn server CPU by repeatedly re-extracting one doc.
|
||
const limit = await checkInboxUploadRateLimit(ctx.supabase, ctx.companyId)
|
||
if (!limit.ok) {
|
||
return NextResponse.json(
|
||
{
|
||
error:
|
||
limit.scope === 'minute'
|
||
? 'För många tolkningsförsök på kort tid. Försök igen om en stund.'
|
||
: 'Dagsgränsen för tolkningar är nådd. Försök igen imorgon.',
|
||
retry_after: limit.retryAfterSec,
|
||
},
|
||
{ status: 429, headers: { 'Retry-After': String(limit.retryAfterSec ?? 60) } },
|
||
)
|
||
}
|
||
|
||
const url = new URL(request.url)
|
||
const id = url.searchParams.get('_id')
|
||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||
|
||
const { data: item } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.select('id, document_id, correlation_id, created_supplier_invoice_id')
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
|
||
if (!item) return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
|
||
if (item.created_supplier_invoice_id) {
|
||
return NextResponse.json(
|
||
{ error: 'Redan bokfört — kan inte köra om tolkningen.' },
|
||
{ status: 409 },
|
||
)
|
||
}
|
||
if (!item.document_id) {
|
||
return NextResponse.json(
|
||
{ error: 'Ingen bilaga att tolka om.' },
|
||
{ status: 400 },
|
||
)
|
||
}
|
||
|
||
if (await isSandboxCompany(ctx.supabase, ctx.companyId)) {
|
||
return NextResponse.json(
|
||
{ error: 'AI-tolkning är inte tillgänglig i sandlådan.' },
|
||
{ status: 409 },
|
||
)
|
||
}
|
||
|
||
const { data: doc } = await ctx.supabase
|
||
.from('document_attachments')
|
||
.select('storage_path, mime_type, file_name')
|
||
.eq('id', item.document_id)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
|
||
if (!doc) {
|
||
return NextResponse.json({ error: 'Bilagan kunde inte hittas.' }, { status: 404 })
|
||
}
|
||
|
||
const { data: blob, error: dlError } = await ctx.supabase.storage
|
||
.from('documents')
|
||
.download(doc.storage_path)
|
||
|
||
if (dlError || !blob) {
|
||
console.error('[invoice-inbox/retry-extraction] download failed:', dlError)
|
||
return NextResponse.json(
|
||
{ error: 'Kunde inte ladda ner bilagan.' },
|
||
{ status: 500 },
|
||
)
|
||
}
|
||
|
||
try {
|
||
const buffer = Buffer.from(await blob.arrayBuffer())
|
||
const { data: extracted } = await extractInvoiceFields({
|
||
buffer,
|
||
mimeType: doc.mime_type,
|
||
fileName: doc.file_name,
|
||
})
|
||
|
||
const { error: updateError } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.update({
|
||
status: 'received',
|
||
error_message: null,
|
||
extracted_data: extracted as unknown as Record<string, unknown>,
|
||
// Retry is user-initiated and bypasses the page-count gate by
|
||
// design — the user explicitly opted into the slow path.
|
||
extraction_skipped: false,
|
||
})
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
|
||
if (updateError) {
|
||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||
}
|
||
|
||
if (item.correlation_id) {
|
||
try {
|
||
await appendProcessingHistory({
|
||
companyId: ctx.companyId,
|
||
correlationId: item.correlation_id,
|
||
aggregateType: 'Document',
|
||
aggregateId: item.document_id,
|
||
eventType: 'DocumentExtractionRetried',
|
||
payload: {
|
||
inbox_item_id: id,
|
||
document_id: item.document_id,
|
||
},
|
||
actor: { type: 'user', id: ctx.userId },
|
||
occurredAt: new Date(),
|
||
})
|
||
} catch (logErr) {
|
||
console.error('[invoice-inbox/retry-extraction] history append failed:', logErr)
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({ data: { extracted_data: extracted } })
|
||
} catch (error) {
|
||
console.error('[invoice-inbox/retry-extraction] extraction failed:', error)
|
||
const message = error instanceof Error ? error.message : 'Tolkning misslyckades'
|
||
await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.update({ status: 'error', error_message: message })
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
return NextResponse.json({ error: message }, { status: 500 })
|
||
}
|
||
},
|
||
},
|
||
|
||
// ── Get this company's inbox address ────────────────────
|
||
{
|
||
method: 'GET',
|
||
path: '/inbox/address',
|
||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const domain = process.env.RESEND_INBOUND_DOMAIN
|
||
if (!domain) {
|
||
return NextResponse.json({ error: 'RESEND_INBOUND_DOMAIN not configured' }, { status: 503 })
|
||
}
|
||
|
||
try {
|
||
const inbox = await getActiveInbox(ctx.supabase, ctx.companyId)
|
||
if (!inbox) {
|
||
return NextResponse.json({ error: 'No active inbox' }, { status: 404 })
|
||
}
|
||
return NextResponse.json({
|
||
data: {
|
||
address: composeInboxAddress(inbox.local_part, domain),
|
||
local_part: inbox.local_part,
|
||
status: inbox.status,
|
||
created_at: inbox.created_at,
|
||
},
|
||
})
|
||
} catch (err) {
|
||
return NextResponse.json(
|
||
{ error: err instanceof Error ? err.message : 'Failed to load inbox' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
},
|
||
},
|
||
|
||
// ── Rotate inbox address (admin/owner only) ─────────────
|
||
{
|
||
method: 'POST',
|
||
path: '/inbox/rotate',
|
||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const domain = process.env.RESEND_INBOUND_DOMAIN
|
||
if (!domain) {
|
||
return NextResponse.json({ error: 'RESEND_INBOUND_DOMAIN not configured' }, { status: 503 })
|
||
}
|
||
|
||
const isAdmin = await isCompanyAdmin(ctx.supabase, ctx.userId, ctx.companyId)
|
||
if (!isAdmin) return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
|
||
|
||
try {
|
||
const newInbox = await rotateCompanyInbox(ctx.supabase, ctx.companyId)
|
||
return NextResponse.json({
|
||
data: {
|
||
address: composeInboxAddress(newInbox.local_part, domain),
|
||
local_part: newInbox.local_part,
|
||
status: newInbox.status,
|
||
},
|
||
})
|
||
} catch (err) {
|
||
console.error('[invoice-inbox/inbox/rotate] Failed:', err)
|
||
return NextResponse.json(
|
||
{ error: err instanceof Error ? err.message : 'Rotation failed' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
},
|
||
},
|
||
|
||
// ── Resend Inbound webhook (Svix-signed, no user auth) ──
|
||
{
|
||
method: 'POST',
|
||
path: '/inbound',
|
||
skipAuth: true,
|
||
handler: async (request: Request) => {
|
||
const domain = process.env.RESEND_INBOUND_DOMAIN
|
||
if (!domain) {
|
||
console.error('[invoice-inbox/inbound] RESEND_INBOUND_DOMAIN not configured')
|
||
return NextResponse.json({ error: 'Inbound not configured' }, { status: 503 })
|
||
}
|
||
|
||
const rawBody = await request.text()
|
||
|
||
let event
|
||
try {
|
||
event = verifyInboundWebhook(rawBody, request.headers)
|
||
} catch (err) {
|
||
if (err instanceof ResendSignatureError) {
|
||
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
|
||
}
|
||
console.error('[invoice-inbox/inbound] Verification error:', err)
|
||
return NextResponse.json({ error: 'Verification failed' }, { status: 500 })
|
||
}
|
||
|
||
if (!isEmailReceivedEvent(event)) {
|
||
return NextResponse.json({ data: { ignored: event.type } }, { status: 200 })
|
||
}
|
||
|
||
const { email_id, to, from, subject, message_id, created_at } = event.data
|
||
|
||
const localPart = extractLocalPartForDomain(to, domain)
|
||
if (!localPart) {
|
||
console.warn('[invoice-inbox/inbound] No recipient matched domain', { to, domain })
|
||
return NextResponse.json({ error: 'No matching recipient' }, { status: 404 })
|
||
}
|
||
|
||
const serviceSupabase = createClient(
|
||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||
)
|
||
|
||
const { data: inbox } = await serviceSupabase
|
||
.from('company_inboxes')
|
||
.select('id, company_id, status')
|
||
.eq('local_part', localPart)
|
||
.maybeSingle()
|
||
|
||
if (!inbox) {
|
||
return NextResponse.json({ error: 'Address not found' }, { status: 404 })
|
||
}
|
||
if (inbox.status !== 'active') {
|
||
return NextResponse.json({ error: 'Address no longer active' }, { status: 410 })
|
||
}
|
||
|
||
const { data: company } = await serviceSupabase
|
||
.from('companies')
|
||
.select('created_by')
|
||
.eq('id', inbox.company_id)
|
||
.single()
|
||
|
||
if (!company?.created_by) {
|
||
console.error('[invoice-inbox/inbound] Company has no created_by', inbox.company_id)
|
||
return NextResponse.json({ error: 'Company owner missing' }, { status: 500 })
|
||
}
|
||
const userId = company.created_by
|
||
|
||
let fullEmail
|
||
try {
|
||
fullEmail = await fetchReceivingEmail(email_id)
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
console.error('[invoice-inbox/inbound] Failed to fetch received email:', err)
|
||
return NextResponse.json({ error: `Fetch failed: ${message}` }, { status: 500 })
|
||
}
|
||
|
||
const bodyText = fullEmail.text ?? null
|
||
const rawAttachments = fullEmail.attachments ?? []
|
||
|
||
// Per-company rate limit (30/min, 500/day). Same Postgres-backed
|
||
// RPC as /upload. Acknowledge + drop on cap — returning 429 to
|
||
// Resend would just consume more budget via their retry.
|
||
const limit = await checkInboxUploadRateLimit(serviceSupabase, inbox.company_id)
|
||
if (!limit.ok) {
|
||
try {
|
||
await appendProcessingHistory({
|
||
companyId: inbox.company_id,
|
||
correlationId: email_id,
|
||
aggregateType: 'System',
|
||
aggregateId: email_id,
|
||
eventType: 'RateLimitedDropped',
|
||
payload: {
|
||
scope: limit.scope,
|
||
retry_after_sec: limit.retryAfterSec,
|
||
attachment_count: rawAttachments.length,
|
||
from,
|
||
subject,
|
||
},
|
||
actor: { type: 'system', id: 'resend-inbound' },
|
||
occurredAt: new Date(),
|
||
})
|
||
} catch (err) {
|
||
console.error('[invoice-inbox/inbound] RateLimitedDropped append failed:', err)
|
||
}
|
||
return NextResponse.json({ data: { processed: 0, reason: 'rate_limited' } })
|
||
}
|
||
|
||
// Per-email attachment cap. 20 covers any legitimate batched
|
||
// supplier email; an attacker stuffing 500 PDFs into one message
|
||
// gets truncated and a single history event records the drop.
|
||
const totalAttachments = rawAttachments.length
|
||
const attachments = rawAttachments.slice(0, MAX_ATTACHMENTS_PER_EMAIL)
|
||
const truncatedCount = totalAttachments - attachments.length
|
||
if (truncatedCount > 0) {
|
||
try {
|
||
await appendProcessingHistory({
|
||
companyId: inbox.company_id,
|
||
correlationId: email_id,
|
||
aggregateType: 'System',
|
||
aggregateId: email_id,
|
||
eventType: 'AttachmentsTruncated',
|
||
payload: {
|
||
total: totalAttachments,
|
||
processed: attachments.length,
|
||
dropped: truncatedCount,
|
||
from,
|
||
subject,
|
||
},
|
||
actor: { type: 'system', id: 'resend-inbound' },
|
||
occurredAt: new Date(),
|
||
})
|
||
} catch (err) {
|
||
console.error('[invoice-inbox/inbound] AttachmentsTruncated append failed:', err)
|
||
}
|
||
}
|
||
|
||
if (attachments.length === 0) {
|
||
await serviceSupabase.from('invoice_inbox_items').insert({
|
||
company_id: inbox.company_id,
|
||
user_id: userId,
|
||
status: 'error',
|
||
source: 'email',
|
||
email_from: from,
|
||
email_subject: subject,
|
||
email_received_at: created_at,
|
||
email_body_text: bodyText,
|
||
resend_email_id: email_id,
|
||
error_message: 'Email had no attachments',
|
||
raw_email_payload: { messageId: message_id },
|
||
})
|
||
return NextResponse.json({ data: { processed: 0, reason: 'no_attachments' } })
|
||
}
|
||
|
||
const results: Array<{ attachment_id: string; inbox_item_id?: string; error?: string; duplicate?: boolean }> = []
|
||
|
||
// Persist a "rejected" inbox row so the user has visibility into the drop.
|
||
// Without this, attachments that fail MIME validation vanish silently —
|
||
// a common Gmail "forward as attachment" foot-gun until we added .eml
|
||
// handling below.
|
||
const logRejection = async (
|
||
attachmentId: string,
|
||
attachmentName: string | null,
|
||
mime: string,
|
||
reason: string,
|
||
) => {
|
||
// attachment_name and mime are attacker-controlled (they come from the
|
||
// forwarded email headers); sanitise before they land in the JSONB
|
||
// raw_email_payload column so they can't surface as injection or
|
||
// oversized values when read back into the UI / audit trails.
|
||
try {
|
||
await serviceSupabase.from('invoice_inbox_items').insert({
|
||
company_id: inbox.company_id,
|
||
user_id: userId,
|
||
status: 'error',
|
||
source: 'email',
|
||
email_from: from,
|
||
email_subject: subject,
|
||
email_received_at: created_at,
|
||
email_body_text: bodyText,
|
||
resend_email_id: email_id,
|
||
resend_attachment_id: attachmentId,
|
||
error_message: reason.slice(0, 500),
|
||
raw_email_payload: {
|
||
messageId: message_id,
|
||
attachment_name: sanitiseFilename(attachmentName, 'unknown'),
|
||
mime: sanitiseMime(mime),
|
||
},
|
||
})
|
||
} catch (insertErr) {
|
||
console.error('[invoice-inbox/inbound] Failed to log rejected attachment:', insertErr)
|
||
}
|
||
}
|
||
|
||
for (const att of attachments) {
|
||
try {
|
||
const { data: existing } = await serviceSupabase
|
||
.from('invoice_inbox_items')
|
||
.select('id')
|
||
.eq('resend_email_id', email_id)
|
||
.eq('resend_attachment_id', att.id)
|
||
.maybeSingle()
|
||
if (existing) {
|
||
results.push({ attachment_id: att.id, inbox_item_id: existing.id, duplicate: true })
|
||
continue
|
||
}
|
||
|
||
const download = await fetchInboundAttachment(email_id, att.id)
|
||
|
||
// Gmail "Forward as attachment" wraps the original email as message/rfc822.
|
||
// Unwrap it and process the inner attachments as if they had arrived
|
||
// directly, carrying the inner email's subject/from into our metadata.
|
||
if (download.contentType === 'message/rfc822') {
|
||
const parsed = await simpleParser(Buffer.from(download.buffer))
|
||
const innerAttachments = parsed.attachments || []
|
||
if (innerAttachments.length === 0) {
|
||
await logRejection(att.id, download.filename, download.contentType, 'Det vidarebefordrade meddelandet innehöll inga bilagor')
|
||
results.push({ attachment_id: att.id, error: 'eml_no_inner_attachments' })
|
||
continue
|
||
}
|
||
const innerFrom = parsed.from?.text || from
|
||
const innerSubject = parsed.subject || subject
|
||
for (let i = 0; i < innerAttachments.length; i++) {
|
||
const inner = innerAttachments[i]
|
||
const innerType = sanitiseMime(inner.contentType)
|
||
const innerName = sanitiseFilename(inner.filename, `attachment-${i}`)
|
||
const innerBuffer = inner.content
|
||
if (!innerBuffer) continue
|
||
const innerId = `${att.id}#${i}`
|
||
if (!UPLOAD_ALLOWED_MIME_TYPES.has(innerType)) {
|
||
await logRejection(innerId, innerName, innerType, `Avvisad bilaga från vidarebefordrat mejl: filtypen ${innerType} stöds inte`)
|
||
results.push({ attachment_id: innerId, error: `Unsupported type ${innerType}` })
|
||
continue
|
||
}
|
||
if (innerBuffer.byteLength > MAX_FILE_SIZE) {
|
||
await logRejection(innerId, innerName, innerType, 'Bilagan i det vidarebefordrade mejlet är för stor')
|
||
results.push({ attachment_id: innerId, error: 'Inner attachment too large' })
|
||
continue
|
||
}
|
||
const innerArrayBuffer = new Uint8Array(innerBuffer).buffer
|
||
const innerResult = await uploadAndExtract(
|
||
serviceSupabase,
|
||
userId,
|
||
inbox.company_id,
|
||
{ name: innerName, buffer: innerArrayBuffer, type: innerType },
|
||
'email',
|
||
{
|
||
from: innerFrom,
|
||
subject: innerSubject,
|
||
receivedAt: created_at,
|
||
messageId: message_id,
|
||
bodyText,
|
||
resendEmailId: email_id,
|
||
resendAttachmentId: innerId,
|
||
}
|
||
)
|
||
results.push({ attachment_id: innerId, inbox_item_id: innerResult.inbox_item_id })
|
||
}
|
||
continue
|
||
}
|
||
|
||
if (!UPLOAD_ALLOWED_MIME_TYPES.has(download.contentType)) {
|
||
await logRejection(att.id, download.filename, download.contentType, `Avvisad: filtypen ${download.contentType} stöds inte`)
|
||
results.push({ attachment_id: att.id, error: `Unsupported type ${download.contentType}` })
|
||
continue
|
||
}
|
||
if (download.buffer.byteLength > MAX_FILE_SIZE) {
|
||
await logRejection(att.id, download.filename, download.contentType, 'Bilagan är för stor')
|
||
results.push({ attachment_id: att.id, error: 'Attachment too large' })
|
||
continue
|
||
}
|
||
|
||
const result = await uploadAndExtract(
|
||
serviceSupabase,
|
||
userId,
|
||
inbox.company_id,
|
||
{ name: download.filename, buffer: download.buffer, type: download.contentType },
|
||
'email',
|
||
{
|
||
from,
|
||
subject,
|
||
receivedAt: created_at,
|
||
messageId: message_id,
|
||
bodyText,
|
||
resendEmailId: email_id,
|
||
resendAttachmentId: att.id,
|
||
}
|
||
)
|
||
results.push({ attachment_id: att.id, inbox_item_id: result.inbox_item_id })
|
||
} catch (err) {
|
||
console.error('[invoice-inbox/inbound] Attachment processing failed:', err)
|
||
results.push({
|
||
attachment_id: att.id,
|
||
error: err instanceof Error ? err.message : 'Unknown error',
|
||
})
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({ data: { processed: results.length, results } })
|
||
},
|
||
},
|
||
|
||
// ── Delete inbox item ──────────────────────────────────
|
||
{
|
||
method: 'DELETE',
|
||
path: '/items/:id',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const url = new URL(request.url)
|
||
const id = url.searchParams.get('_id')
|
||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||
|
||
const { data: item } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.select('id, created_supplier_invoice_id, created_journal_entry_id')
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
|
||
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||
if (item.created_supplier_invoice_id) {
|
||
return NextResponse.json(
|
||
{ error: 'Posten är kopplad till en leverantörsfaktura och kan inte tas bort.' },
|
||
{ status: 409 }
|
||
)
|
||
}
|
||
if (item.created_journal_entry_id) {
|
||
return NextResponse.json(
|
||
{ error: 'Posten är bokförd och kan inte tas bort.' },
|
||
{ status: 409 }
|
||
)
|
||
}
|
||
|
||
const { error } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.delete()
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
|
||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||
return NextResponse.json({ data: { id, deleted: true } })
|
||
},
|
||
},
|
||
|
||
// ── Convert inbox item to supplier invoice ─────────────
|
||
{
|
||
method: 'POST',
|
||
path: '/items/:id/convert',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const url = new URL(request.url)
|
||
const id = url.searchParams.get('_id')
|
||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||
|
||
const { data: item, error: fetchError } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.select('*')
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.single()
|
||
|
||
if (fetchError || !item) return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
|
||
if (item.created_supplier_invoice_id) {
|
||
return NextResponse.json({ error: 'Posten är redan kopplad till en leverantörsfaktura.' }, { status: 409 })
|
||
}
|
||
|
||
let body: ReturnType<typeof CreateSupplierInvoiceSchema.parse>
|
||
try {
|
||
const json = await request.json()
|
||
body = CreateSupplierInvoiceSchema.parse(json)
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : 'Invalid request body'
|
||
return NextResponse.json({ error: message }, { status: 400 })
|
||
}
|
||
|
||
const { data: supplier, error: supplierError } = await ctx.supabase
|
||
.from('suppliers')
|
||
.select('*')
|
||
.eq('id', body.supplier_id)
|
||
.eq('company_id', ctx.companyId)
|
||
.single()
|
||
|
||
if (supplierError || !supplier) {
|
||
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
|
||
}
|
||
|
||
// Periodisering requires faktureringsmetoden — mirror the main
|
||
// /api/supplier-invoices guard so kontantmetod companies never store
|
||
// accrual fields the booking would silently ignore.
|
||
const hasAccrualItems = body.items.some(
|
||
(bodyItem) => bodyItem.accrual_period_start && bodyItem.accrual_period_end,
|
||
)
|
||
if (hasAccrualItems && body.reverse_charge) {
|
||
// Omvänd skattskyldighet: the expense line carries the VAT base for
|
||
// rutor 20–32 — deferring the net to a 17xx interim account would
|
||
// corrupt the momsdeklaration. Same guard as /api/supplier-invoices.
|
||
return errorResponseFromCode('SI_CREATE_ACCRUAL_REVERSE_CHARGE', ctx.log)
|
||
}
|
||
if (hasAccrualItems) {
|
||
const { data: methodSettings } = await ctx.supabase
|
||
.from('company_settings')
|
||
.select('accounting_method')
|
||
.eq('company_id', ctx.companyId)
|
||
.single()
|
||
if ((methodSettings?.accounting_method || 'accrual') !== 'accrual') {
|
||
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', ctx.log, {
|
||
details: { reason: 'periodisering requires faktureringsmetoden (accrual)' },
|
||
})
|
||
}
|
||
}
|
||
|
||
const { data: arrivalNum, error: arrivalError } = await ctx.supabase
|
||
.rpc('get_next_arrival_number', { p_company_id: ctx.companyId })
|
||
|
||
if (arrivalError) {
|
||
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
|
||
}
|
||
|
||
const items = body.items.map((bodyItem, index) => {
|
||
const vatRate = bodyItem.vat_rate ?? 0.25
|
||
const lineTotal = bodyItem.amount != null
|
||
? Math.round(bodyItem.amount * 100) / 100
|
||
: Math.round((bodyItem.quantity ?? 1) * (bodyItem.unit_price ?? 0) * 100) / 100
|
||
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
|
||
return {
|
||
sort_order: index,
|
||
description: bodyItem.description,
|
||
quantity: bodyItem.amount != null ? 1 : (bodyItem.quantity ?? 1),
|
||
unit: bodyItem.amount != null ? 'st' : (bodyItem.unit || 'st'),
|
||
unit_price: bodyItem.amount != null ? lineTotal : (bodyItem.unit_price ?? 0),
|
||
line_total: lineTotal,
|
||
account_number: bodyItem.account_number,
|
||
vat_code: bodyItem.vat_code || null,
|
||
vat_rate: vatRate,
|
||
vat_amount: vatAmount,
|
||
// Self-assessed RC rate (0.06/0.12/0.25) or null — engine defaults
|
||
// to 25% huvudregeln when null for a reverse-charge invoice.
|
||
reverse_charge_rate: body.reverse_charge ? (bodyItem.reverse_charge_rate ?? null) : null,
|
||
// Periodisering: frozen onto the line; the balance account
|
||
// defaults from the cost account's BAS convention.
|
||
accrual_period_start:
|
||
bodyItem.accrual_period_start && bodyItem.accrual_period_end
|
||
? bodyItem.accrual_period_start
|
||
: null,
|
||
accrual_period_end:
|
||
bodyItem.accrual_period_start && bodyItem.accrual_period_end
|
||
? bodyItem.accrual_period_end
|
||
: null,
|
||
accrual_balance_account:
|
||
bodyItem.accrual_period_start && bodyItem.accrual_period_end
|
||
? (bodyItem.accrual_balance_account ??
|
||
suggestBalanceAccount('expense', bodyItem.account_number))
|
||
: null,
|
||
}
|
||
})
|
||
|
||
const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
|
||
const totalVat = items.reduce((sum, i) => sum + i.vat_amount, 0)
|
||
const total = Math.round((subtotal + totalVat) * 100) / 100
|
||
|
||
const exchangeRate = body.exchange_rate || null
|
||
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
|
||
const vatAmountSek = exchangeRate ? Math.round(totalVat * exchangeRate * 100) / 100 : null
|
||
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
|
||
|
||
const { data: invoice, error: invoiceError } = await ctx.supabase
|
||
.from('supplier_invoices')
|
||
.insert({
|
||
user_id: ctx.userId,
|
||
company_id: ctx.companyId,
|
||
supplier_id: body.supplier_id,
|
||
arrival_number: arrivalNum,
|
||
supplier_invoice_number: body.supplier_invoice_number,
|
||
invoice_date: body.invoice_date,
|
||
due_date: body.due_date,
|
||
delivery_date: body.delivery_date || null,
|
||
status: 'registered',
|
||
currency: body.currency || 'SEK',
|
||
exchange_rate: exchangeRate,
|
||
vat_treatment: body.vat_treatment || 'standard_25',
|
||
reverse_charge: body.reverse_charge || false,
|
||
payment_reference: body.payment_reference || null,
|
||
subtotal: Math.round(subtotal * 100) / 100,
|
||
subtotal_sek: subtotalSek,
|
||
vat_amount: Math.round(totalVat * 100) / 100,
|
||
vat_amount_sek: vatAmountSek,
|
||
total: Math.round(total * 100) / 100,
|
||
total_sek: totalSek,
|
||
remaining_amount: Math.round(total * 100) / 100,
|
||
document_id: item.document_id || null,
|
||
notes: body.notes || null,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (invoiceError || !invoice) {
|
||
// A unique-index hit on (company_id, supplier_id,
|
||
// supplier_invoice_number) is a recoverable conflict — the user
|
||
// already registered this invoice (often manually, then tried to
|
||
// convert the same inbox document). Mirror the main
|
||
// /api/supplier-invoices route and return a friendly 409 with the
|
||
// existing invoice, instead of letting the raw Postgres message
|
||
// surface as a generic 500 ("Ett oväntat serverfel uppstod").
|
||
const pgErr = invoiceError as { code?: string; message?: string } | null
|
||
const isDuplicateNumber =
|
||
pgErr?.code === '23505' &&
|
||
(pgErr.message || '').includes('idx_supplier_invoices_company_supplier_number')
|
||
|
||
if (isDuplicateNumber) {
|
||
// Tenancy: ctx.supabase is the cookie-scoped RLS client and the
|
||
// supplier_invoices SELECT policy is
|
||
// `company_id IN (SELECT user_company_ids())`. Combined with the
|
||
// explicit company_id filter below, this lookup can only ever
|
||
// resolve an invoice the caller's own company owns — the returned
|
||
// details are never cross-tenant (OWASP ASVS V8.2.1; ISO 27001
|
||
// A.8.3; GDPR art.25(2)).
|
||
const { data: existing } = await ctx.supabase
|
||
.from('supplier_invoices')
|
||
.select('id, supplier_invoice_number, status')
|
||
.eq('company_id', ctx.companyId)
|
||
.eq('supplier_id', body.supplier_id)
|
||
.eq('supplier_invoice_number', body.supplier_invoice_number)
|
||
.maybeSingle()
|
||
|
||
let creditNoteId: string | null = null
|
||
if (existing?.status === 'credited') {
|
||
const { data: creditNote } = await ctx.supabase
|
||
.from('supplier_invoices')
|
||
.select('id')
|
||
.eq('company_id', ctx.companyId)
|
||
.eq('credited_invoice_id', existing.id)
|
||
.eq('is_credit_note', true)
|
||
.maybeSingle()
|
||
creditNoteId = creditNote?.id ?? null
|
||
}
|
||
|
||
// Return ONLY server-authoritative fields the recovery dialog needs
|
||
// (the existing row, read under RLS). The raw request body
|
||
// (supplier_id / supplier_invoice_number) is deliberately not
|
||
// echoed back: the client already holds it from its own form state,
|
||
// and reflecting user-supplied values widens the response surface
|
||
// for no benefit (GDPR art.5(1)(c) data minimisation; OWASP ASVS
|
||
// V4.5). The Postgres constraint name is used only to classify the
|
||
// error above and is never placed in the response.
|
||
return errorResponseFromCode('SI_CREATE_DUPLICATE_INVOICE_NUMBER', ctx.log, {
|
||
details: {
|
||
existing: existing
|
||
? {
|
||
id: existing.id,
|
||
supplier_invoice_number: existing.supplier_invoice_number,
|
||
status: existing.status,
|
||
credit_note_id: creditNoteId,
|
||
}
|
||
: null,
|
||
},
|
||
})
|
||
}
|
||
|
||
return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
|
||
}
|
||
|
||
const itemInserts = items.map((lineItem) => ({
|
||
supplier_invoice_id: invoice.id,
|
||
...lineItem,
|
||
}))
|
||
|
||
const { data: insertedItems, error: itemsError } = await ctx.supabase
|
||
.from('supplier_invoice_items')
|
||
.insert(itemInserts)
|
||
.select('id, sort_order')
|
||
|
||
if (itemsError) {
|
||
await ctx.supabase.from('supplier_invoices').delete().eq('id', invoice.id)
|
||
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
||
}
|
||
|
||
const { data: settings } = await ctx.supabase
|
||
.from('company_settings')
|
||
.select('accounting_method')
|
||
.eq('company_id', ctx.companyId)
|
||
.single()
|
||
|
||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||
let registrationJournalEntryId: string | null = null
|
||
|
||
if (accountingMethod === 'accrual') {
|
||
try {
|
||
const journalEntry = await createSupplierInvoiceRegistrationEntry(
|
||
ctx.supabase,
|
||
ctx.companyId,
|
||
ctx.userId,
|
||
invoice as SupplierInvoice,
|
||
items as SupplierInvoiceItem[],
|
||
supplier.supplier_type,
|
||
supplier.name
|
||
)
|
||
if (journalEntry) {
|
||
registrationJournalEntryId = journalEntry.id
|
||
;(invoice as SupplierInvoice).registration_journal_entry_id = journalEntry.id
|
||
await ctx.supabase
|
||
.from('supplier_invoices')
|
||
.update({ registration_journal_entry_id: journalEntry.id })
|
||
.eq('id', invoice.id)
|
||
|
||
if (item.document_id) {
|
||
await ctx.supabase
|
||
.from('document_attachments')
|
||
.update({ journal_entry_id: journalEntry.id })
|
||
.eq('id', item.document_id)
|
||
.eq('company_id', ctx.companyId)
|
||
}
|
||
|
||
if (hasAccrualItems) {
|
||
// Schedules + catch-up dissolutions for deferred lines. Never
|
||
// fatal — the registration entry is committed; failures are
|
||
// retried/surfaced via the periodiseringar page.
|
||
const idBySortOrder = new Map(
|
||
((insertedItems ?? []) as Array<{ id: string; sort_order: number }>).map(
|
||
(row) => [row.sort_order, row.id],
|
||
),
|
||
)
|
||
const itemsWithIds = items.map((lineItem) => ({
|
||
...lineItem,
|
||
id: idBySortOrder.get(lineItem.sort_order) ?? null,
|
||
}))
|
||
const scheduleResult = await createSchedulesForSupplierInvoice(
|
||
ctx.supabase,
|
||
ctx.companyId,
|
||
ctx.userId,
|
||
invoice as SupplierInvoice,
|
||
itemsWithIds as unknown as SupplierInvoiceItem[],
|
||
journalEntry.id,
|
||
)
|
||
if (scheduleResult.failed > 0) {
|
||
ctx.log.error('accrual schedule creation failed on inbox convert', {
|
||
supplierInvoiceId: invoice.id,
|
||
failed: scheduleResult.failed,
|
||
})
|
||
}
|
||
}
|
||
} else {
|
||
// createSupplierInvoiceRegistrationEntry returns null ONLY when no
|
||
// fiscal period covers invoice_date (every other failure throws).
|
||
// Roll back so we never mark the inbox item converted against an
|
||
// unbooked supplier invoice (orphan understating 2440/2641).
|
||
await ctx.supabase
|
||
.from('supplier_invoices')
|
||
.delete()
|
||
.eq('id', invoice.id)
|
||
.eq('company_id', ctx.companyId)
|
||
return errorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', ctx.log, {
|
||
details: { invoiceDate: (invoice as SupplierInvoice).invoice_date },
|
||
})
|
||
}
|
||
} catch (err) {
|
||
// Engine threw (period lock, unbalanced entry, etc.) instead of
|
||
// cleanly returning null. Roll back the supplier invoice so the inbox
|
||
// item is never marked converted against an unbooked invoice (an orphan
|
||
// understating 2440/2641), then surface the error — mirroring the main
|
||
// /api/supplier-invoices route's registration catch.
|
||
await ctx.supabase
|
||
.from('supplier_invoices')
|
||
.delete()
|
||
.eq('id', invoice.id)
|
||
.eq('company_id', ctx.companyId)
|
||
const typed = bookkeepingErrorResponse(err)
|
||
if (typed) return typed
|
||
return errorResponseFromCode('SI_CREATE_FAILED', ctx.log, {
|
||
details: {
|
||
reason: err instanceof Error ? err.message : 'unknown',
|
||
step: 'registration_journal_entry',
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
try {
|
||
await ctx.emit({
|
||
type: 'supplier_invoice.registered',
|
||
payload: { supplierInvoice: invoice as SupplierInvoice, companyId: ctx.companyId, userId: ctx.userId },
|
||
})
|
||
} catch { /* non-blocking */ }
|
||
|
||
await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.update({ created_supplier_invoice_id: invoice.id })
|
||
.eq('id', id)
|
||
|
||
try {
|
||
await ctx.emit({
|
||
type: 'supplier_invoice.confirmed',
|
||
payload: {
|
||
inboxItem: { ...item, created_supplier_invoice_id: invoice.id } as InvoiceInboxItem,
|
||
supplierInvoice: invoice as SupplierInvoice,
|
||
userId: ctx.userId,
|
||
companyId: ctx.companyId,
|
||
},
|
||
})
|
||
} catch { /* non-blocking */ }
|
||
|
||
return NextResponse.json({
|
||
data: {
|
||
...invoice,
|
||
items: itemInserts,
|
||
registration_journal_entry_id: registrationJournalEntryId,
|
||
inbox_item_id: id,
|
||
},
|
||
})
|
||
},
|
||
},
|
||
|
||
// ── Book inbox item directly as a manual journal entry ─
|
||
// For kontantmetoden users (and ad-hoc receipts) — bypasses the
|
||
// supplier-invoice flow entirely. Optionally links to a bank
|
||
// transaction; otherwise produces a standalone verifikation
|
||
// (e.g. private outlay, cash receipt). The source document is
|
||
// attached to the new entry per BFL 5 kap. 6§.
|
||
{
|
||
method: 'POST',
|
||
path: '/items/:id/book-direct',
|
||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
|
||
const url = new URL(request.url)
|
||
const id = url.searchParams.get('_id')
|
||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||
|
||
let body: z.infer<typeof BookInboxItemDirectlySchema>
|
||
try {
|
||
const json = await request.json()
|
||
body = BookInboxItemDirectlySchema.parse(json)
|
||
} catch (err) {
|
||
return NextResponse.json(
|
||
{ error: err instanceof Error ? err.message : 'Invalid request body' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
const { data: item, error: fetchError } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.select('id, document_id, status, created_supplier_invoice_id, created_journal_entry_id, matched_transaction_id, correlation_id')
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
|
||
if (fetchError) {
|
||
// Surface the real DB error instead of masking as 404. Common cause:
|
||
// the migration adding `created_journal_entry_id` hasn't been
|
||
// applied to this database (e.g. local dev DB lagging staging).
|
||
console.error('[invoice-inbox/book-direct] Item lookup failed:', fetchError)
|
||
return NextResponse.json(
|
||
{ error: `Kunde inte slå upp posten: ${fetchError.message}` },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
if (!item) {
|
||
return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
|
||
}
|
||
if (item.created_supplier_invoice_id) {
|
||
return NextResponse.json(
|
||
{ error: 'Posten är redan kopplad till en leverantörsfaktura.' },
|
||
{ status: 409 }
|
||
)
|
||
}
|
||
if (item.created_journal_entry_id) {
|
||
return NextResponse.json(
|
||
{ error: 'Posten är redan bokförd.' },
|
||
{ status: 409 }
|
||
)
|
||
}
|
||
|
||
// If a transaction is provided, validate it before booking.
|
||
let transaction: { id: string; journal_entry_id: string | null } | null = null
|
||
if (body.transaction_id) {
|
||
const { data: tx, error: txError } = await ctx.supabase
|
||
.from('transactions')
|
||
.select('id, journal_entry_id')
|
||
.eq('id', body.transaction_id)
|
||
.eq('company_id', ctx.companyId)
|
||
.maybeSingle()
|
||
if (txError || !tx) {
|
||
return NextResponse.json({ error: 'Transaktion hittades inte' }, { status: 404 })
|
||
}
|
||
if (tx.journal_entry_id) {
|
||
return NextResponse.json(
|
||
{ error: 'Transaktionen är redan bokförd' },
|
||
{ status: 409 }
|
||
)
|
||
}
|
||
transaction = tx
|
||
}
|
||
|
||
// Create the journal entry via the engine. Source-tracks back to
|
||
// the inbox item so the audit trail is preserved even when no
|
||
// transaction is involved.
|
||
let journalEntry
|
||
try {
|
||
journalEntry = await createJournalEntry(ctx.supabase, ctx.companyId, ctx.userId, {
|
||
fiscal_period_id: body.fiscal_period_id,
|
||
entry_date: body.entry_date,
|
||
description: body.description,
|
||
source_type: transaction ? 'bank_transaction' : 'inbox_item',
|
||
source_id: transaction ? transaction.id : item.id,
|
||
notes: body.notes,
|
||
lines: body.lines,
|
||
})
|
||
} catch (err) {
|
||
const typed = bookkeepingErrorResponse(err)
|
||
if (typed) return typed
|
||
return NextResponse.json(
|
||
{ error: err instanceof Error ? err.message : 'Kunde inte skapa verifikation' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// Link the source document to the new entry. Best-effort — the
|
||
// entry itself is already posted; surfacing the failure shouldn't
|
||
// roll it back, but log so support can re-link manually.
|
||
if (item.document_id) {
|
||
try {
|
||
await linkToJournalEntry(
|
||
ctx.supabase,
|
||
ctx.companyId,
|
||
item.document_id,
|
||
journalEntry.id
|
||
)
|
||
} catch (err) {
|
||
console.error('[invoice-inbox/book-direct] Document link failed:', err)
|
||
}
|
||
}
|
||
|
||
// If transaction-linked, mark the transaction as booked.
|
||
if (transaction) {
|
||
const { error: txUpdateError } = await ctx.supabase
|
||
.from('transactions')
|
||
.update({
|
||
journal_entry_id: journalEntry.id,
|
||
is_business: true,
|
||
category: 'uncategorized',
|
||
})
|
||
.eq('id', transaction.id)
|
||
.eq('company_id', ctx.companyId)
|
||
if (txUpdateError) {
|
||
console.error('[invoice-inbox/book-direct] Transaction link failed:', txUpdateError)
|
||
}
|
||
}
|
||
|
||
// Mark the inbox item as resolved by writing the FK. The status
|
||
// column is intentionally left at 'received' — terminal state is
|
||
// encoded via created_journal_entry_id / matched_transaction_id
|
||
// (see migration 20260504180000_invoice_inbox_remove_ai_columns).
|
||
const { error: updateError } = await ctx.supabase
|
||
.from('invoice_inbox_items')
|
||
.update({
|
||
created_journal_entry_id: journalEntry.id,
|
||
matched_transaction_id: transaction?.id ?? null,
|
||
})
|
||
.eq('id', id)
|
||
.eq('company_id', ctx.companyId)
|
||
if (updateError) {
|
||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||
}
|
||
|
||
// The engine already emits journal_entry.committed — no need to
|
||
// re-emit. Transaction categorization is implicit: the entry is
|
||
// already source-linked to the transaction via source_type.
|
||
|
||
return NextResponse.json({
|
||
data: {
|
||
journal_entry: journalEntry,
|
||
inbox_item_id: id,
|
||
transaction_id: transaction?.id ?? null,
|
||
},
|
||
})
|
||
},
|
||
},
|
||
],
|
||
}
|
||
|
||
// Re-export the extraction shape for tests / consumers.
|
||
export type { InvoiceExtractionResult }
|