import { BadRequestException, Injectable, Logger, ServiceUnavailableException, } from '@nestjs/common'; import * as pdfParse from 'pdf-parse'; import { PrismaService } from '../prisma/prisma.service'; import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto'; import { AiService } from '../ai/ai.service'; import { CategoriesService } from '../categories/categories.service'; const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; export const RECEIPT_IMPORT_MODEL = 'mistral-small-2603'; const IMAGE_PROMPT = `Du är en kvittoläsare. Analysera detta kvitto och returnera ENDAST en JSON-array med alla köpta varor. Varje vara ska ha följande fält: - "rawName": varans namn som det står på kvittot (sträng) - "quantity": antal eller mängd som ett tal (t.ex. 1, 2, 0.5) - "unit": enhet — välj ett av: "st", "kg", "g", "l", "dl", "cl", "ml", "förp", "pak", "burk", "flaska" - "price": pris i SEK som ett tal, eller null - "brand": märke eller leverantör om det tydligt framgår av varunamnet (t.ex. "Arla", "ICA", "Oatly"), annars null - "origin": ursprungsland om det framgår av varunamnet (t.ex. "Brasilien", "Sverige", "Italien"), annars null Returnera BARA JSON-arrayen utan markdown-formatering.`; const TEXT_PROMPT = (text: string) => `Du är en kvittoläsare. Nedan följer rå text från ett kvitto. Analysera texten och returnera ENDAST en JSON-array med alla köpta varor. Varje vara ska ha följande fält: - "rawName": varans namn som det står på kvittot (sträng) - "quantity": antal eller mängd som ett tal (t.ex. 1, 2, 0.5) - "unit": enhet — välj ett av: "st", "kg", "g", "l", "dl", "cl", "ml", "förp", "pak", "burk", "flaska" - "price": pris i SEK som ett tal, eller null - "brand": märke eller leverantör om det tydligt framgår av varunamnet (t.ex. "Arla", "ICA", "Oatly"), annars null - "origin": ursprungsland om det framgår av varunamnet (t.ex. "Brasilien", "Sverige", "Italien"), annars null Returnera BARA JSON-arrayen utan markdown-formatering. Kvittotext: ${text}`; @Injectable() export class ReceiptImportService { private readonly logger = new Logger(ReceiptImportService.name); private readonly MAX_RETRIES = 3; private async callMistralWithRetry(body: object, apiKey: string, source: string): Promise { for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) { const response = await fetch(MISTRAL_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(body), }); if (response.status === 503 || response.status === 429) { const err = await response.text(); this.logger.warn(`Mistral ${response.status} (${source}, försök ${attempt}/${this.MAX_RETRIES}): ${err}`); if (attempt < this.MAX_RETRIES) { await new Promise((r) => setTimeout(r, attempt * 2000)); continue; } throw new ServiceUnavailableException('Mistral API returnerade ett fel: Tjänsten tillfälligt otillgänglig (503)'); } return response; } throw new ServiceUnavailableException('Kunde inte nå Mistral API efter flera försök'); } constructor( private readonly prisma: PrismaService, private readonly aiService: AiService, private readonly categoriesService: CategoriesService, ) {} async parseReceipt(file: Express.Multer.File, isPremium = false): Promise { const apiKey = process.env.MISTRAL_API_KEY; if (!apiKey) { throw new ServiceUnavailableException( 'MISTRAL_API_KEY är inte konfigurerad i miljövariabler', ); } const isPdf = file.mimetype === 'application/pdf' || file.mimetype === 'application/octet-stream' || file.originalname?.toLowerCase().endsWith('.pdf'); const rawItems = isPdf ? await this.parseReceiptFromPdf(file.buffer, apiKey) : await this.parseReceiptFromImage(file.buffer, file.mimetype, apiKey); const matched = await this.matchProducts(rawItems); if (isPremium) { return this.enrichWithAiCategories(matched); } return matched; } private async parseReceiptFromImage( buffer: Buffer, mimeType: string, apiKey: string, ): Promise { const base64 = buffer.toString('base64'); const response = await this.callMistralWithRetry({ model: RECEIPT_IMPORT_MODEL, messages: [ { role: 'user', content: [ { type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64}` }, }, { type: 'text', text: IMAGE_PROMPT }, ], }, ], max_tokens: 2000, temperature: 0.1, }, apiKey, 'bild'); return this.extractItemsFromMistralResponse(response, 'bild'); } private async parseReceiptFromPdf( buffer: Buffer, apiKey: string, ): Promise { let pdfText: string; try { const parsed = await pdfParse(buffer); pdfText = parsed.text?.trim(); } catch { throw new BadRequestException('Kunde inte läsa PDF-filen. Kontrollera att filen inte är skadad.'); } if (!pdfText || pdfText.length < 20) { throw new BadRequestException( 'PDF-filen verkar inte innehålla läsbar text. Prova att fotografera kvittot istället.', ); } this.logger.log(`PDF-text extraherad (${pdfText.length} tecken)`); const response = await this.callMistralWithRetry({ model: RECEIPT_IMPORT_MODEL, messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }], max_tokens: 2000, temperature: 0.1, }, apiKey, 'PDF'); return this.extractItemsFromMistralResponse(response, 'PDF'); } private async extractItemsFromMistralResponse( response: Response, source: string, ): Promise { if (!response.ok) { const err = await response.text(); this.logger.error(`Mistral API svarade ${response.status} (${source}): ${err}`); const hint = response.status === 401 ? 'Ogiltig API-nyckel (401)' : response.status === 429 ? 'För många förfrågningar — försök igen om en stund (429)' : `HTTP ${response.status}`; throw new ServiceUnavailableException( `Mistral API returnerade ett fel: ${hint}`, ); } const data = (await response.json()) as { choices: { message: { content: string } }[]; }; const content = data.choices?.[0]?.message?.content ?? '[]'; try { const clean = content.replace(/```(?:json)?/gi, '').trim(); const items = JSON.parse(clean); if (!Array.isArray(items)) throw new Error('Inte en array'); return items as ParsedReceiptItem[]; } catch { this.logger.error(`Kunde inte parsa Mistral-svar (${source}):`, content); throw new BadRequestException( `Kvittot kunde inte tolkas. Försök med en tydligare ${source === 'PDF' ? 'PDF' : 'bild'}.`, ); } } private async matchProducts( items: ParsedReceiptItem[], ): Promise { // Hämta alias och produkter parallellt const [aliases, products] = await Promise.all([ this.prisma.receiptAlias.findMany({ select: { receiptName: true, productId: true, product: { select: { id: true, name: true, canonicalName: true } } }, }), this.prisma.product.findMany({ where: { isActive: true }, select: { id: true, name: true, canonicalName: true }, }), ]); return items.map((item) => { const raw = (item.rawName ?? '').toLowerCase().trim(); if (!raw) return item; // 1. Alias-match (säker, användaren behöver inte bekräfta) const alias = aliases.find((a) => a.receiptName === raw); if (alias) { return { ...item, matchedProductId: alias.product.id, matchedProductName: alias.product.canonicalName ?? alias.product.name, }; } // 2. Ordbaserad matchning (förslag, kräver bekräftelse) const suggestion = this.findWordMatch(raw, products); return { ...item, suggestedProductId: suggestion?.id, suggestedProductName: suggestion ? (suggestion.canonicalName ?? suggestion.name) : undefined, }; }); } private findWordMatch( raw: string, products: { id: number; name: string; canonicalName: string | null }[], ): { id: number; name: string; canonicalName: string | null } | undefined { // Dela upp kvittonamnet i ord (min 3 tecken) const rawWords = raw.split(/[\s\-_]+/).filter((w) => w.length >= 3); if (rawWords.length === 0) return undefined; // Fortsätt med att hitta produkter där ett produktnamn-ord finns i kvittonamnet // Exempel: produktord "ost" finns i kvittoord "prästost", "herrgårdsost", "brieost" return products.find((p) => { const productWords = (p.canonicalName ?? p.name) .toLowerCase() .split(/[\s\-_]+/) .filter((w) => w.length >= 3); return productWords.some((pw) => rawWords.some((rw) => rw.includes(pw) || pw.includes(rw)), ); }); } private async enrichWithAiCategories(items: ParsedReceiptItem[]): Promise { const unmatched = items.filter((i) => !i.matchedProductId && !i.suggestedProductId && i.rawName); if (unmatched.length === 0) return items; let categories: Awaited>; try { categories = await this.categoriesService.findFlattened(); } catch { return items; // Om kategoritjänsten är otillgänglig, returnera utan AI-förslag } const enriched = new Map(); for (const item of unmatched) { try { const suggestion = await this.aiService.suggestCategory(item.rawName, categories); enriched.set(item.rawName, { ...item, categorySuggestion: suggestion }); } catch { // Om AI-anrop misslyckas för enskild vara — hoppa över utan att kasta enriched.set(item.rawName, item); } } return items.map((item) => enriched.get(item.rawName) ?? item); } }