From fae9b111ce78004f8eb82be3101af27ba2eccbd6 Mon Sep 17 00:00:00 2001 From: DrunkOnJava <151978260+DrunkOnJava@users.noreply.github.com> Date: Sat, 12 Jul 2025 08:14:10 -0400 Subject: [PATCH] Implement receipt import and preview saving --- .../ViewModels/ReceiptImportViewModel.swift | 53 ++++++++++- .../ViewModels/ReceiptPreviewViewModel.swift | 35 ++++++- .../Tests/ReceiptImportViewModelTests.swift | 94 +++++++++++++++++++ .../Tests/ReceiptPreviewViewModelTests.swift | 68 ++++++++++++++ 4 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 Modules/Receipts/Tests/ReceiptImportViewModelTests.swift create mode 100644 Modules/Receipts/Tests/ReceiptPreviewViewModelTests.swift diff --git a/Modules/Receipts/Sources/ViewModels/ReceiptImportViewModel.swift b/Modules/Receipts/Sources/ViewModels/ReceiptImportViewModel.swift index 680821f6..29502ef5 100644 --- a/Modules/Receipts/Sources/ViewModels/ReceiptImportViewModel.swift +++ b/Modules/Receipts/Sources/ViewModels/ReceiptImportViewModel.swift @@ -23,11 +23,58 @@ final class ReceiptImportViewModel: ObservableObject { } func importFromEmail() async { - // TODO: Implement email import + guard let emailService else { + errorMessage = "Email import not available" + return + } + + isLoading = true + defer { isLoading = false } + + do { + let emails = try await emailService.fetchEmails(from: nil, matching: nil) + + for email in emails { + if let parsed = try await emailService.parseReceiptFromEmail(email) { + let receipt = Receipt( + storeName: parsed.storeName, + date: parsed.date, + totalAmount: parsed.totalAmount, + rawText: parsed.rawData, + confidence: parsed.confidence + ) + saveReceipt(receipt) + } + } + } catch { + errorMessage = error.localizedDescription + } } - + func importFromCamera() async { - // TODO: Implement camera/OCR import + #if canImport(UIKit) + isLoading = true + defer { isLoading = false } + + do { + let image = UIImage() + if let data = try await ocrService.extractReceiptData(from: image) { + let receipt = Receipt( + storeName: data.storeName ?? "Unknown Store", + date: data.date ?? Date(), + totalAmount: data.totalAmount ?? 0, + imageData: image.jpegData(compressionQuality: 0.8), + rawText: data.rawText, + confidence: data.confidence + ) + saveReceipt(receipt) + } + } catch { + errorMessage = error.localizedDescription + } + #else + errorMessage = "Camera import not supported" + #endif } func saveReceipt(_ receipt: Receipt) { diff --git a/Modules/Receipts/Sources/ViewModels/ReceiptPreviewViewModel.swift b/Modules/Receipts/Sources/ViewModels/ReceiptPreviewViewModel.swift index 2b485d6a..435031f7 100644 --- a/Modules/Receipts/Sources/ViewModels/ReceiptPreviewViewModel.swift +++ b/Modules/Receipts/Sources/ViewModels/ReceiptPreviewViewModel.swift @@ -25,6 +25,39 @@ final class ReceiptPreviewViewModel: ObservableObject { } func saveReceipt() async { - // TODO: Implement saving parsed receipt + isLoading = true + defer { isLoading = false } + + do { + // Save parsed items first + var itemIds: [UUID] = [] + for item in parsedData.items { + let newItem = Item( + name: item.name, + category: item.category ?? .other, + quantity: item.quantity, + purchasePrice: item.price, + purchaseDate: parsedData.date, + storeName: parsedData.storeName + ) + try await itemRepository.save(newItem) + itemIds.append(newItem.id) + } + + let receipt = Receipt( + storeName: parsedData.storeName, + date: parsedData.date, + totalAmount: parsedData.totalAmount, + itemIds: itemIds, + imageData: parsedData.imageData, + rawText: parsedData.rawText, + confidence: parsedData.confidence + ) + + try await receiptRepository.save(receipt) + completion(receipt) + } catch { + print("Failed to save receipt: \(error)") + } } } \ No newline at end of file diff --git a/Modules/Receipts/Tests/ReceiptImportViewModelTests.swift b/Modules/Receipts/Tests/ReceiptImportViewModelTests.swift new file mode 100644 index 00000000..749002df --- /dev/null +++ b/Modules/Receipts/Tests/ReceiptImportViewModelTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import Receipts +import Core + +final class ReceiptImportViewModelTests: XCTestCase { + func testImportFromEmailCreatesReceipt() async { + let email = EmailMessage( + id: "1", + subject: "Receipt", + sender: "store@example.com", + recipient: "me@example.com", + date: Date(), + body: "body" + ) + + let parsed = ParsedEmailReceipt( + storeName: "Test Store", + date: Date(), + totalAmount: 9.99, + confidence: 0.9, + rawData: "body" + ) + + let emailService = MockEmailService(emails: [email], parsedReceipt: parsed) + let ocrService = MockOCRService() + var received: Receipt? + let vm = ReceiptImportViewModel(emailService: emailService, ocrService: ocrService) { receipt in + received = receipt + } + + await vm.importFromEmail() + XCTAssertNotNil(received) + XCTAssertEqual(received?.storeName, "Test Store") + } + + func testImportFromCameraUsesOCR() async { + let ocrService = MockOCRService() + #if canImport(UIKit) + ocrService.receiptData = OCRReceiptData( + storeName: "Camera Store", + date: Date(), + totalAmount: 5, + items: [], + confidence: 0.8, + rawText: "text" + ) + #endif + var received: Receipt? + let vm = ReceiptImportViewModel(emailService: nil, ocrService: ocrService) { receipt in + received = receipt + } + + await vm.importFromCamera() + #if canImport(UIKit) + XCTAssertTrue(ocrService.called) + XCTAssertEqual(received?.storeName, "Camera Store") + #else + XCTAssertNotNil(vm.errorMessage) + #endif + } +} + +private final class MockEmailService: EmailServiceProtocol { + var emails: [EmailMessage] + var parsedReceipt: ParsedEmailReceipt? + + init(emails: [EmailMessage], parsedReceipt: ParsedEmailReceipt?) { + self.emails = emails + self.parsedReceipt = parsedReceipt + } + + func fetchEmails(from sender: String?, matching criteria: String?) async throws -> [EmailMessage] { + emails + } + + func parseReceiptFromEmail(_ email: EmailMessage) async throws -> ParsedEmailReceipt? { + parsedReceipt + } +} + +private final class MockOCRService: OCRServiceProtocol { +#if canImport(UIKit) + var called = false + var receiptData: OCRReceiptData? + func extractText(from image: UIImage) async throws -> OCRResult { + called = true + return OCRResult(text: "", confidence: 1, language: nil) + } + func extractReceiptData(from image: UIImage) async throws -> OCRReceiptData? { + called = true + return receiptData + } +#endif +} diff --git a/Modules/Receipts/Tests/ReceiptPreviewViewModelTests.swift b/Modules/Receipts/Tests/ReceiptPreviewViewModelTests.swift new file mode 100644 index 00000000..f30b95f8 --- /dev/null +++ b/Modules/Receipts/Tests/ReceiptPreviewViewModelTests.swift @@ -0,0 +1,68 @@ +import XCTest +@testable import Receipts +import Core + +final class ReceiptPreviewViewModelTests: XCTestCase { + func testSaveReceiptPersistsItemsAndReceipt() async { + let parsed = ParsedReceiptData( + storeName: "Store", + date: Date(), + totalAmount: 20, + items: [ + ParsedReceiptItem(name: "Item1", price: 10), + ParsedReceiptItem(name: "Item2", price: 10) + ], + confidence: 1, + rawText: "text", + imageData: nil + ) + + let receiptRepo = MockReceiptRepository() + let itemRepo = MockItemRepository() + var completed = false + let vm = ReceiptPreviewViewModel(parsedData: parsed, receiptRepository: receiptRepo, itemRepository: itemRepo) { _ in + completed = true + } + + await vm.saveReceipt() + + XCTAssertEqual(itemRepo.savedItems.count, 2) + XCTAssertEqual(receiptRepo.savedReceipts.count, 1) + XCTAssertEqual(receiptRepo.savedReceipts.first?.itemIds.count, 2) + XCTAssertTrue(completed) + } +} + +private final class MockReceiptRepository: ReceiptRepository { + var savedReceipts: [Receipt] = [] + func fetchAll() async throws -> [Receipt] { savedReceipts } + func fetch(id: Receipt.ID) async throws -> Receipt? { savedReceipts.first { $0.id == id } } + func save(_ entity: Receipt) async throws { savedReceipts.append(entity) } + func saveAll(_ entities: [Receipt]) async throws { savedReceipts.append(contentsOf: entities) } + func delete(_ entity: Receipt) async throws {} + func delete(id: Receipt.ID) async throws {} + func fetchByDateRange(from startDate: Date, to endDate: Date) async throws -> [Receipt] { [] } + func fetchByStore(_ storeName: String) async throws -> [Receipt] { [] } + func fetchByItemId(_ itemId: UUID) async throws -> [Receipt] { [] } + func fetchAboveAmount(_ amount: Decimal) async throws -> [Receipt] { [] } +} + +private final class MockItemRepository: ItemRepository { + var savedItems: [Item] = [] + func fetchAll() async throws -> [Item] { savedItems } + func fetch(id: Item.ID) async throws -> Item? { savedItems.first { $0.id == id } } + func save(_ entity: Item) async throws { savedItems.append(entity) } + func saveAll(_ entities: [Item]) async throws { savedItems.append(contentsOf: entities) } + func delete(_ entity: Item) async throws {} + func delete(id: Item.ID) async throws {} + func search(query: String) async throws -> [Item] { [] } + func fuzzySearch(query: String, threshold: Double) async throws -> [Item] { [] } + func searchWithCriteria(_ criteria: ItemSearchCriteria) async throws -> [Item] { [] } + func fetchByCategory(_ category: ItemCategory) async throws -> [Item] { [] } + func fetchByCategoryId(_ categoryId: UUID) async throws -> [Item] { [] } + func fetchByLocation(_ locationId: UUID) async throws -> [Item] { [] } + func fetchByBarcode(_ barcode: String) async throws -> Item? { nil } + func fetchItemsUnderWarranty() async throws -> [Item] { [] } + func fetchFavoriteItems() async throws -> [Item] { [] } + func fetchRecentlyAdded(days: Int) async throws -> [Item] { [] } +}