Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
}
}
94 changes: 94 additions & 0 deletions Modules/Receipts/Tests/ReceiptImportViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
68 changes: 68 additions & 0 deletions Modules/Receipts/Tests/ReceiptPreviewViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -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] { [] }
}
Loading