* feat(kpi): expense mix and top suppliers charts Replace the single monthly-trend chart with two additional compact visuals on /kpi: expense composition donut (BAS class 4-7) and top suppliers bar (supplier_invoices sum_sek over the fiscal period). KPIReport gains expenseComposition and topSuppliers fields, computed from the trial balance and supplier_invoices rows already fetched in the API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(nav): swap Deadlines sidebar slot for Dokumentinkorg Sidebar main-menu slot now points to the invoice-inbox extension. The /deadlines page stays accessible via dashboard widgets and direct links — only the prominent nav entry changes. Most users open gnubok to act on incoming documents, not to read tax deadlines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): cross-currency totals, FX residual, review SEK display Five fixes around foreign-currency supplier invoices: - Form layout: move Valuta / Växelkurs / Reverse charge from collapsed "Övrigt" into a visible row above the line-item table. Auto-fetch the Riksbanken rate when switching to a non-SEK currency; never clobber a user-typed rate; clear it when switching back to SEK. - Form submit: reset() the form on successful submit so the useUnsavedChanges hook detaches its beforeunload listener before the router.push, killing the "Are you sure you want to leave?" prompt that fired during Turbopack-mediated navigations. - BankTransactionPicker: drop the strict currency filter that hid every SEK transaction when the invoice was in EUR/USD. Cross-currency rows fall to the bottom with an "Annan valuta" hint instead of producing a meaningless numeric diff. - match-supplier-invoice route: when the bank transaction currency differs from the invoice currency, compute the FX diff against the AP-booked SEK and pass it to createSupplierInvoicePaymentEntry so 7960/3960 catches the residual instead of leaving a permanent stub on 2440. Fix also covers the "EUR transaction paying a SEK invoice" case that the first iteration missed. - Review dialog: buildJournalPreview now multiplies amounts by the exchange rate so the "Verifikation som bokförs" table shows the actual SEK numbers that hit the DB, not the EUR magnitudes labelled with no unit. Header gains an "(i SEK)" hint when foreign currency. Test coverage for the FX residual path covers SEK-SEK (no diff), SEK-into-EUR-invoice (loss), SEK-into-EUR-invoice (gain), foreign-tx- into-SEK-invoice, and the no-rate fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(inbox): rate limits, multi-file UX, onboarding, retry, supplier autolink Big workspace pass on /e/general/invoice-inbox. Highlights: Backend - New table inbox_rate_counters + RPC check_and_increment_inbox_quota. Postgres-backed (no Upstash dep) per-company limit: 30/min, 500/day. Applied at /upload, /inbound, and /items/:id/retry-extraction. - POST /items/:id/retry-extraction — re-runs the deterministic extractor on a stored document when the previous attempt errored. - POST /items/:id/match-supplier — links a freshly-created supplier back to the inbox item so the next action prefills correctly. - POST /api/transactions/create-from-document — creates an uncategorized manual transaction from an inbox item for the "I have a receipt, no bank transaction" case. The user categorizes through the normal flow. - /inbound caps email at 20 attachments/email; truncated count goes to processing_history as AttachmentsTruncated. Rate-limit drops emit RateLimitedDropped and return 200 so Resend doesn't retry. - attach-document side effect: when the document came from an inbox item, the inbox row's matched_transaction_id is updated so the UI can flip it to "Kopplad till transaktion" without a round-trip. New migration: re-introduces matched_transaction_id on invoice_inbox_items as a plain FK (the AI metadata that the previous migration stripped doesn't come back). Workspace UI - Onboarding card replaces the thin empty-state with a 3-step checkmark guide (Aktivera adress → Ladda upp → Matcha eller bokför). Auto-hides when all three steps are done; localStorage-backed dismiss. Beta badge + link to gnubok.se/priser. - Responsive layout: 3-pane at lg, 2-pane at md, master-detail toggle on phone (list xor detail with a back button). - Filter pills (Alla / Behöver åtgärd / Bearbetade / Fel) + search input above the list — client-side over the existing items list. - Multi-file upload queue with "Laddar X av N…" progress counter on the button. Sequential to avoid hammering pdfjs. Selection stays put during a batch (only single-file drops auto-jump the detail pane). - Bulk select + delete with sticky action bar. Items linked to a supplier invoice are skipped with a count toast. - Retry button in the FieldsRail error branch. - "Skapa transaktion från underlag" CTA in the match dialog when no unmatched bank transactions exist. Prefills date/amount/description from the extracted data; user picks the sign. - "Skapa leverantör" inline CTA when the extractor caught a supplier name with no match against existing suppliers. POSTs /api/suppliers with the extracted fields, then auto-links via /items/:id/match-supplier. - Matched-state CTA renamed to "Bokför transaktionen" with link to /transactions?highlight=<id> so the categorize panel auto-opens. Tests - lib/rate-limits/__tests__/inbox.test.ts — RPC wrapper happy/error/scope - app/api/transactions/create-from-document/__tests__/route.test.ts — auth, validation, 404/409/200/500, inbox-link failure tolerated - extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts — auth, rate limit, 404, 409, 400 no-doc, success, extraction failure - attach-document tests extend coverage to the new inbox-link side effect (both success and best-effort failure paths) - inbound-webhook test mocks the rate-limit module so the queued-mock sequence in each existing test doesn't have to know about it CLAUDE.md gains a row for lib/rate-limits/ so the new helper is discoverable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(transactions): paperclip indicator and highlight-row param Close the feedback loop after a user attaches a receipt to a transaction from the inbox: the row in /transactions now shows a paperclip icon when transaction.document_id is set, with a click handler that fetches a signed download URL and opens the document in a new tab. Works for both uncategorized and history views. When the inbox sends a user to /transactions?highlight=<id>, the page now scrolls that row into view and auto-opens the categorize panel if the transaction is still uncategorized. Behind a double-rAF so the row DOM exists when scrollIntoView fires. QuickReviewDialog no longer prompts to upload underlag when the transaction already has a doc attached (which it does after the inbox match flow). Shows "Underlag bifogat — Visa" instead, opening the existing doc in a new tab. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-444): address review feedback (Greptile + compliance bots) Migration rules - New migration 20260512092423: adds updated_at trigger on inbox_rate_counters (CLAUDE.md rule 2) and explicit USING (false) RLS policies for the four DML verbs to make the SECURITY DEFINER-only intent explicit (rule 1). - New pg-real test inbox-rate-limit.pg.test.ts covering happy path, minute-cap rejection, day-cap rejection, per-company isolation, and the updated_at trigger firing. CLAUDE.md mandates *.pg.test.ts for every new RPC because mocks pass on broken PL/pgSQL. Bugs - Stale exchange rate on currency switch (Greptile P1) — userTouchedRateRef was scoped per session, not per currency. Switching EUR (with a hand-edited rate) → USD kept the EUR rate. Now tracks the last fetched currency in a ref and resets the touched flag on currency change while still honoring manual edits within a single currency. - topSuppliersResult.error silently swallowed (Greptile P2) — failed queries used to render an empty chart matching the no-data state. Logged now. - Currency from extracted_data not validated (GDPR Art.25(2), OWASP V4.5, Swedish compliance bot) — extracted PDF currency was inserted into transactions.currency without sanitisation. Allowlisted against the six supported ISO 4217 codes; coerce to SEK otherwise. - Idempotency gap on create-from-document (OWASP V2.3) — two concurrent POSTs with the same inbox_item_id could each pass the matched_transaction_id IS NULL read and insert duplicate transactions. UPDATE now includes .is('matched_transaction_id', null) as an optimistic-lock release and returns 409 with an orphan-transaction rollback when the predicate doesn't match. - FX residual on cash-method match path (Swedish compliance bot) — createSupplierInvoiceCashEntry has no exchange_rate_difference path, so a cross-currency match would silently leave a 1930 reconciliation gap. Added a guard that returns MATCH_SI_CASH_FX_UNSUPPORTED (400) before the JE is created. Users on cash method can switch to accrual or book the FX diff manually. Design system - gap-y-1.5 / gap-1.5 in KPIExpenseMixChart — replaced with gap-y-2 / gap-2 (CLAUDE.md design tokens; 2.5/1.5/5/hardcoded pixels are forbidden spacing values). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(migrations): rename to match applied versions The mcp__plugin_supabase_supabase__apply_migration tool stamps its own timestamp when it applies a migration to the live project, so the version recorded in supabase_migrations.schema_migrations differs from my local generation-time filenames. Renaming the local files so a production CD run sees the migrations as already-applied (matching versions) instead of trying to re-apply them — which would fail for the trigger/RLS migration (CREATE TRIGGER and CREATE POLICY don't support IF NOT EXISTS). Follows the pattern from d854efcd ("chore(migration): rename to match applied version"). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(create-from-document): scope orphan rollback DELETE by company_id Defence in depth on the inbox-link race rollback. newTx.id is a fresh UUID from a company-scoped insert two statements above, so the existing single-key DELETE is already safe, but adding .eq('company_id', companyId) makes the cross-company invariant explicit on every write — addresses the OWASP ASVS V2.3 finding from the compliance swarm on PR #444. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(nav): mark Dokumentinkorg with Beta badge Same signal we use for Löner and Anställda — the inbox flow (AI extraction, supplier autolink, manual transaction creation) is in end-to-end customer testing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1261 lines
46 KiB
TypeScript
1261 lines
46 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 } 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 { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
|
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
|
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
|
|
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
|
|
) {
|
|
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)
|
|
}
|
|
|
|
const { data: extracted, rawText } = 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
|
|
|
|
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'
|
|
)
|
|
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,
|
|
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 })
|
|
},
|
|
},
|
|
|
|
// ── 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 }> = []
|
|
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)
|
|
if (!UPLOAD_ALLOWED_MIME_TYPES.has(download.contentType)) {
|
|
results.push({ attachment_id: att.id, error: `Unsupported type ${download.contentType}` })
|
|
continue
|
|
}
|
|
if (download.buffer.byteLength > MAX_FILE_SIZE) {
|
|
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')
|
|
.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 }
|
|
)
|
|
}
|
|
|
|
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,
|
|
},
|
|
})
|
|
},
|
|
},
|
|
],
|
|
}
|
|
|
|
// Re-export the extraction shape for tests / consumers.
|
|
export type { InvoiceExtractionResult }
|