diff --git a/backend/src/receipt-import/receipt-import.controller.ts b/backend/src/receipt-import/receipt-import.controller.ts index ec369d3d..c06f443e 100644 --- a/backend/src/receipt-import/receipt-import.controller.ts +++ b/backend/src/receipt-import/receipt-import.controller.ts @@ -16,6 +16,7 @@ const ALLOWED_MIMES = [ 'image/webp', 'image/heic', 'image/heif', + 'application/pdf', ]; @Controller('receipt-import') @@ -37,7 +38,7 @@ export class ReceiptImportController { } if (!ALLOWED_MIMES.includes(file.mimetype)) { throw new BadRequestException( - 'Otillåten filtyp. Använd JPEG, PNG eller WebP.', + 'Otillåten filtyp. Använd JPEG, PNG, WebP eller PDF.', ); } return this.receiptImportService.parseReceipt(file); diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 587173a1..a664b814 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -4,11 +4,13 @@ import { 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'; const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; -const PROMPT = `Du är en kvittoläsare. Analysera detta kvitto och returnera ENDAST en JSON-array med alla köpta varor. + +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) @@ -17,6 +19,19 @@ Varje vara ska ha följande fält: 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 + +Returnera BARA JSON-arrayen utan markdown-formatering. + +Kvittotext: +${text}`; + @Injectable() export class ReceiptImportService { private readonly logger = new Logger(ReceiptImportService.name); @@ -31,9 +46,20 @@ export class ReceiptImportService { ); } - const base64 = file.buffer.toString('base64'); - const mimeType = file.mimetype || 'image/jpeg'; + const isPdf = file.mimetype === 'application/pdf'; + const rawItems = isPdf + ? await this.parseReceiptFromPdf(file.buffer, apiKey) + : await this.parseReceiptFromImage(file.buffer, file.mimetype, apiKey); + return this.matchProducts(rawItems); + } + + private async parseReceiptFromImage( + buffer: Buffer, + mimeType: string, + apiKey: string, + ): Promise { + const base64 = buffer.toString('base64'); const response = await fetch(MISTRAL_API_URL, { method: 'POST', headers: { @@ -50,7 +76,7 @@ export class ReceiptImportService { type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64}` }, }, - { type: 'text', text: PROMPT }, + { type: 'text', text: IMAGE_PROMPT }, ], }, ], @@ -59,6 +85,50 @@ export class ReceiptImportService { }), }); + 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 fetch(MISTRAL_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: 'mistral-small-latest', + messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }], + max_tokens: 2000, + temperature: 0.1, + }), + }); + + 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}: ${err}`); @@ -70,19 +140,17 @@ export class ReceiptImportService { }; const content = data.choices?.[0]?.message?.content ?? '[]'; - let rawItems: ParsedReceiptItem[]; try { const clean = content.replace(/```(?:json)?/gi, '').trim(); - rawItems = JSON.parse(clean); - if (!Array.isArray(rawItems)) throw new Error('Inte en array'); + 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:', content); + this.logger.error(`Kunde inte parsa Mistral-svar (${source}):`, content); throw new BadRequestException( - 'Kvittot kunde inte tolkas. Försök med en tydligare bild.', + `Kvittot kunde inte tolkas. Försök med en tydligare ${source === 'PDF' ? 'PDF' : 'bild'}.`, ); } - - return this.matchProducts(rawItems); } private async matchProducts( diff --git a/frontend/app/kvitto/ReceiptImportClient.tsx b/frontend/app/kvitto/ReceiptImportClient.tsx index 6f444922..b447ceb2 100644 --- a/frontend/app/kvitto/ReceiptImportClient.tsx +++ b/frontend/app/kvitto/ReceiptImportClient.tsx @@ -29,7 +29,11 @@ export default function ReceiptImportClient() { const file = e.target.files?.[0]; if (!file) return; setSelectedFile(file); - setPreview(URL.createObjectURL(file)); + if (file.type === 'application/pdf') { + setPreview('pdf'); + } else { + setPreview(URL.createObjectURL(file)); + } setRows([]); setError(null); setSavedCount(null); @@ -119,12 +123,18 @@ export default function ReceiptImportClient() { - {preview ? ( + {preview === 'pdf' ? ( +
+
📄
+
{selectedFile?.name}
+
PDF-kvitto valt
+
+ ) : preview ? ( // eslint-disable-next-line @next/next/no-img-element 📷
Fotografera eller välj kvitto
- Klicka för att välja bild (JPEG, PNG, WebP) + Klicka för att välja bild (JPEG, PNG, WebP) eller PDF
)}