feat: add receipt import functionality with UI and backend integration

This commit is contained in:
Nils-Johan Gynther
2026-04-16 20:02:57 +02:00
parent 88d3c4ad73
commit a12abe0402
10 changed files with 513 additions and 0 deletions
+2
View File
@@ -7,6 +7,7 @@ import { RecipesModule } from './recipes/recipes.module';
import { QuickImportModule } from './quick-import/quick-import.module';
import { PantryModule } from './pantry/pantry.module';
import { MealPlanModule } from './meal-plan/meal-plan.module';
import { ReceiptImportModule } from './receipt-import/receipt-import.module';
@Module({
@@ -19,6 +20,7 @@ import { MealPlanModule } from './meal-plan/meal-plan.module';
QuickImportModule,
PantryModule,
MealPlanModule,
ReceiptImportModule,
],
})
export class AppModule {}
@@ -0,0 +1,8 @@
export interface ParsedReceiptItem {
rawName: string;
quantity: number;
unit: string;
price?: number | null;
matchedProductId?: number;
matchedProductName?: string;
}
@@ -0,0 +1,45 @@
import {
Controller,
Post,
UploadedFile,
UseInterceptors,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { ReceiptImportService } from './receipt-import.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
const ALLOWED_MIMES = [
'image/jpeg',
'image/png',
'image/webp',
'image/heic',
'image/heif',
];
@Controller('receipt-import')
export class ReceiptImportController {
constructor(private readonly receiptImportService: ReceiptImportService) {}
@Post()
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
}),
)
async parseReceipt(
@UploadedFile() file?: Express.Multer.File,
): Promise<ParsedReceiptItem[]> {
if (!file?.buffer) {
throw new BadRequestException('Ingen fil skickades med.');
}
if (!ALLOWED_MIMES.includes(file.mimetype)) {
throw new BadRequestException(
'Otillåten filtyp. Använd JPEG, PNG eller WebP.',
);
}
return this.receiptImportService.parseReceipt(file);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ReceiptImportController } from './receipt-import.controller';
import { ReceiptImportService } from './receipt-import.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [ReceiptImportController],
providers: [ReceiptImportService],
})
export class ReceiptImportModule {}
@@ -0,0 +1,122 @@
import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
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.
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.`;
@Injectable()
export class ReceiptImportService {
private readonly logger = new Logger(ReceiptImportService.name);
constructor(private readonly prisma: PrismaService) {}
async parseReceipt(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey) {
throw new ServiceUnavailableException(
'MISTRAL_API_KEY är inte konfigurerad i miljövariabler',
);
}
const base64 = file.buffer.toString('base64');
const mimeType = file.mimetype || 'image/jpeg';
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'pixtral-12b-2409',
messages: [
{
role: 'user',
content: [
{
type: 'image_url',
image_url: { url: `data:${mimeType};base64,${base64}` },
},
{ type: 'text', text: PROMPT },
],
},
],
max_tokens: 2000,
temperature: 0.1,
}),
});
if (!response.ok) {
const err = await response.text();
this.logger.error(`Mistral API svarade ${response.status}: ${err}`);
throw new ServiceUnavailableException('Mistral API returnerade ett fel — kontrollera API-nyckeln');
}
const data = (await response.json()) as {
choices: { message: { content: string } }[];
};
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');
} catch {
this.logger.error('Kunde inte parsa Mistral-svar:', content);
throw new BadRequestException(
'Kvittot kunde inte tolkas. Försök med en tydligare bild.',
);
}
return this.matchProducts(rawItems);
}
private async matchProducts(
items: ParsedReceiptItem[],
): Promise<ParsedReceiptItem[]> {
const products = await this.prisma.product.findMany({
select: { id: true, name: true, canonicalName: true },
});
return items.map((item) => {
const raw = (item.rawName ?? '').toLowerCase().trim();
if (!raw) return item;
// Exakt matchning först
let match = products.find((p) => {
const n = (p.canonicalName ?? p.name).toLowerCase();
return n === raw || p.name.toLowerCase() === raw;
});
// Delvis matchning
if (!match) {
match = products.find((p) => {
const n = (p.canonicalName ?? p.name).toLowerCase();
return n.includes(raw) || raw.includes(n);
});
}
return {
...item,
matchedProductId: match?.id,
matchedProductName: match
? (match.canonicalName ?? match.name)
: undefined,
};
});
}
}