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 { createServiceClient } from '@/lib/supabase/server' import { extractInvoiceFields, ExtractionSchema, emptyResult } from './lib/extract-invoice-fields' import { uploadAndExtract, sanitiseFilename, sanitiseMime, isSandboxCompany, countPdfPages, slicePdfForExtraction, MAX_FILE_SIZE, MAX_PAGES_FOR_AUTO_EXTRACT, UPLOAD_ALLOWED_MIME_TYPES, } from './lib/upload-and-extract' import { verifyInboundWebhook, fetchReceivingEmail, fetchInboundAttachment, extractLocalPartForDomain, parseRecipients, isEmailReceivedEvent, ResendSignatureError, } from './lib/resend-inbound' import { rotateCompanyInbox, getActiveInbox, composeInboxAddress, } from './lib/inbox-provisioning' import { claimCustomDomain, checkCustomDomainVerification, removeCustomDomain, getCustomDomain, findCompanyForRecipientDomains, applyDomainStatusFromWebhook, } from './lib/custom-domains' 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 { resolveSupplierInvoiceExchangeRate, supplierInvoiceSekAmounts, } from '@/lib/currency/supplier-invoice-rate' import { roundOre } from '@/lib/money' import { linkToJournalEntry } from '@/lib/core/documents/document-service' import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes' import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema, BulkBookInboxSchema } from '@/lib/api/schemas' import { bulkBookMatchedInboxItems } from '@/lib/transactions/categorize-core' import { hasCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability' import { CAPABILITY } from '@/lib/entitlements/keys' import { appendProcessingHistory } from '@/lib/processing-history/append' import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox' import { simpleParser } from 'mailparser' import type { InboxChannelContext, InvoiceExtractionResult, InvoiceInboxItem, SupplierInvoice, SupplierInvoiceItem } from '@/types' const MAX_ATTACHMENTS_PER_EMAIL = 20 // 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(), }) // Claim body for POST /inbox/domain. Length-capped only: real validation // (punycode, hostname shape, blocklist) lives in normalizeInboundDomain / // validateClaimableDomain so the same rules apply to every caller. const ClaimDomainSchema = z.object({ domain: z.string().trim().min(1).max(255), }) // Custom inbound domains are fully built but deliberately not exposed, // product decision 2026-07-02: the default is the Fortnox-style shared // address (+ user-side forwarding); own-domain inbound waits for real demand. // Flip INBOX_CUSTOM_DOMAINS_ENABLED=true to re-enable the /inbox/domain // routes. The globe entry point in InvoiceInboxWorkspace was removed at the // same time. Restore it when re-enabling. const customDomainsEnabled = () => process.env.INBOX_CUSTOM_DOMAINS_ENABLED === 'true' const customDomainsDisabledResponse = () => NextResponse.json( { error: 'Egen domän är inte tillgänglig.', code: 'FEATURE_DISABLED' }, { status: 403 } ) // ── Admin/owner check helper ────────────────────────────────── async function isCompanyAdmin( supabase: import('@supabase/supabase-js').SupabaseClient, userId: string, companyId: string ): Promise { 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, email_body_text, error_message, created_supplier_invoice_id, matched_transaction_id, created_journal_entry_id, resend_email_id, extraction_skipped, channel_context `) .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 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 }) .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, 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 handling as /upload (issue #553): long PDFs extract // from a slice of their first pages; the skip only remains for // unsliceable (encrypted/malformed) PDFs. 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) // Paid-tier gate: no `ai` capability → no Bedrock OCR (seed empty // skeleton; the attached document is still stored). Same paywall as // the shared upload path above. const hasAiEntitlement = await hasCapability(ctx.supabase, ctx.companyId, CAPABILITY.ai) const slicedBuffer = gatedByPageCount && hasAiEntitlement && !sandbox ? await slicePdfForExtraction(buffer, MAX_PAGES_FOR_AUTO_EXTRACT) : null const skipReason: 'no_ai_entitlement' | 'too_many_pages' | 'sandbox' | null = !hasAiEntitlement ? 'no_ai_entitlement' : sandbox ? 'sandbox' : gatedByPageCount && slicedBuffer == null ? 'too_many_pages' : null const skipExtraction = skipReason !== null const { data: extracted } = skipExtraction ? { data: emptyResult() } : await extractInvoiceFields({ buffer: Buffer.from(slicedBuffer ?? buffer), mimeType: file.type, fileName: file.name, }) if (!skipExtraction && slicedBuffer != null && pageCount != null) { extracted.pages = { total: pageCount, analyzed: MAX_PAGES_FOR_AUTO_EXTRACT } } const { error: linkError } = await ctx.supabase .from('invoice_inbox_items') .update({ document_id: doc.id, extracted_data: extracted as unknown as Record, 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 }, ) } // Paid-tier gate: retry is an explicit "run AI OCR now" action, so a // company without the `ai` capability is hard-blocked (403) rather than // silently emptied: there is nothing to retry without the entitlement. if (!(await hasCapability(ctx.supabase, ctx.companyId, CAPABILITY.ai))) { return capabilityBlockedResponse(CAPABILITY.ai) } 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 }) } // Download via the service-role client: the storage SELECT policy // only covers the uploader's own folder, and inbox documents are // attributed to the company creator, so ctx.supabase (user-bound) // cannot read them for other members. The company-scoped row fetch // above is the authorization. const { data: blob, error: dlError } = await createServiceClient().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, // 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 } ) } }, }, // ── Custom inbound domain: read current state ──────────── { method: 'GET', path: '/inbox/domain', handler: async (_request: Request, ctx?: ExtensionContext) => { if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!customDomainsEnabled()) return customDomainsDisabledResponse() try { const row = await getCustomDomain(ctx.supabase, ctx.companyId) // null when the company has no custom domain: the UI renders the // claim form in that case. return NextResponse.json({ data: row }) } catch (err) { return NextResponse.json( { error: err instanceof Error ? err.message : 'Failed to load domain' }, { status: 500 } ) } }, }, // ── Custom inbound domain: claim (admin/owner only) ────── { method: 'POST', path: '/inbox/domain', handler: async (request: Request, ctx?: ExtensionContext) => { if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!customDomainsEnabled()) return customDomainsDisabledResponse() const isAdmin = await isCompanyAdmin(ctx.supabase, ctx.userId, ctx.companyId) if (!isAdmin) return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 }) // Sandbox companies are anonymous 24h demo accounts: letting them // register domains in our Resend account is a pure abuse vector. if (await isSandboxCompany(ctx.supabase, ctx.companyId)) { return NextResponse.json( { error: 'Egen domän är inte tillgänglig i sandlådan.' }, { status: 403 } ) } // Claiming hits the Resend domains API: share the per-company inbox // quota so a claim/delete loop can't burn the provider budget. const limit = await checkInboxUploadRateLimit(ctx.supabase, ctx.companyId) if (!limit.ok) { return NextResponse.json( { error: 'För många förfrågningar, försök igen om en stund.', retry_after: limit.retryAfterSec }, { status: 429, headers: { 'Retry-After': String(limit.retryAfterSec ?? 60) } }, ) } let body: z.infer try { body = ClaimDomainSchema.parse(await request.json()) } catch (err) { return NextResponse.json( { error: err instanceof Error ? err.message : 'Invalid request body' }, { status: 400 } ) } const result = await claimCustomDomain(ctx.supabase, ctx.companyId, body.domain) if (!result.ok) { return NextResponse.json({ error: result.error }, { status: result.status }) } return NextResponse.json({ data: result.data }) }, }, // ── Custom inbound domain: re-check verification ───────── { method: 'POST', path: '/inbox/domain/verify', handler: async (_request: Request, ctx?: ExtensionContext) => { if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!customDomainsEnabled()) return customDomainsDisabledResponse() const isAdmin = await isCompanyAdmin(ctx.supabase, ctx.userId, ctx.companyId) if (!isAdmin) return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 }) // verify() triggers a DNS check at Resend: rate-limit the button. const limit = await checkInboxUploadRateLimit(ctx.supabase, ctx.companyId) if (!limit.ok) { return NextResponse.json( { error: 'För många kontroller, försök igen om en stund.', retry_after: limit.retryAfterSec }, { status: 429, headers: { 'Retry-After': String(limit.retryAfterSec ?? 60) } }, ) } const result = await checkCustomDomainVerification(ctx.supabase, ctx.companyId) if (!result.ok) { return NextResponse.json({ error: result.error }, { status: result.status }) } return NextResponse.json({ data: result.data }) }, }, // ── Custom inbound domain: remove (admin/owner only) ───── { method: 'DELETE', path: '/inbox/domain', handler: async (_request: Request, ctx?: ExtensionContext) => { if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!customDomainsEnabled()) return customDomainsDisabledResponse() const isAdmin = await isCompanyAdmin(ctx.supabase, ctx.userId, ctx.companyId) if (!isAdmin) return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 }) const result = await removeCustomDomain(ctx.supabase, ctx.companyId) if (!result.ok) { return NextResponse.json({ error: result.error }, { status: result.status }) } return NextResponse.json({ data: result.data }) }, }, // ── 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 }) } // Resend pushes domain.* lifecycle events to the same webhook. Apply // domain.updated to custom-domain rows so verification flips without // the user pressing "Kontrollera igen" (requires the event type to be // subscribed on the Resend webhook; harmless when it isn't). if (event.type === 'domain.updated') { const domainServiceSupabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ) const matched = await applyDomainStatusFromWebhook(domainServiceSupabase, { id: event.data.id, status: event.data.status, records: event.data.records, }) return NextResponse.json({ data: { domain_updated: matched } }) } 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 serviceSupabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ) // Recipient → company resolution. Shared-domain addresses first // (existing local_part flow), then per-company verified custom // domains. Custom domains are catch-all by design: MX routing is // per-domain, and a supplier typing fakturor@ instead of faktura@ // must land in the inbox rather than silently vanish (Resend has // already accepted the message; there is no bounce path). let companyId: string | null = null let sharedInboxStatus: string | null = null const localPart = extractLocalPartForDomain(to, domain) if (localPart) { const { data: inbox } = await serviceSupabase .from('company_inboxes') .select('id, company_id, status') .eq('local_part', localPart) .maybeSingle() if (inbox) { sharedInboxStatus = inbox.status if (inbox.status === 'active') companyId = inbox.company_id } } if (!companyId) { const customDomains = parseRecipients(to) .map((r) => r.domain) .filter((d) => d !== domain.toLowerCase()) if (customDomains.length > 0) { const match = await findCompanyForRecipientDomains(serviceSupabase, customDomains) if (match) companyId = match.companyId } } if (!companyId) { // Preserve the pre-custom-domain status semantics: 410 for a // deprecated/blocked shared address, 404 otherwise. if (sharedInboxStatus && sharedInboxStatus !== 'active') { return NextResponse.json({ error: 'Address no longer active' }, { status: 410 }) } console.warn('[invoice-inbox/inbound] No recipient matched', { to, domain }) return NextResponse.json( { error: localPart ? 'Address not found' : 'No matching recipient' }, { status: 404 } ) } const { data: company } = await serviceSupabase .from('companies') .select('created_by') .eq('id', companyId) .single() if (!company?.created_by) { console.error('[invoice-inbox/inbound] Company has no created_by', companyId) 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, companyId) if (!limit.ok) { try { await appendProcessingHistory({ companyId, 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, 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: companyId, 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: companyId, 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, companyId, { 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, companyId, { 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 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)' }, }) } } // Same currency policy as POST /api/supplier-invoices and the v1 REST // route (lib/currency/supplier-invoice-rate.ts): a non-SEK invoice with // no caller-supplied rate gets one fetched from Riksbanken for the // invoice date, and an unresolvable rate refuses the conversion instead // of writing exchange_rate = NULL. That matters most here: inbox items // are AI-extracted, the currency comes off the PDF and the rate never // does, so this path produced unconverted rows most readily. Resolved // before the arrival number so a refusal burns no ankomstnummer. const fx = await resolveSupplierInvoiceExchangeRate(ctx.supabase, { currency: body.currency, invoiceDate: body.invoice_date, suppliedRate: body.exchange_rate, }) if (!fx.ok) { return errorResponseFromCode('SI_FX_RATE_MISSING', ctx.log, { details: { currency: fx.currency, invoice_date: fx.invoiceDate }, }) } 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) // roundOre, not the naive form: `total` and `total_sek` must round // identically or a SEK invoice ends up one öre apart. const total = roundOre(subtotal + totalVat) // SEK resolves to rate 1, so total_sek === total rather than NULL. const { subtotal_sek: subtotalSek, vat_amount_sek: vatAmountSek, total_sek: totalSek, } = supplierInvoiceSekAmounts(fx.rate, { subtotal, vatAmount: totalVat, total }) 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: fx.rate.currency, exchange_rate: fx.rate.exchangeRate, // Which day's kurs the SEK amounts were translated at: the audit // trail that makes them verifiable (BFL 5 kap). exchange_rate_date: fx.rate.exchangeRateDate, vat_treatment: body.vat_treatment || 'standard_25', reverse_charge: body.reverse_charge || false, payment_reference: body.payment_reference || null, subtotal: roundOre(subtotal), subtotal_sek: subtotalSek, vat_amount: roundOre(totalVat), vat_amount_sek: vatAmountSek, total, total_sek: totalSek, remaining_amount: total, document_id: item.document_id || null, // WhatsApp-sourced items: when the request carries NO notes field // at all, default to the rendered chat context (representation // deltagare + syfte, sender note) so the human answers from the // chat reach the leverantörsfaktura. Presence decides, not // truthiness: `notes: ""` is an explicit clear and stays empty // (same rule as book-direct, where the value lands on an // immutable verifikat). The caption is excluded: this form never // shows the chat context, so nobody reviewed it. notes: body.notes === undefined ? renderChannelContextNotes( (item as { channel_context?: InboxChannelContext | null }).channel_context, ) : body.notes.trim() || 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 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, channel_context') .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 } // WhatsApp-sourced items: when the request carries NO notes field at // all, default to the rendered chat context (representation deltagare // + syfte, sender note) so the audit text reaches the verifikat even // through clients that never saw the chat (MCP, older UI). // // Presence decides, not truthiness: `notes: ""` is the UI saying the // user emptied the field, and resurrecting the prefill there would // write text onto an immutable verifikat against an explicit user // action (removable only via rättelse). So an empty string clears, // and only an absent field defaults. The caption is excluded: this // path can run without a human ever seeing the string. const effectiveNotes = body.notes === undefined ? renderChannelContextNotes( (item as { channel_context?: InboxChannelContext | null }).channel_context, ) ?? undefined : body.notes.trim() || undefined // 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: effectiveNotes, 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, }, }) }, }, // ── Bulk-book selected inbox items (Modell B) ───────────── // "Bokför valda" in the Underlag selection bar. Each selected item is // booked against its matched bank transaction (which already carries the // SEK amount) using one shared category + VAT treatment: individual // verifikat, not a samlingsverifikation. Unmatched / already-booked / // supplier-invoice-linked items are skipped, not errored, so the batch is // resilient. Reuses the same categorize core as the single-item agent flow, // so reverse-charge moms on foreign services is handled correctly. { method: 'POST', path: '/items/bulk-book', handler: async (request: Request, ctx?: ExtensionContext) => { if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) let body: z.infer try { const json = await request.json() body = BulkBookInboxSchema.parse(json) } catch (err) { return NextResponse.json( { error: err instanceof Error ? err.message : 'Invalid request body' }, { status: 400 } ) } const { booked, skipped } = await bulkBookMatchedInboxItems( ctx.supabase, ctx.userId, ctx.companyId, body, ) return NextResponse.json({ data: { booked_count: booked.length, skipped_count: skipped.length, booked, skipped, }, }) }, }, ], } // Re-export the extraction shape for tests / consumers. export type { InvoiceExtractionResult }