Skip to content

Commit

Permalink
Represent Transfers as subtype of PlaidFireflyTransaction
Browse files Browse the repository at this point in the history
  • Loading branch information
nprzy committed Jul 5, 2024
1 parent 6301282 commit 32687b8
Show file tree
Hide file tree
Showing 4 changed files with 118 additions and 112 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import net.djvk.fireflyPlaidConnector2.api.firefly.models.TransactionTypePropert
import net.djvk.fireflyPlaidConnector2.api.plaid.models.Transaction
import java.time.OffsetDateTime
import java.time.ZoneId
import java.util.function.Supplier
import kotlin.math.abs

/**
* Represents a single non-transfer transaction, including both the Plaid and the Firefly representation of it.
* Represents a single transaction, including both the Plaid and the Firefly representation of it.
*
* See the descriptions of the individual subtypes for more details about the situations in which each is used.
*/
Expand Down Expand Up @@ -123,7 +125,9 @@ sealed interface PlaidFireflyTransaction {

/**
* This subtype is used when we've received a transaction from Plaid that already has a corresponding transaction
* in Firefly. Note that this is NOT related to
* in Firefly. Note that this is NOT related to the matching of two discrete transactions from two different
* accounts into a "transfer". Both the Firefly and Plaid transactions within this object will be related to the
* same account.
*/
data class MatchedTransaction(
override val plaidTransaction: Transaction,
Expand All @@ -136,4 +140,48 @@ sealed interface PlaidFireflyTransaction {
return getPlaidTransactionDate(plaidTransaction, zoneId)
}
}

/**
* This subtype represents a pair of two transactions that we believe to be a transfer from one account into a
* different account.
*/
data class Transfer private constructor(
val deposit: PlaidFireflyTransaction,
val withdrawal: PlaidFireflyTransaction,
): PlaidFireflyTransaction {
companion object {
fun create(first: PlaidFireflyTransaction, second: PlaidFireflyTransaction): Transfer {
if ((first.amount > 0) == (second.amount > 0)) {
throw IllegalArgumentException("A transfer must have one withdrawal and one deposit")
}
if (abs(first.amount) != abs(second.amount)) {
throw IllegalArgumentException("A transfer must have the same withdrawal and deposit amounts")
}
if (first.fireflyAccountId == second.fireflyAccountId) {
throw IllegalArgumentException("A transfer must not have the same Firefly account IDs")
}

return if (first.amount >= 0) {
Transfer(
deposit = first,
withdrawal = second,
)
} else {
Transfer(
deposit = second,
withdrawal = first,
)
}
}
}

override val plaidTransaction = deposit.plaidTransaction ?: withdrawal.plaidTransaction
override val fireflyTransaction = deposit.fireflyTransaction ?: withdrawal.fireflyTransaction
override val amount = abs(deposit.amount)
override val transactionId = deposit.transactionId
override val fireflyAccountId get() = throw RuntimeException("Can not get Firefly account ID for a transfer")
override fun getTimestamp(zoneId: ZoneId): OffsetDateTime {
return deposit.getTimestamp(zoneId)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,6 @@ class TransactionConverter(
private fun requirePlaidTransaction(tx: PlaidFireflyTransaction?): PlaidTransaction {
return tx?.plaidTransaction ?: throw RuntimeException("Transaction missing required Plaid information")
}

private fun requireFireflyTransaction(tx: PlaidFireflyTransaction?): FireflyTransactionDto {
return tx?.fireflyTransaction ?: throw RuntimeException("Transaction missing required Firefly information")
}
}

fun getTxTimestamp(tx: PlaidTransaction): OffsetDateTime {
Expand Down Expand Up @@ -148,24 +144,20 @@ class TransactionConverter(
accountMap: Map<PlaidAccountId, FireflyAccountId>,
): List<FireflyTransactionDto> {
logger.debug("Batch sync converting Plaid transactions to Firefly transactions")
val (singles, pairs) = transferMatcher.match(PlaidFireflyTransaction.normalizeByTransactionId(txs, listOf(), accountMap))
val out = mutableListOf<FireflyTransactionDto>()

for (single in singles) {
out.add(convertSingle(requirePlaidTransaction(single), accountMap))
}

for (pair in pairs) {
out.add(
convertDoublePlaid(
requirePlaidTransaction(pair.first),
requirePlaidTransaction(pair.second),
accountMap
)
)
return transferMatcher.match(PlaidFireflyTransaction.normalizeByTransactionId(txs, listOf(), accountMap)).map {
when (it) {
is PlaidFireflyTransaction.Transfer -> {
convertDoublePlaid(
requirePlaidTransaction(it.withdrawal),
requirePlaidTransaction(it.deposit),
accountMap
)
}
else -> {
convertSingle(requirePlaidTransaction(it), accountMap)
}
}
}

return out
}

data class ConvertPollSyncResult(
Expand All @@ -190,8 +182,6 @@ class TransactionConverter(
): ConvertPollSyncResult {
logger.trace("Starting ${::convertPollSync.name}")
val transferCandidateExistingFireflyTxs = filterFireflyCandidateTransferTxs(existingFireflyTxs)
val createdSet = plaidCreatedTxs.toSet()
val updatedSet = plaidUpdatedTxs.toSet()

val creates = mutableListOf<FireflyTransactionDto>()
val updates = mutableListOf<FireflyTransactionDto>()
Expand All @@ -201,29 +191,57 @@ class TransactionConverter(
* Don't pass in [plaidUpdatedTxs] here because we're not going to try to update transfers for now
* because it's more complexity than I want to deal with, and I haven't seen any Plaid updates in the wild yet
*/
val (singles, pairs) = transferMatcher.match(
val wrappedCreates = transferMatcher.match(
PlaidFireflyTransaction.normalizeByTransactionId(plaidCreatedTxs, transferCandidateExistingFireflyTxs, accountMap)
)
logger.debug("${::convertPollSync.name} call to transferMatcher returned ${singles.size} singles and ${pairs.size} pairs")
logger.debug(
"{} call to transferMatcher returned {} transactions",
::convertPollSync.name,
wrappedCreates.size,
)

/**
* Handle singles, which are transactions that didn't have any transfer pair matches
*/
for (single in singles) {
val convertedSingle = when (single) {
is PlaidFireflyTransaction.PlaidTransaction -> convertSingle(single.plaidTransaction, accountMap)
for (create in wrappedCreates) {
val convertedSingle = when (create) {
is PlaidFireflyTransaction.PlaidTransaction -> convertSingle(create.plaidTransaction, accountMap)

// In both of these cases a Firefly transaction already exists. We don't need to do anything to it.
// If we have an associated Plaid transaction, log a message. Otherwise, silently ignore it.
is PlaidFireflyTransaction.FireflyTransaction -> continue
is PlaidFireflyTransaction.MatchedTransaction -> {
logger.debug(
"Ignoring Plaid transaction id {} because it already has a corresponding Firefly transaction {}",
single.plaidTransaction.transactionId,
single.fireflyTransaction.id,
create.plaidTransaction.transactionId,
create.fireflyTransaction.id,
)
continue
}

is PlaidFireflyTransaction.Transfer -> {
if (create.withdrawal.fireflyTransaction != null && create.deposit.fireflyTransaction != null) {
logger.debug("TransferMatcher found multiple existing Firefly transactions that appear to "
+ "be a transfer. Converting multiple existing Firefly transactions to a transfer is "
+ "not supported. Skipping: {}", create)
continue
}

val fireflyComponent = create.fireflyTransaction
if (fireflyComponent != null) {
convertDoubleFirefly(
requirePlaidTransaction(create),
fireflyComponent,
accountMap,
)
} else {
convertDoublePlaid(
requirePlaidTransaction(create.deposit),
requirePlaidTransaction(create.withdrawal),
accountMap,
)
}
}
}

if (convertedSingle.id == null) {
Expand All @@ -233,48 +251,6 @@ class TransactionConverter(
}
}

/**
* Handle pairs that we think should become Firefly transfers
*/
for (pair in pairs) {
val (hasFireflyTx, noFireflyTx) = pair.toList().partition { it.fireflyTransaction != null }
if (hasFireflyTx.size > 1) {
logger.debug("TransferMatcher found multiple existing Firefly transactions that appear to "
+ "be a transfer. Converting multiple existing Firefly transactions to a transfer is not "
+ "supported. Skipping: {}", pair)
continue
}

val plaidComponent = requirePlaidTransaction(noFireflyTx.getOrNull(0))

val out = when {
hasFireflyTx.isNotEmpty() ->
convertDoubleFirefly(
plaidComponent,
requireFireflyTransaction(hasFireflyTx.getOrNull(0)),
accountMap,
)

else ->
convertDoublePlaid(
plaidComponent,
requirePlaidTransaction(noFireflyTx.getOrNull(1)),
accountMap,
)
}

if (out.id != null) {
updates.add(out)
} else if (createdSet.contains(plaidComponent)) {
// TODO: what happens if the pair is two Plaid transactions, one create and one update?
creates.add(out)
} else if (updatedSet.contains(plaidComponent)) {
updates.add(out)
} else {
throw IllegalArgumentException("Unable to determine create/update status of sorted pair: $pair")
}
}

val indexer = FireflyTransactionExternalIdIndexer(existingFireflyTxs)
/**
* Handle Plaid updates
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,14 @@ class TransferMatcher(
PersonalFinanceCategoryEnum.Primary.BANK_FEES,
)

data class SortByPairsResult(
val singles: List<PlaidFireflyTransaction>,
val pairs: List<Pair<PlaidFireflyTransaction, PlaidFireflyTransaction>>,
)

/**
* Identify matching transaction pairs that can be converted to a single "transfer" in Firefly.
*
* Note that this method will not perform any filtering. The caller is expected to filter-out any transactions
* that it would not make sense to act on, such as matching pairs of Firefly transactions that do not have
* corresponding Plaid transactions.
*/
fun match(txs: List<PlaidFireflyTransaction>): SortByPairsResult {
fun match(txs: List<PlaidFireflyTransaction>): List<PlaidFireflyTransaction> {
logger.trace("Starting ${::match.name}")

// Split-out the transactions that are unlikely to be transfers based on their category. If we're not sure,
Expand All @@ -51,8 +46,7 @@ class TransferMatcher(
category == null || transferTypes.contains(PersonalFinanceCategoryEnum.from(category).primary)
}

val pairsOut = mutableListOf<Pair<PlaidFireflyTransaction, PlaidFireflyTransaction>>()
val singlesOut = nonTransfers.toMutableList()
val results = nonTransfers.toMutableList()

val amountIndexedTxs = possibleTransfers.groupBy { it.amount }
// The loop below will process an amount value and its inverse, so we use this to mark the inverse
Expand All @@ -71,7 +65,7 @@ class TransferMatcher(

// If there are no matching txs, then this group has no soulmates and we should move on
if (matchingGroupTxs == null) {
singlesOut.addAll(groupTxs)
results.addAll(groupTxs)
continue
}
val txsSecondsDiff = mutableListOf<CandidatePair>()
Expand Down Expand Up @@ -123,15 +117,15 @@ class TransferMatcher(
}.forEach { (_, aTx, bTx) ->
logger.trace("${::match.name} found valid pair with timestamps ${aTx.getTimestamp(zoneId)};" +
"${bTx.getTimestamp(zoneId)} and amount $amount")
pairsOut.add(Pair(aTx, bTx))
results.add(PlaidFireflyTransaction.Transfer.create(aTx, bTx))
}

// Output all leftover transactions as singles
singlesOut.addAll(groupTxs.filter { !usedATxIds.contains(it.transactionId) })
singlesOut.addAll(matchingGroupTxs.filter { !usedBTxIds.contains(it.transactionId) })
results.addAll(groupTxs.filter { !usedATxIds.contains(it.transactionId) })
results.addAll(matchingGroupTxs.filter { !usedBTxIds.contains(it.transactionId) })
}

return SortByPairsResult(singlesOut, pairsOut)
return results
}

private data class CandidatePair(
Expand Down
Loading

0 comments on commit 32687b8

Please sign in to comment.