Skip to content
This repository was archived by the owner on May 16, 2024. It is now read-only.
Open
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
21 changes: 21 additions & 0 deletions Sources/CardVision/Extensions/Date+current.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
15 changes: 15 additions & 0 deletions Sources/CardVision/Extensions/String+isMatchedBy.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
49 changes: 41 additions & 8 deletions Sources/CardVision/Logic/TransactionReaderOCRText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Foundation

typealias TransactionReaderOCRText = [String]


extension TransactionReaderOCRText {
func parseTransactions(screenshotDate: Date) -> [Transaction] {
var ocrText = self
Expand All @@ -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"]
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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?
Expand All @@ -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)
Expand Down Expand Up @@ -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? {
Expand Down
2 changes: 1 addition & 1 deletion Sources/CardVision/Models/TransactionImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions Tests/CardVisionTests/Extensions/Date+mock.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
89 changes: 89 additions & 0 deletions Tests/CardVisionTests/TransactionReaderOCRTextTests.swift
Original file line number Diff line number Diff line change
@@ -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
)]
}