diff --git a/handler/tests/test_transactions.py b/handler/tests/test_transactions.py index c1dfb9d..e4e3625 100644 --- a/handler/tests/test_transactions.py +++ b/handler/tests/test_transactions.py @@ -45,6 +45,7 @@ class TestTransactions(TestCase): # Mock the rates self.transactions.money = MagicMock() + self.transactions.money.to_usd = self.mock_to_usd self.transactions.money.get_rates_all = MagicMock() self.transactions.money.get_rates_all.return_value = {"GBP": 0.8} @@ -119,6 +120,17 @@ class TestTransactions(TestCase): if trade["id"] == string: return trade["reference"] + def mock_to_usd(self, amount, currency): + if currency == "GBP": + return amount * 1.3 + elif currency == "USD": + return amount + # fuck it who cares + elif currency == "SEK": + return 100 + elif currency == "EUR": + return 10 + def test_transaction(self): self.transactions.transaction(self.test_data) self.transactions.release_funds.assert_called_once_with("uuid1", "TEST-1") @@ -154,7 +166,7 @@ class TestTransactions(TestCase): def test_transaction_no_reference_pass(self): no_reference_pass = self.data_custom(1, "GBP", "none") - no_reference_pass["meta"]["provider_reference"] = "none" + no_reference_pass["meta"]["provider_reference"] = "THIS_ONE_FAILS" self.return_trades = [1] self.transactions.transaction(no_reference_pass) diff --git a/handler/transactions.py b/handler/transactions.py index 4d9a3d3..e21a933 100644 --- a/handler/transactions.py +++ b/handler/transactions.py @@ -67,49 +67,64 @@ class Transactions(util.Base): self.lc_es_checks.start(delay) self.agora.es = self.es - # TODO: write tests then refactor, this is terribly complicated! - def transaction(self, data): + def valid_transaction(self, data): """ - Store details of transaction and post notifications to IRC. - Matches it up with data stored in Redis to attempt to reconcile with an Agora trade. - :param data: details of transaction + Determine if a given transaction object is valid. + :param data: a transaction cast :type data: dict + :return: whether the transaction is valid + :rtype: bool """ - print(f"Raw transaction data: {data}") - ts = data["timestamp"] txid = data["transaction_id"] if "amount" not in data: - return + return False if "currency" not in data: - return + return False amount = data["amount"] if amount <= 0: self.log.info(f"Ignoring transaction with negative/zero amount: {txid}") - return - currency = data["currency"] + return False + return True + def extract_reference(self, data): + """ + Extract a reference from the transaction cast. + :param data: a transaction cast + :type data: dict + :return: the extracted reference or not_set + :rtype: str + """ if "reference" in data: - reference = data["reference"] + return data["reference"] elif "meta" in data: if "provider_reference" in data["meta"]: - reference = data["meta"]["provider_reference"] - else: - reference = "not_set" - else: - reference = "not_set" + return data["meta"]["provider_reference"] + return "not_set" - subclass = data["subclass"] - to_store = { - "trade_id": "", - "subclass": subclass, - "ts": ts, - "txid": txid, - "reference": reference, - "amount": amount, - "currency": currency, - } - self.log.info(f"Transaction processed: {dumps(to_store, indent=2)}") - self.irc.sendmsg(f"AUTO Incoming transaction on {subclass}: {amount}{currency} ({reference})") + def extract_sender(self, data): + """ + Extract a sender name from the transaction cast. + :param data: a transaction cast + :type data: dict + :return: the sender name or not_set + :rtype: str + """ + if "debtorName" in data: + return data["debtorName"] + elif "meta" in data: + if "debtor_account_name" in data["meta"]: + return data["meta"]["debtor_account_name"] + return "not_set" + + def reference_partial_check(self, reference, txid, currency, amount): + """ + Perform a partial check by intersecting all parts of the split of the + reference against the existing references, and returning a set of the matches. + :param reference: the reference to check + :type reference: str + :return: matching trade ID string + :rtype: str + """ # Partial reference implementation # Account for silly people not removing the default string # Split the reference into parts @@ -122,74 +137,142 @@ class Transactions(util.Base): self.log.error(f"Multiple references valid for TXID {txid}: {reference}") self.irc.sendmsg(f"Multiple references valid for TXID {txid}: {reference}") self.ux.notify.notify_tx_lookup_failed(currency, amount, reference, "MULTIPLE_REFS_MATCH") + return False + if len(stored_trade_reference) == 0: + return None + return stored_trade_reference.pop() + + def can_alt_lookup(self, amount, currency, reference): + amount_usd = self.money.to_usd(amount, currency) + # Amount is reliable here as it is checked by find_trade, so no need for stored_trade["amount"] + if float(amount_usd) > float(settings.Agora.AcceptableAltLookupUSD): + self.log.info("Not checking against amount and currency as amount exceeds MAX") + self.irc.sendmsg(f"Not checking against amount and currency as amount exceeds MAX") + # Close here if the amount exceeds the allowable limit for no reference + self.ux.notify.notify_tx_lookup_failed(currency, amount, reference, "EXCEEDS_MAX") + return False + return True + + def amount_currency_lookup(self, amount, currency, txid, reference): + self.log.info(f"No reference in DB refs for {reference}") + self.irc.sendmsg(f"No reference in DB refs for {reference}") + # Try checking just amount and currency, as some people (usually people buying small amounts) + # are unable to put in a reference properly. + + self.log.info(f"Checking against amount and currency for TXID {txid}") + self.irc.sendmsg(f"Checking against amount and currency for TXID {txid}") + if not self.can_alt_lookup(amount, currency, reference): + return False + stored_trade = self.find_trade(txid, currency, amount) + if not stored_trade: + self.log.info(f"Failed to get reference by amount and currency: {txid} {currency} {amount}") + self.irc.sendmsg(f"Failed to get reference by amount and currency: {txid} {currency} {amount}") + self.ux.notify.notify_tx_lookup_failed(currency, amount, reference, "ALT_LOOKUP_FAILED") + return None + stored_trade["amount"] = float(stored_trade["amount"]) # convert to float + return stored_trade + + def normal_lookup(self, stored_trade_reference, reference, currency, amount): + stored_trade = self.get_ref(stored_trade_reference) + if not stored_trade: + self.log.info(f"No reference in DB for {reference}") + self.irc.sendmsg(f"No reference in DB for {reference}") + self.ux.notify.notify_tx_lookup_failed(currency, amount, reference, "NOREF", stored_trade_reference) + return False + stored_trade["amount"] = float(stored_trade["amount"]) # convert to float + return stored_trade + + def currency_check(self, currency, amount, reference, stored_trade): + if not stored_trade["currency"] == currency: + self.log.info(f"Currency mismatch, Agora: {stored_trade['currency']} / Sink: {currency}") + self.irc.sendmsg(f"Currency mismatch, Agora: {stored_trade['currency']} / Sink: {currency}") + self.ux.notify.notify_tx_lookup_failed(currency, amount, reference, "CURRENCY_MISMATCH", stored_trade["id"]) + return False + return True + + def alt_amount_check(self, amount, currency, reference, stored_trade): + # If the amount does not match exactly, get the min and max values for our given acceptable margins for trades + min_amount, max_amount = self.money.get_acceptable_margins(currency, stored_trade["amount"]) + self.log.info(f"Amount does not match exactly, trying with margins: min: {min_amount} / max: {max_amount}") + self.irc.sendmsg(f"Amount does not match exactly, trying with margins: min: {min_amount} / max: {max_amount}") + if not min_amount < amount < max_amount: + self.log.info("Amount mismatch - not in margins: {stored_trade['amount']} (min: {min_amount} / max: {max_amount}") + self.irc.sendmsg(f"Amount mismatch - not in margins: {stored_trade['amount']} (min: {min_amount} / max: {max_amount}") + self.ux.notify.notify_tx_lookup_failed(currency, amount, reference, "AMOUNT_MARGIN_MISMATCH", stored_trade["id"]) + return False + return True + + def transaction(self, data): + """ + Store details of transaction and post notifications to IRC. + Matches it up with data stored in Redis to attempt to reconcile with an Agora trade. + :param data: details of transaction + :type data: dict + """ + valid = self.valid_transaction(data) + if not valid: + return False + print(f"Raw transaction data: {data}") + ts = data["timestamp"] + txid = data["transaction_id"] + amount = float(data["amount"]) + currency = data["currency"] + + reference = self.extract_reference(data) + sender = self.extract_sender(data) + + subclass = data["subclass"] + to_store = { + "trade_id": "", + "subclass": subclass, + "ts": ts, + "txid": txid, + "reference": reference, + "amount": amount, + "currency": currency, + "sender": sender, + } + self.log.info(f"Transaction processed: {dumps(to_store, indent=2)}") + self.irc.sendmsg(f"AUTO Incoming transaction on {subclass}: {amount}{currency} ({reference})") + + stored_trade_reference = self.reference_partial_check(reference, txid, currency, amount) + if stored_trade_reference is False: # can be None though return stored_trade = False looked_up_without_reference = False - # Amount/currency lookup implementation - if not stored_trade_reference: - self.log.info(f"No reference in DB refs for {reference}") - self.irc.sendmsg(f"No reference in DB refs for {reference}") - # Try checking just amount and currency, as some people (usually people buying small amounts) - # are unable to put in a reference properly. + # Normal implementation for when we have a reference + if stored_trade_reference: + stored_trade = self.normal_lookup(stored_trade_reference, reference, currency, amount) + # if not stored_trade: + # return - self.log.info(f"Checking against amount and currency for TXID {txid}") - self.irc.sendmsg(f"Checking against amount and currency for TXID {txid}") - stored_trade = self.find_trade(txid, currency, amount) - if not stored_trade: - self.log.info(f"Failed to get reference by amount and currency: {txid} {currency} {amount}") - self.irc.sendmsg(f"Failed to get reference by amount and currency: {txid} {currency} {amount}") - self.ux.notify.notify_tx_lookup_failed(currency, amount, reference, "ALT_LOOKUP_FAILED") - return - if currency == "USD": - amount_usd = amount - else: - rates = self.money.get_rates_all() - amount_usd = amount / rates[currency] - # Amount is reliable here as it is checked by find_trade, so no need for stored_trade["amount"] - if float(amount_usd) > float(settings.Agora.AcceptableAltLookupUSD): - self.log.info("Not checking against amount and currency as amount exceeds MAX") - self.irc.sendmsg(f"Not checking against amount and currency as amount exceeds MAX") - # Close here if the amount exceeds the allowable limit for no reference - if len(stored_trade_reference) == 1: # better safe than sorry - self.ux.notify.notify_tx_lookup_failed(currency, amount, reference, "EXCEEDS_MAX", stored_trade_reference[0]) + # Amount/currency lookup implementation for when we have no reference + else: + if not stored_trade: # check we don't overwrite the lookup above + stored_trade = self.amount_currency_lookup(amount, currency, txid, reference) + if stored_trade is False: + return + if stored_trade: + # Note that we have looked it up without reference so we don't use +- below + # This might be redundant given the amount checks in find_trade, but better safe than sorry! + looked_up_without_reference = True else: - self.ux.notify.notify_tx_lookup_failed(currency, amount, reference, "EXCEEDS_MAX") + return + else: + # Stored trade reference is none, the checks below will do nothing at all return - # Note that we have looked it up without reference so we don't use +- below - # This might be redundant given the amount checks in find_trade, but better safe than sorry! - looked_up_without_reference = True - if not stored_trade: - stored_trade = self.get_ref(stored_trade_reference.pop()) - if not stored_trade: - self.log.info(f"No reference in DB for {reference}") - self.irc.sendmsg(f"No reference in DB for {reference}") - self.ux.notify.notify_tx_lookup_failed(currency, amount, reference, "NOREF", stored_trade["id"]) - return - - amount = float(amount) - stored_trade["amount"] = float(stored_trade["amount"]) # Make sure it was sent in the expected currency - if not stored_trade["currency"] == currency: - self.log.info(f"Currency mismatch, Agora: {stored_trade['currency']} / Sink: {currency}") - self.irc.sendmsg(f"Currency mismatch, Agora: {stored_trade['currency']} / Sink: {currency}") - self.ux.notify.notify_tx_lookup_failed(currency, amount, reference, "CURRENCY_MISMATCH", stored_trade["id"]) + if not self.currency_check(currency, amount, reference, stored_trade): return # Make sure the expected amount was sent if not stored_trade["amount"] == amount: if looked_up_without_reference: return - # If the amount does not match exactly, get the min and max values for our given acceptable margins for trades - min_amount, max_amount = self.money.get_acceptable_margins(currency, stored_trade["amount"]) - self.log.info(f"Amount does not match exactly, trying with margins: min: {min_amount} / max: {max_amount}") - self.irc.sendmsg(f"Amount does not match exactly, trying with margins: min: {min_amount} / max: {max_amount}") - if not min_amount < amount < max_amount: - self.log.info("Amount mismatch - not in margins: {stored_trade['amount']} (min: {min_amount} / max: {max_amount}") - self.irc.sendmsg(f"Amount mismatch - not in margins: {stored_trade['amount']} (min: {min_amount} / max: {max_amount}") - self.ux.notify.notify_tx_lookup_failed(currency, amount, reference, "AMOUNT_MARGIN_MISMATCH", stored_trade["id"]) + if not self.alt_amount_check(amount, currency, reference, stored_trade): return r.hmset(f"tx.{txid}", to_store) @@ -263,11 +346,8 @@ class Transactions(util.Base): matching_refs = [] # TODO: use get_ref_map in this function instead of calling get_ref multiple times for ref in refs: - print(f"ITER REF {ref}") stored_trade = self.get_ref(ref) - print(f"ITER REF STORED TRADE {stored_trade}") if stored_trade["currency"] == currency and float(stored_trade["amount"]) == float(amount): - print(f"APPENDING STORED TRADE AS MATCH {stored_trade}") matching_refs.append(stored_trade) if len(matching_refs) != 1: self.log.error(f"Find trade returned multiple results for TXID {txid}: {matching_refs}")