Files
accounted/extensions/general/invoice-inbox/index.ts
T
Jakob WennbergandClaude Opus 4.7 39204cc0de UX polish bundle: Enable Banking lookback + sync progress, invoice inbox, matching previews (#548)
* fix(import): dedup opening-balance rows when account numbers differ only in whitespace

The parser's merge map keyed on the post-strip account_number, but rows like
"1930", " 1930 " and "1.930" could leak as separate entries when the
upstream string contained non-breaking spaces or zero-width chars that the
old .replace(/[^0-9]/g, '') ran on already-stripped output. Strip those
explicitly in the raw string and use /\D/g for the digit extraction.

Also adds defense-in-depth dedup inside OpeningBalanceEditStep so any
duplicates that survive the parser collapse before the user sees them.

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

* feat(enable-banking): anchor lookback picker to fiscal year, not days

Replaces the 90/180/365 days dropdown on the account-selection screen
with three explicit modes:

- "Senaste 90 dagar (snabbt)" — fastest path, matches PSD2 ceiling
- "Sedan räkenskapsårets början" (default) — resolves via
  fiscal_year_start_month, surfaces the literal date inline
- "Anpassat datum" — free date picker OR "Föregående räkenskapsårets start"

When the resulting range exceeds 90 days, the picker now surfaces a
quiet helper that points users at the SIE/bankfil import for older
history, so they don't waste an account-selection round-trip discovering
that banks usually cap at ~90 days.

The PATCH /accounts handler accepts initial_lookback_from_date alongside
initial_lookback_days; the new helper getCurrentFiscalYearStart() in
lib/company/fiscal-year.ts is reused.

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

* feat(enable-banking): dedicated sync progress modal replaces silent spinner

After the user confirms account selection, transactions fetch in the
background for 30–60 seconds. Previously this showed only the Spara-button
spinner with no indication of duration or what was happening — users
described being stuck on the page.

The new BankSyncProgressDialog opens immediately on Save, lists the enabled
accounts being synced, and disables manual close until the PATCH resolves.
On completion it shows the imported count and the actual date range the
bank returned, plus an amber escape hatch to SIE/bankfil import when the
returned range was truncated by >7 days from what was requested.

Failure path surfaces in the same modal rather than as a destructive toast
that disappears.

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

* refactor(invoice-inbox): drop duplicate Skapa leverantör button

The inbox detail panel had its own supplier-creation button that fired
/api/suppliers + match-supplier. The same action is reachable from the
supplier-invoice form's "Skapa & välj" card (showAISupplierHint), which
also prefills more fields. The duplicate button is gone; a quiet inline
hint replaces it so the user still knows why no supplier matched.

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

* ui(invoice-inbox): surface currency and totals above the long metadata tail

Move Valuta / Totalt / Moms in FIELD_DEFS so they sit immediately under
Leverantör / Org.nr / VAT-nr. These are the fields the user reads first
when triaging an inbox item; burying them after nine metadata fields
forces unnecessary scrolling on every single invoice.

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

* feat(invoice-inbox): accept .eml forwards and log rejected attachments

Gmail's "Forward as attachment" packages the original email as message/rfc822,
which our MIME allowlist silently dropped. Adds mailparser so we can unwrap
the inner attachments and ingest them under the inner email's subject/from.

Also persists every rejected attachment as an invoice_inbox_items row with
status='error', so users can see what was dropped instead of guessing why
nothing showed up in their inbox.

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

* fix(supplier-invoices): redirect back to inbox after creating from invoice-inbox

When the leverantörsfaktura form is opened from an invoice-inbox item, every
successful create previously kicked the user out to /supplier-invoices or the
just-created invoice's detail page — derailing the "process the next
document" workflow. The Tillbaka button likewise routed to the
supplier-invoice list rather than the inbox they came from.

Adds an afterCreate helper that lands inbox-originated submissions at
/e/general/invoice-inbox and preserves the original target everywhere else.

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

* ui(pending): show transaction/document context for match-and-attach reviews

The granskning page previously rendered attach_document_to_transaction and
match_transaction_invoice operations through the generic key/value preview,
so reviewers saw "document file name: Faktura.pdf / transaction amount:
-216 USD" without any visual indication of which two things were being
paired. The MCP tool already returns enriched preview data; we just
needed dedicated layouts.

Adds:
- AttachDocumentPreview — two-card layout (Transaktion | Dokument) with
  a "Visa dokument" button that fetches a signed download URL on demand
- MatchTransactionInvoicePreview — same layout (Transaktion | Faktura)
- DocumentViewButton — reusable signed-URL opener

Also tightens the matching tools' descriptions so AI clients are nudged
to verify human-readable context before staging.

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

* fix(review): address PR #548 review feedback

- invoice-inbox: hoist mailparser to a static import. The extension system
  generates a static import tree via setup:extensions and disallows dynamic
  imports — await import('mailparser') worked in dev but could fail in
  production standalone builds.
- enable-banking AccountPickerDialog: guard the Save path when "Anpassat
  datum" + "Specifikt datum" is selected with an empty date. Without this,
  lookback.body resolves to null and the PATCH silently falls back to the
  backend's 120-day default, ignoring the user's intent.
- enable-banking BankSyncProgressDialog: drop the empty-body useEffect.
  Close-prevention is already handled inline via the onOpenChange guard +
  onPointerDownOutside + onEscapeKeyDown handlers.
- lib/company/fiscal-year: pin both operands of daysBetween() to UTC when
  parsing ISO date strings. Mixing a UTC-parsed date with new Date() (local
  time) drifts by one day in any timezone east of UTC.

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

* fix(review): address compliance swarm + Swedish review feedback

Three actionable items from the post-fix compliance scan; the rest were
false positives or out of scope.

- enable-banking PATCH /accounts: reject future initial_lookback_from_date
  with 400 instead of silently falling through to the 120-day default.
  Compliance V2.2.
- AttachDocumentPreview: promote the overwrite warning to a destructive
  banner with BFL 7 kap context when the existing document is marked as
  räkenskapsinformation. A muted footnote was too easy to skip past for
  a verifikationsunderlag replacement.
- MatchTransactionInvoicePreview: surface transaction_date + invoice_date
  in the staged preview so reviewers can spot date drift before approving
  (BFL 5 kap 6§ — verifikation date must align with affärshändelse). Also
  shows a quiet hint when the two dates differ by > 31 days. Tool's SELECT
  + stage payload extended accordingly.

Skipped (with rationale):
- V5.3 inner.filename path traversal — lib/core/documents/document-service.ts
  already sanitizes filenames before constructing storage paths.
- V5.2 magic-number MIME — pre-existing pattern for all email attachments;
  scope is codebase-wide.
- V1.2 att.id composite ID — only used as a DB column value, never a path.
- V13.1 / CM-8 SBOM/SCA — repository-wide policy, not this PR.

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

* fix(review): address second-round compliance + Swedish review feedback

Compliance Swarm (defense-in-depth + valid finds):
- invoice-inbox: sanitise .eml inner attachment filenames and content-types
  before they flow into uploadAndExtract or the raw_email_payload JSONB.
  document-service already strips bad chars before constructing storage
  paths, but the swarm flagged the upstream input as unsanitised — easier
  to add a thin sanitiseFilename/sanitiseMime layer than to argue about
  defense-in-depth. Caps lengths too.
- DocumentViewButton: validate documentId as a UUID before interpolating
  into /api/documents/:id — staged preview_data is Record<string, unknown>
  on the wire, so refusing junk early gives a clearer error and keeps the
  internal API from seeing oddly-shaped path segments. (Compliance V1.2.)

Swedish review:
- MatchTransactionInvoicePreview: drop the BFL 5 kap 6§ citation from the
  date-drift hint — that section governs verifikationsinnehåll, not a 31-day
  tolerance. The hint stays (the practical concern is real) but no longer
  pretends to quote a legislated threshold.
- fiscal-year: document the implicit assumption that entity_type reflects
  the company's current tax-year status, not a mid-conversion state.

Skipped (with rationale):
- V5.2 magic-number MIME — pre-existing pattern across all email attachments.
- A.8.12 signed URL via window.open — pre-existing pattern shared with
  JournalEntryAttachments.tsx; refactor to server-side redirect is broader scope.
- A.8.15 logRejection failure path — pre-existing console.error pattern.
- CC9.2 mailparser vendor review / SBOM — out of PR scope.
- CC6.1 IDOR — /api/documents/:id already enforces company_id; false positive.
- Swedish #1 räkenskapsinformation flag origin — server-side already derives
  the flag from document_attachments.journal_entry_id in the staging tool;
  not caller-trusted.

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

* fix(review): fail-safe BFL warning + preserve merge validation errors

Two findings from the third compliance pass; both worth addressing.

- AttachDocumentPreview: treat an absent
  existing_document_is_rakenskapsinformation flag as räkenskapsinformation
  rather than as "safe to overwrite". The MCP staging tool sets the flag
  deterministically from document_attachments.journal_entry_id today, but
  a future code path that forgets it would silently downgrade the BFL 7
  kap warning. Only an explicit `=== false` from the server keeps the
  muted note path.
- Opening-balance merge: union validation_errors when collapsing duplicate
  account_number rows, both in the parser and the EditStep useState
  initializer. Previously a warning that fired on row 5 (e.g. BAS-class
  mismatch) was silently dropped if row 2 of the same account had no error,
  risking misclassified IB data downstream. Added a parser test covering
  the union behaviour for two rows of a class-3 (resultatkonto) account.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:10:12 +02:00

1702 lines
65 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 { createJournalEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
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 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
// 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,
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)
}
// 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 } = opts.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>,
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,
})
.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,
},
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,
}
}
// ── 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
// 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 }
)
}
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,
{ 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')
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 20), 50)
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
`)
.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',
})
const { data: extracted } = 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>,
})
.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 },
})
} 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 } })
},
},
// ── 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 },
)
}
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>,
})
.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 })
}
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,
}
})
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) {
return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
}
const itemInserts = items.map((lineItem) => ({
supplier_invoice_id: invoice.id,
...lineItem,
}))
const { error: itemsError } = await ctx.supabase
.from('supplier_invoice_items')
.insert(itemInserts)
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)
}
}
} catch (err) {
console.error('[invoice-inbox/convert] Failed to create registration journal entry:', err)
}
}
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 }