diff --git a/Sources/CardVision/Extensions/Date+current.swift b/Sources/CardVision/Extensions/Date+current.swift new file mode 100644 index 0000000..c2714d3 --- /dev/null +++ b/Sources/CardVision/Extensions/Date+current.swift @@ -0,0 +1,21 @@ +// +// Date+current.swift +// +// Implements a Date.current method to get the current date in a way that can be mocked for testing. +// Adapted from: https://dev.to/ivanmisuno/deterministic-unit-tests-for-current-date-dependent-code-in-swift-2h72 +// Created by Alex Wolfe on 6/18/22. +// + +import Foundation + + +internal var __date_currentImpl = { Date() } + +extension Date { + /// Return current date + /// Please note that use of `Date()` and `Date(timeIntervalSinceNow:)` should not be prohibited + /// through lint rules or commit hooks, always use `Date.current` + static var current: Date { + return __date_currentImpl() + } +} diff --git a/Sources/CardVision/Extensions/String+isMatchedBy.swift b/Sources/CardVision/Extensions/String+isMatchedBy.swift new file mode 100644 index 0000000..62631fb --- /dev/null +++ b/Sources/CardVision/Extensions/String+isMatchedBy.swift @@ -0,0 +1,15 @@ +// +// String+isMatchedBy.swift +// +// +// Created by Alex Wolfe on 6/26/22. +// + +import Foundation + +extension String { + /// Returns whether the string matches the given regular expression + func isMatchedBy(regex: String) -> Bool { + return (self.range(of: regex, options: .regularExpression) ?? nil) != nil + } +} diff --git a/Sources/CardVision/Logic/TransactionReaderOCRText.swift b/Sources/CardVision/Logic/TransactionReaderOCRText.swift index c3125bd..2a0a2c0 100644 --- a/Sources/CardVision/Logic/TransactionReaderOCRText.swift +++ b/Sources/CardVision/Logic/TransactionReaderOCRText.swift @@ -9,6 +9,7 @@ import Foundation typealias TransactionReaderOCRText = [String] + extension TransactionReaderOCRText { func parseTransactions(screenshotDate: Date) -> [Transaction] { var ocrText = self @@ -35,6 +36,7 @@ fileprivate extension Array where Element == String { return top } + /// Determines if the payee and memo combination constitute a Daily Cash transaction. static func isDailyCashTransaction(payee: String, memo: String) -> Bool { // Non-daily cash payee let nonDailyCashPayees = ["Payment", "Daily Cash Adjustment", "Balance Adjustment"] @@ -45,19 +47,31 @@ fileprivate extension Array where Element == String { return !(memo.contains("Refund") || isDeclinedTransaction(declinedCandidate: memo)) } + /// Determines if the given amount string is a valid monetary amount static func isAmount(amountCandidate: String) -> Bool { - amountCandidate.range(of: #"^\+*\$[\d,]*\.\d\d$"#, options: .regularExpression) != nil + amountCandidate.isMatchedBy(regex: #"^\+*\$[\d,]*\.\d\d$"#) } + /// Determines if the given transaction string is "Declined" static func isDeclinedTransaction(declinedCandidate: String) -> Bool { declinedCandidate.contains("Declined") } + /// Determines if the given transaction string is "Pending" static func isPendingTransaction(pendingCandidate: String) -> Bool { pendingCandidate.contains("Pending") } + /// Determines if the given string contains a valid timestamp + static func isTimestamp(timestampCandidate: String) -> Bool { + return (timestampCandidate.isMatchedBy(regex: "[0-9]{1,2} (?:minute|hour)s{0,1} ago") || // relative timestamp + timestampCandidate.isMatchedBy(regex: "\\d{1,2}\\/\\d{1,2}\\/\\d{2}") || // mm/dd/yy date stamp + timestampCandidate.isMatchedBy(regex: "(?i)W*(?:Mon|Tues|Wednes|Thurs|Fri|Satur|Sun|Yester)day\\b[sS]*")) // Day of week, including "Yesterday" + + } + // TODO: Refactor this to a better place + /// Iterates over the ocrText array to process the raw text into an IntermediateTransaction mutating func nextTransaction() -> IntermediateTransaction? { if Self.debug { print(self.debugDescription) @@ -67,7 +81,7 @@ fileprivate extension Array where Element == String { return nil } - // Sometimes payee names get broken into additiona lines + // Sometimes payee names get broken into additional lines // Keep iterating until we find a valid transaction amount var foundAmount: String? while foundAmount == nil { @@ -100,7 +114,7 @@ fileprivate extension Array where Element == String { foundMemo = pop() } - guard let memo = foundMemo else { return nil } + guard var memo = foundMemo else { return nil } // Not all transactions have daily cash rewards var dailyCash: String? @@ -110,13 +124,32 @@ fileprivate extension Array where Element == String { dailyCash = pop() } while !(dailyCash?.contains { $0 == "%" } ?? true) } - + + // Sometimes "ago" winds up on the next line and separators from Family Sharing mess with the timestamp. + // Keep building the string until it contains a valid time stamp. guard var timeDescription = baTimeDescription ?? pop() else { return nil } - - // sometimes "ago" ends up on the next line - if timeDescription.contains("hour") && !timeDescription.contains("ago") { + while (!Self.isTimestamp(timestampCandidate: timeDescription) && count > 0) { timeDescription = timeDescription + " " + (pop() ?? "") } + timeDescription = timeDescription + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: "•", with: " ") + + // Attempt to remove family member's name from description when using Family Sharing. + // ex. "NAME - Yesterday" + // If the description contains spaces and does not start with a number, it likely starts with the family member's name. + if timeDescription.contains(" ") && !timeDescription.isMatchedBy(regex: "^[0-9]"){ + let splitTimeDescription = timeDescription + .split(separator: " ", maxSplits: 1) + // Prepend the family member to the memo. + let familyMember = String(splitTimeDescription[0]) + memo = familyMember + " - " + memo + // If string contains spaces and does not start with a number. + timeDescription = splitTimeDescription[1] // Get everything after the first space + .trimmingCharacters(in: .whitespaces) // Trim whitespace + } + + // Check if declined and pending let declined = Self.isDeclinedTransaction(declinedCandidate: memo) @@ -266,7 +299,7 @@ extension IntermediateTransaction { return nil } - return baseDate.date(byAddingHours: -minutes) + return baseDate.date(byAddingMinutes: -minutes) } func leadingValue(in string: String, containing: String) -> Int? { diff --git a/Sources/CardVision/Models/TransactionImage.swift b/Sources/CardVision/Models/TransactionImage.swift index 5f10f51..58fbd0f 100644 --- a/Sources/CardVision/Models/TransactionImage.swift +++ b/Sources/CardVision/Models/TransactionImage.swift @@ -31,7 +31,7 @@ public extension TransactionImage { } let urlCreationDate = try? url.resourceValues(forKeys: [.creationDateKey]).creationDate - creationDate = urlCreationDate ?? Date() + creationDate = urlCreationDate ?? Date.current imageURL = url self.image = image diff --git a/Tests/CardVisionTests/Extensions/Date+mock.swift b/Tests/CardVisionTests/Extensions/Date+mock.swift new file mode 100644 index 0000000..ef2f8ea --- /dev/null +++ b/Tests/CardVisionTests/Extensions/Date+mock.swift @@ -0,0 +1,21 @@ +// +// Date+mock.swift +// +// Overrides the current date to the set mockDate. +// Adapted from: https://dev.to/ivanmisuno/deterministic-unit-tests-for-current-date-dependent-code-in-swift-2h72 +// Created by Alex Wolfe on 6/18/22. +// + +import Foundation +@testable import CardVision + +// Mock current date to simplify testing +extension Date { + + // Monday, January 1, 2018 12:00:00 PM UTC + static var mockDate: Date = Date(timeIntervalSinceReferenceDate: 536500800) + + static func overrideCurrentDate(_ currentDate: @autoclosure @escaping () -> Date) { + __date_currentImpl = currentDate + } +} diff --git a/Tests/CardVisionTests/TransactionReaderOCRTextTests.swift b/Tests/CardVisionTests/TransactionReaderOCRTextTests.swift new file mode 100644 index 0000000..3acb5d8 --- /dev/null +++ b/Tests/CardVisionTests/TransactionReaderOCRTextTests.swift @@ -0,0 +1,89 @@ +// +// TransactionReaderOCRTextTests.swift +// +// +// Created by Alex Wolfe on 6/12/22. +// + +import XCTest +@testable import CardVision + +final class TransactionReaderOCRTextTests: XCTestCase { + + var ocrText:[String]! + + override func setUp() { + super.setUp() + + // mockDate is Monday, January 1, 2018 12:00:00 PM UTC + Date.overrideCurrentDate(Date.mockDate) + ocrText = [ + "Transaction 1", "$111.11", "Pending", "Card Number Used", "2%", "15 minutes", "ago", // test extra splitting on "ago" + "Transaction 2", "$222.22", "Somewhere, USA", "2%", "2 hours ago", + "Transaction 3", "$333.33", "Somewhere, USA", "1%", "Monday", // Monday resolves to the current day in these tests, (However, it would not be used to describe current day in Apple Wallet.) + "Transaction 4", "$444.44", "Somewhere, USA", "1%", "Yesterday", + "Transaction 5", "$555.55", "Somewhere, USA", "1%", "Saturday", + "Transaction 6", "$666.66", "Somewhere, USA", "1%", "Friday", + "Transaction 7", "$777.77", "Somewhere, USA", "1%", "Thursday", + "Transaction 8", "$888.88", "Somewhere, USA", "1%", "Wednesday", + "Transaction 9", "$999.99", "Somewhere, USA", "1%", "Tuesday", + "Transaction 10", "$1000.00", "Somewhere, USA", "1%", "12/25/2017", + ] + + } + + + func testTransactionText() { + let transactions = ocrText.parseTransactions(screenshotDate: Date.current) + verifyTransactionDates(transactions: transactions) + + // Verify the memo of each transaction: + XCTAssertEqual("Pending", transactions[0].memo) + let expectedMemo = "Somewhere, USA" + for i in 1...transactions.count-1 { + XCTAssertEqual(expectedMemo, transactions[i].memo) + } + } + + /// Verifies transaction parsing when using family sharing with variations on how the partner name is parsed. + func testTransactionTextFamilySharing() { + ocrText = [ + "Transaction 1", "$111.11", "Pending", "Card Number Used", "2%", "Partner - 15 minutes", "ago", // test extra splitting on "ago" + "Transaction 2", "$222.22", "Somewhere, USA", "2%", "Partner", "2 hours ago", // Separate partner field + "Transaction 3", "$333.33", "Somewhere, USA", "1%", "Partner - Monday", // with dash + "Transaction 4", "$444.44", "Somewhere, USA", "1%", "Partner • Yesterday", // with dot instead of dash + "Transaction 5", "$555.55", "Somewhere, USA", "1%", "Partner Saturday", // no separator + "Transaction 6", "$666.66", "Somewhere, USA", "1%", "Partner - Friday", + "Transaction 7", "$777.77", "Somewhere, USA", "1%", "-", "Partner Thursday", // extra dash before + "Transaction 8", "$888.88", "Somewhere, USA", "1%", "Partner", "Wednesday", + "Transaction 9", "$999.99", "Somewhere, USA", "1%", "-", "Partner", "Tuesday", //extra dash before and separator + "Transaction 10", "$1000.00", "Somewhere, USA", "1%", "Partner", "12/25/2017" + ] + let transactions = ocrText.parseTransactions(screenshotDate: Date.current) + verifyTransactionDates(transactions: transactions) + + // Verify the memo of each transaction: + XCTAssertEqual("Partner - Pending", transactions[0].memo) + let expectedMemo = "Partner - Somewhere, USA" + for i in 1...transactions.count-1 { + XCTAssertEqual(expectedMemo, transactions[i].memo) + } + } + + /// Helper method to make sure that the default transaction dates are correct. + func verifyTransactionDates(transactions: [Transaction]) { + XCTAssertEqual(Date.current.date(byAddingMinutes: -15), transactions[0].date) + XCTAssertEqual(Date.current.date(byAddingHours: -2), transactions[1].date) + XCTAssertEqual(Date.current, transactions[2].date) + for i in 3...transactions.count-1 { + let daysAgo = 2-i + XCTAssert(Calendar.current.isDate(Date.current.date(byAddingDays: daysAgo), inSameDayAs: transactions[i].date)) + } + + } + + static var allTests = [( + "testTransactionText", testTransactionText, + "testTransactionTextFamilySharing", testTransactionTextFamilySharing + )] +}