From 7d1bd75f482ca21c5594973c0101ceec20b8f3d0 Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Mon, 13 Mar 2023 18:49:47 +0000 Subject: [PATCH] Implement transaction handling --- core/clients/aggregator.py | 443 +++++++------ core/clients/aggregators/nordigen.py | 2 +- core/clients/platform.py | 92 ++- core/clients/platforms/agora.py | 16 +- core/clients/platforms/api/agoradesk.py | 15 +- core/lib/antifraud.py | 84 +-- core/lib/money.py | 39 +- core/management/commands/scheduling.py | 13 +- ...ion_transaction_id_transaction_ts_added.py | 26 + core/migrations/0023_alter_trade_linked.py | 18 + core/models.py | 101 ++- core/tests/test_transactions.py | 587 ++++++++++++++++++ 12 files changed, 1108 insertions(+), 328 deletions(-) create mode 100644 core/migrations/0022_transaction_transaction_id_transaction_ts_added.py create mode 100644 core/migrations/0023_alter_trade_linked.py create mode 100644 core/tests/test_transactions.py diff --git a/core/clients/aggregator.py b/core/clients/aggregator.py index 8983540..f374ee6 100644 --- a/core/clients/aggregator.py +++ b/core/clients/aggregator.py @@ -1,10 +1,8 @@ from abc import ABC -import orjson - -from core.lib import db, notify - -# from core.lib.money import money +from core.clients.platforms.agora import AgoraClient +from core.lib import notify +from core.lib.money import money from core.util import logs log = logs.get_logger("aggregator") @@ -53,29 +51,55 @@ class AggregatorClient(ABC): async def process_transactions(self, account_id, transactions): if not transactions: return False - transaction_ids = [x["transaction_id"] for x in transactions] - new_key_name = f"new.transactions.{self.instance.id}.{self.name}.{account_id}" - old_key_name = f"transactions.{self.instance.id}.{self.name}.{account_id}" - # for transaction_id in transaction_ids: - if not transaction_ids: - return - await db.r.sadd(new_key_name, *transaction_ids) - - difference = list(await db.r.sdiff(new_key_name, old_key_name)) - - difference = db.convert(difference) - - new_transactions = [ - x for x in transactions if x["transaction_id"] in difference - ] - - # Rename the new key to the old key so we can run the diff again - await db.r.rename(new_key_name, old_key_name) - for transaction in new_transactions: - transaction["subclass"] = self.name - # self.tx.transaction(transaction) - def valid_transaction(self, data): + platforms = self.platforms + for transaction in transactions: + transaction_id = transaction["transaction_id"] + tx_obj = self.instance.get_transaction( + account_id, + transaction_id, + ) + if tx_obj is None: + tx_cast = { + "transaction_id": transaction_id, + "recipient": transaction["creditorName"], + "sender": transaction["debtorName"], + "amount": transaction["amount"], + "currency": transaction["currency"], + "note": transaction["reference"], + } + tx_obj = self.instance.add_transaction( + account_id, + tx_cast, + ) + # New transaction + await self.transaction(platforms, tx_obj) + else: + # Transaction exists + continue + # transaction_ids = [x["transaction_id"] for x in transactions] + # new_key_name = f"new.transactions.{self.instance.id}.{self.name}.{account_id}" + # old_key_name = f"transactions.{self.instance.id}.{self.name}.{account_id}" + # # for transaction_id in transaction_ids: + # if not transaction_ids: + # return + # await db.r.sadd(new_key_name, *transaction_ids) + + # difference = list(await db.r.sdiff(new_key_name, old_key_name)) + + # difference = db.convert(difference) + + # new_transactions = [ + # x for x in transactions if x["transaction_id"] in difference + # ] + + # # Rename the new key to the old key so we can run the diff again + # await db.r.rename(new_key_name, old_key_name) + # for transaction in new_transactions: + # transaction["subclass"] = self.name + # # self.tx.transaction(transaction) + + def valid_transaction(self, tx_obj): """ Determine if a given transaction object is valid. :param data: a transaction cast @@ -83,56 +107,58 @@ class AggregatorClient(ABC): :return: whether the transaction is valid :rtype: bool """ - txid = data["transaction_id"] - if "amount" not in data: + txid = tx_obj.transaction_id + if tx_obj.amount is None: return False - if "currency" not in data: + if tx_obj.currency is None: return False - amount = data["amount"] + amount = tx_obj.amount if amount <= 0: log.info(f"Ignoring transaction with negative/zero amount: {txid}") 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: - return data["reference"] - elif "meta" in data: - if "provider_reference" in data["meta"]: - return data["meta"]["provider_reference"] - return "not_set" - - 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"] - elif " " in data["reference"]: - refsplit = data["reference"].split(" ") - if not len(refsplit) == 2: - log.error(f"Sender cannot be extracted: {data}") - return "not_set" - realname, part2 = data["reference"].split(" ") - return realname - - return "not_set" - - async def reference_partial_check(self, reference, txid, currency, amount): + # 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: + # return data["reference"] + # elif "meta" in data: + # if "provider_reference" in data["meta"]: + # return data["meta"]["provider_reference"] + # return "not_set" + + # 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"] + # elif " " in data["reference"]: + # refsplit = data["reference"].split(" ") + # if not len(refsplit) == 2: + # log.error(f"Sender cannot be extracted: {data}") + # return "not_set" + # realname, part2 = data["reference"].split(" ") + # return realname + + # return "not_set" + + async def reference_partial_check( + self, platform, 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. @@ -146,9 +172,10 @@ class AggregatorClient(ABC): # Split the reference into parts ref_split = reference.split(" ") # Get all existing references - existing_refs = await db.get_refs() + existing_refs = platform.references # Get all parts of the given reference split that match the existing references - stored_trade_reference = set(existing_refs).intersection(set(ref_split)) + # stored_trade_reference = set(existing_refs).intersection(set(ref_split)) + stored_trade_reference = [x for x in existing_refs if x in ref_split] if len(stored_trade_reference) > 1: message = ( f"Multiple references valid for TXID {txid}: {reference}" @@ -162,38 +189,59 @@ class AggregatorClient(ABC): return stored_trade_reference.pop() # TODO: pass platform here - # async 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): - # message = ( - # f"Amount exceeds max for {reference}" - # f"Currency: {currency} | Amount: {amount}" - # ) - # title = "Amount exceeds max for {reference}" - # await notify.sendmsg(self.instance.user, message, title=title) - # return False - # return True - - async def amount_currency_lookup(self, amount, currency, txid, reference): - 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. - - log.info(f"Checking against amount and currency for TXID {txid}") - self.irc.sendmsg(f"Checking against amount and currency for TXID {txid}") + async def can_alt_lookup(self, platform, amount, currency, reference): + amount_usd = await money.to_usd(amount, currency) + # Amount is reliable here as it is checked by find_trade, + # so no need for stored_trade["amount"] + if amount_usd > platform.no_reference_amount_check_max_usd: + message = ( + f"Amount exceeds max for {reference}" + f"Currency: {currency} | Amount: {amount}" + ) + title = "Amount exceeds max for {reference}" + await notify.sendmsg(self.instance.user, message, title=title) + return False + return True + + def find_trade(self, platform, txid, currency, amount): + """ + Get a trade reference that matches the given currency and amount. + Only works if there is one result. + :param txid: Sink transaction ID + :param currency: currency + :param amount: amount + :type txid: string + :type currency: string + :type amount: int + :return: matching trade object or False + :rtype: dict or bool + """ + refs = platform.references + matching_refs = [] + # TODO: use get_ref_map in this function instead of calling get_ref multiple + # times + for ref in refs: + stored_trade = platform.get_trade_by_reference(ref) + if stored_trade.currency == currency and stored_trade.amount_fiat == amount: + matching_refs.append(stored_trade) + if len(matching_refs) != 1: + log.error( + f"Find trade returned multiple results for TXID {txid}: {matching_refs}" + ) + return False + return matching_refs[0] + + async def amount_currency_lookup(self, platform, amount, currency, txid, ref): title = f"Checking against amount and currency for TXID {txid}" message = ( f"Checking against amount and currency for TXID {txid}" f"Currency: {currency} | Amount: {amount}" ) await notify.sendmsg(self.instance.user, message, title=title) - if not await self.can_alt_lookup(amount, currency, reference): + + if not await self.can_alt_lookup(platform, amount, currency, ref): return False - stored_trade = await self.find_trade(txid, currency, amount) + stored_trade = self.find_trade(platform, txid, currency, amount) if not stored_trade: title = f"Failed to get reference by amount and currency: {txid}" message = ( @@ -202,11 +250,12 @@ class AggregatorClient(ABC): ) await notify.sendmsg(self.instance.user, message, title=title) return None - stored_trade["amount"] = float(stored_trade["amount"]) # convert to float return stored_trade - async def normal_lookup(self, stored_trade_reference, reference, currency, amount): - stored_trade = await db.get_ref(stored_trade_reference) + async def normal_lookup( + self, platform, stored_trade_reference, reference, currency, amount + ): + stored_trade = platform.get_trade_by_reference(stored_trade_reference) if not stored_trade: title = f"No reference in DB for {reference}" message = ( @@ -215,27 +264,25 @@ class AggregatorClient(ABC): ) await notify.sendmsg(self.instance.user, message, title=title) return False - stored_trade["amount"] = float(stored_trade["amount"]) # convert to float + # stored_trade["amount"] = float(stored_trade["amount"]) # convert to float return stored_trade - async def currency_check(self, currency, amount, reference, stored_trade): - if not stored_trade["currency"] == currency: + async def currency_check(self, currency, stored_trade): + if not stored_trade.currency == currency: title = "Currency mismatch" message = ( - f"Currency mismatch, Agora: {stored_trade['currency']} " + f"Currency mismatch, Agora: {stored_trade.currency} " f"/ Sink: {currency}" ) await notify.sendmsg(self.instance.user, message, title=title) return False return True - async def alt_amount_check( - self, platform, amount, currency, reference, stored_trade - ): + async def alt_amount_check(self, platform, amount, currency, 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 = await self.money.get_acceptable_margins( - platform, currency, stored_trade["amount"] + min_amount, max_amount = await money.get_acceptable_margins( + platform, currency, stored_trade.amount_fiat ) log.info( ( @@ -243,7 +290,6 @@ class AggregatorClient(ABC): f" / max: {max_amount}" ) ) - self.irc.sendmsg() title = "Amount does not match exactly" message = ( f"Amount does not match exactly, trying with margins: min: " @@ -253,14 +299,14 @@ class AggregatorClient(ABC): if not min_amount < amount < max_amount: title = "Amount mismatch - not in margins" message = ( - f"Amount mismatch - not in margins: {stored_trade['amount']} " + f"Amount mismatch - not in margins: {stored_trade.amount_fiat} " f"(min: {min_amount} / max: {max_amount}" ) await notify.sendmsg(self.instance.user, message, title=title) return False return True - async def transaction(self, data): + async def transaction(self, platforms, tx_obj): """ Store details of transaction and post notifications to IRC. Matches it up with data stored in Redis to attempt to reconcile with an Agora @@ -268,104 +314,93 @@ class AggregatorClient(ABC): :param data: details of transaction :type data: dict """ - valid = self.valid_transaction(data) + valid = self.valid_transaction(tx_obj) if not valid: return False - 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 = { - "subclass": subclass, - "ts": ts, - "txid": txid, - "reference": reference, - "amount": amount, - "currency": currency, - "sender": sender, - } - db.r.hmset(f"tx.{txid}", to_store) - - log.info(f"Transaction processed: {orjson.dumps(to_store, indent=2)}") - self.irc.sendmsg( - ( - f"AUTO Incoming transaction on {subclass}: {txid} {amount}{currency} " - f"({reference})" - ) - ) + txid = tx_obj.transaction_id + amount = tx_obj.amount + currency = tx_obj.currency - stored_trade_reference = self.reference_partial_check( - reference, txid, currency, amount - ) - if stored_trade_reference is False: # can be None though - return + reference = tx_obj.note - stored_trade = False - looked_up_without_reference = False + # reference = self.extract_reference(data) + # sender = tx_obj.sender - # Normal implementation for when we have a reference - if stored_trade_reference: - stored_trade = self.normal_lookup( - stored_trade_reference, reference, currency, amount + log.info(f"Transaction processed: {tx_obj}") + await notify.sendmsg( + self.instance.user, + (f"Transaction: {txid} {amount}{currency}: {reference}"), + title="Incoming transaction", + ) + for platform in platforms: + stored_trade_reference = await self.reference_partial_check( + platform, reference, txid, currency, amount ) - # if not stored_trade: - # return + if stored_trade_reference is False: # can be None though + continue + + stored_trade = False + looked_up_without_reference = False - # 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 + # Normal implementation for when we have a reference + if stored_trade_reference: + stored_trade = await self.normal_lookup( + platform, stored_trade_reference, reference, currency, amount ) - 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: - return + # if not stored_trade: + # return + + # Amount/currency lookup implementation for when we have no reference else: - # Stored trade reference is none, the checks below will do nothing - return - - # Make sure it was sent in the expected currency - 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 - platform = stored_trade["subclass"] - if not self.alt_amount_check( - platform, amount, currency, reference, stored_trade - ): - return - platform = stored_trade["subclass"] - # platform_buyer = stored_trade["buyer"] - - # Check sender - we don't do anything with this yet - # sender_valid = antifraud.check_valid_sender( - # reference, platform, sender, platform_buyer - # ) - # log.info(f"Trade {reference} buyer {platform_buyer} valid: {sender_valid}") - # trade_released = self.release_map_trade(reference, txid) - # if trade_released: - # self.ux.notify.notify_complete_trade(amount, currency) - # else: - # log.error(f"Cannot release trade {reference}.") - # return - - rtrn = await self.release_funds(stored_trade["id"], stored_trade["reference"]) - if rtrn: - title = "Trade complete" - message = f"Trade complete: {amount}{currency}" - await notify.sendmsg(self.instance.user, message, title=title) + if not stored_trade: # check we don't overwrite the lookup above + stored_trade = await self.amount_currency_lookup( + platform, amount, currency, txid, reference + ) + if stored_trade is False: + continue + if stored_trade: + # Note that we have looked it up without reference so we don't + # use +- below + # This might be redundant given the checks in find_trade, + # but better safe than sorry! + looked_up_without_reference = True + else: + continue + else: + # Stored trade reference is none, the checks below will do nothing + continue + + # Make sure it was sent in the expected currency + if not await self.currency_check(currency, stored_trade): + continue + + # Make sure the expected amount was sent + if not stored_trade.amount_fiat == amount: + if looked_up_without_reference: + continue + if not await self.alt_amount_check( + platform, amount, currency, stored_trade + ): + continue + # platform_buyer = stored_trade["buyer"] + + # Check sender - we don't do anything with this yet + # sender_valid = antifraud.check_valid_sender( + # reference, platform, sender, platform_buyer + # ) + # log.info(f"Trade {reference} buyer {platform_buyer} + # valid: {sender_valid}") + instance = await AgoraClient(platform) + rtrn = await instance.release_map_trade(stored_trade, tx_obj) + # if trade_released: + # self.ux.notify.notify_complete_trade(amount, currency) + # else: + # log.error(f"Cannot release trade {reference}.") + # return + + # rtrn = await platform.release_funds(stored_trade["id"], + # stored_trade["reference"]) + if rtrn: + title = "Trade complete" + message = f"Trade complete: {amount}{currency}" + await notify.sendmsg(self.instance.user, message, title=title) diff --git a/core/clients/aggregators/nordigen.py b/core/clients/aggregators/nordigen.py index 03077fe..877aa1c 100644 --- a/core/clients/aggregators/nordigen.py +++ b/core/clients/aggregators/nordigen.py @@ -306,7 +306,7 @@ class NordigenClient(BaseClient, AggregatorClient): self.normalise_transactions(parsed, state="booked") if process: - await self.process_transactions(parsed) + await self.process_transactions(account_id, parsed) if pending: parsed_pending = response["pending"] self.normalise_transactions(parsed_pending, state="pending") diff --git a/core/clients/platform.py b/core/clients/platform.py index 61e174e..731b0e2 100644 --- a/core/clients/platform.py +++ b/core/clients/platform.py @@ -778,72 +778,68 @@ class LocalPlatformClient(ABC): return all(actioned) - async def release_funds(self, trade_id, reference): + async def release_trade_escrow(self, trade_id, reference): # stored_trade = await db.get_ref(reference) logmessage = f"All checks passed, releasing funds for {trade_id} {reference}" log.info(logmessage) title = "Releasing escrow" await notify.sendmsg(self.instance.user, logmessage, title=title) - release = self.release_funds - post_message = self.api.contact_message_post - rtrn = await release(trade_id) - if rtrn["message"] == "OK": - post_message(trade_id, "Thanks! Releasing now :)") - return True - else: - logmessage = f"Release funds unsuccessful: {rtrn['message']}" - title = "Release unsuccessful" - log.error(logmessage) - await notify.sendmsg(self.instance.user, logmessage, title=title) - return - - # # Parse the escrow release response - # message = rtrn["message"] - # # message_long = rtrn["response"]["data"]["message"] - # self.irc.sendmsg(f"{dumps(message)}") - - async def update_trade_tx(self, reference, txid): + # THIS IS NOT A COMMENT + # THIS IS FOR SECURITY + # WHEN IT HAS BEEN CONFIRMED TO WORK + # THIS CAN BE UNCOMMENTED + # rtrn = await self.release_funds(trade_id) + # if rtrn["message"] == "OK": + # await self.api.contact_message_post(trade_id, "Thanks! Releasing now :)") + # return True + # else: + # logmessage = f"Release funds unsuccessful: {rtrn['message']}" + # title = "Release unsuccessful" + # log.error(logmessage) + # await notify.sendmsg(self.instance.user, logmessage, title=title) + # return + # UNCOMMENT TO HERE + + async def update_trade_tx(self, stored_trade, tx_obj): """ Update a trade to point to a given transaction ID. - Return False if the trade already has a mapped transaction. + Return False if the transaction already has a mapped trade. """ - existing_tx = await db.r.hget(f"trade.{reference}", "tx") - if existing_tx is None: - return None - elif existing_tx == b"": - await db.r.hset(f"trade.{reference}", "tx", txid) - return True - else: # Already a mapped transaction + + if tx_obj.reconciled: return False - async def release_map_trade(self, reference, tx): + if tx_obj in stored_trade.linked.all(): + return False + + stored_trade.linked.add(tx_obj) + stored_trade.save() + + tx_obj.reconciled = True + tx_obj.save() + + return True + + async def release_map_trade(self, stored_trade, tx_obj): """ Map a trade to a transaction and release if no other TX is mapped to the same trade. """ - stored_trade = await db.get_ref(reference) - if not stored_trade: - log.error(f"Could not get stored trade for {reference}.") - return None - tx_obj = await db.get_tx(tx) - if not tx_obj: - log.error(f"Could not get TX for {tx}.") - return None - platform_buyer = stored_trade["buyer"] - bank_sender = tx_obj["sender"] - trade_id = stored_trade["id"] - is_updated = await self.update_trade_tx(reference, tx) - if is_updated is None: - return None - elif is_updated is True: + platform_buyer = stored_trade.buyer + bank_sender = tx_obj.sender + trade_id = stored_trade.contact_id + is_updated = await self.update_trade_tx(stored_trade, tx_obj) + if is_updated is True: # We mapped the trade successfully - self.release_funds(trade_id, reference) - antifraud.add_bank_sender(platform_buyer, bank_sender) + await self.release_trade_escrow(trade_id, stored_trade.reference) + await antifraud.add_bank_sender(platform_buyer, bank_sender) return True - elif is_updated is False: + else: # Already mapped - log.error(f"Trade {reference} already has a TX mapped, cannot map {tx}.") + log.error( + f"Trade {stored_trade} already has a TX mapped, cannot map {tx_obj}." + ) return False async def new_trade( diff --git a/core/clients/platforms/agora.py b/core/clients/platforms/agora.py index 012ad48..26c17ce 100644 --- a/core/clients/platforms/agora.py +++ b/core/clients/platforms/agora.py @@ -3,6 +3,9 @@ from pyotp import TOTP from core.clients.base import BaseClient from core.clients.platform import LocalPlatformClient +from core.util import logs + +log = logs.get_logger("agora") class AgoraClient(LocalPlatformClient, BaseClient): @@ -20,15 +23,12 @@ class AgoraClient(LocalPlatformClient, BaseClient): """ print("CALLING RELEASE FUNDS", contact_id) if self.instance.dummy: - self.log.error( - f"Running in dummy mode, not releasing funds for {contact_id}" - ) + log.error(f"Running in dummy mode, not releasing funds for {contact_id}") return - payload = {"tradeId": contact_id, "password": self.sets.Pass} - rtrn = await self.api._api_call( - api_method=f"contact_release/{contact_id}", - http_method="POST", - query_values=payload, + + rtrn = await self.api.contact_release( + contact_id, + self.instance.password, ) # Check if we can withdraw funds diff --git a/core/clients/platforms/api/agoradesk.py b/core/clients/platforms/api/agoradesk.py index 4f45be9..42aa14f 100644 --- a/core/clients/platforms/api/agoradesk.py +++ b/core/clients/platforms/api/agoradesk.py @@ -88,7 +88,6 @@ class AgoraDesk: async with session.post(api_call_url, **cast) as response_raw: response = await response_raw.json() status_code = response_raw.status - else: cast["params"] = query_values async with aiohttp.ClientSession() as session: @@ -226,11 +225,23 @@ class AgoraDesk: ) # Todo: - # post/trade/contact_release/{trade_id} • Release trade escrow # post/contact_fund/{trade_id} • Fund a trade # post/contact_dispute/{trade_id} • Start a trade dispute # post/contact_mark_as_paid/{trade_id} • Mark a trade as paid + + async def contact_release(self, trade_id: str, password: str) -> Dict[str, Any]: + """See Agoradesk API documentation. + + https://agoradesk.com/api-docs/v1#operation/releaseEscrow + """ + payload = {"tradeId": trade_id, "password": password} + return await self._api_call( + api_method=f"contact_release/{trade_id}", + http_method="POST", + query_values=payload, + ) + async def contact_mark_as_paid(self, trade_id: str) -> Dict[str, Any]: """See Agoradesk API. diff --git a/core/lib/antifraud.py b/core/lib/antifraud.py index 791b1c7..bfdd48e 100644 --- a/core/lib/antifraud.py +++ b/core/lib/antifraud.py @@ -1,20 +1,21 @@ # Project imports -from core.lib import db # , notify +# from core.lib import db # , notify from core.util import logs log = logs.get_logger("antifraud") class AntiFraud(object): - async def add_bank_sender(self, platform, platform_buyer, bank_sender): + async def add_bank_sender(self, platform_buyer, bank_sender): """ Add the bank senders into Redis. :param platform: name of the platform - freeform :param platform_buyer: the username of the buyer on the platform :param bank_sender: the sender name from the bank """ - key = f"namemap.{platform}.{platform_buyer}" - await db.r.sadd(key, bank_sender) + # key = f"namemap.{platform}.{platform_buyer}" + # await db.r.sadd(key, bank_sender) + # TODO async def get_previous_senders(self, platform, platform_buyer): """ @@ -24,12 +25,13 @@ class AntiFraud(object): :return: set of previous buyers :rtype: set """ - key = f"namemap.{platform}.{platform_buyer}" - senders = await db.r.smembers(key) - if not senders: - return None - senders = db.convert(senders) - return senders + # key = f"namemap.{platform}.{platform_buyer}" + # senders = await db.r.smembers(key) + # if not senders: + # return None + # senders = db.convert(senders) + # return senders + # TODO async def check_valid_sender( self, reference, platform, bank_sender, platform_buyer @@ -45,22 +47,23 @@ class AntiFraud(object): :return: whether the sender is valid :rtype: bool """ - senders = await self.get_previous_senders(platform, platform_buyer) - if senders is None: # no senders yet, assume it's valid - return True - if platform_buyer in senders: - return True - self.ux.notify.notify_sender_name_mismatch( - reference, platform_buyer, bank_sender - ) - # title = "Sender name mismatch" - # message = ( - # f"Sender name mismatch for {reference}:\n" - # f"Platform buyer: {platform_buyer}" - # f"Bank sender: {bank_sender}" + # senders = await self.get_previous_senders(platform, platform_buyer) + # if senders is None: # no senders yet, assume it's valid + # return True + # if platform_buyer in senders: + # return True + # self.ux.notify.notify_sender_name_mismatch( + # reference, platform_buyer, bank_sender # ) - # await notify.sendmsg(self.instance.) # TODO - return False + # # title = "Sender name mismatch" + # # message = ( + # # f"Sender name mismatch for {reference}:\n" + # # f"Platform buyer: {platform_buyer}" + # # f"Bank sender: {bank_sender}" + # # ) + # # await notify.sendmsg(self.instance.) # TODO + # return False + # TODO async def check_tx_sender(self, tx, reference): """ @@ -69,21 +72,22 @@ class AntiFraud(object): :param tx: the transaction ID :param reference: the trade reference """ - stored_trade = await db.get_ref(reference) - if not stored_trade: - return None - stored_tx = await db.get_tx(tx) - if not stored_tx: - return None - bank_sender = stored_tx["sender"] - platform_buyer = stored_trade["buyer"] - platform = stored_trade["subclass"] - is_allowed = await self.check_valid_sender( - reference, platform, bank_sender, platform_buyer - ) - if is_allowed is True: - return True - return False + # stored_trade = await db.get_ref(reference) + # if not stored_trade: + # return None + # stored_tx = await db.get_tx(tx) + # if not stored_tx: + # return None + # bank_sender = stored_tx["sender"] + # platform_buyer = stored_trade["buyer"] + # platform = stored_trade["subclass"] + # is_allowed = await self.check_valid_sender( + # reference, platform, bank_sender, platform_buyer + # ) + # if is_allowed is True: + # return True + # return False + # TODO # def user_verification_successful(self, uid): # """ diff --git a/core/lib/money.py b/core/lib/money.py index b5f8b23..64268a7 100644 --- a/core/lib/money.py +++ b/core/lib/money.py @@ -111,26 +111,25 @@ class Money(object): return rates # TODO: pass platform - # async def get_acceptable_margins(self, platform, currency, amount): - # """ - # Get the minimum and maximum amounts we would accept a trade for. - # :param currency: currency code - # :param amount: amount - # :return: (min, max) - # :rtype: tuple - # """ - # sets = util.get_settings(platform) - # rates = await self.get_rates_all() - # if currency == "USD": - # min_amount = amount - float(sets.AcceptableUSDMargin) - # max_amount = amount + float(sets.AcceptableUSDMargin) - # return (min_amount, max_amount) - # amount_usd = amount / rates[currency] - # min_usd = amount_usd - float(sets.AcceptableUSDMargin) - # max_usd = amount_usd + float(sets.AcceptableUSDMargin) - # min_local = min_usd * rates[currency] - # max_local = max_usd * rates[currency] - # return (min_local, max_local) + async def get_acceptable_margins(self, platform, currency, amount): + """ + Get the minimum and maximum amounts we would accept a trade for. + :param currency: currency code + :param amount: amount + :return: (min, max) + :rtype: tuple + """ + rates = await self.get_rates_all() + if currency == "USD": + min_amount = amount - platform.accept_within_usd + max_amount = amount + platform.accept_within_usd + return (min_amount, max_amount) + amount_usd = amount / rates[currency] + min_usd = amount_usd - platform.accept_within_usd + max_usd = amount_usd + platform.accept_within_usd + min_local = min_usd * rates[currency] + max_local = max_usd * rates[currency] + return (min_local, max_local) async def get_minmax(self, min_usd, max_usd, asset, currency): rates = await self.get_rates_all() diff --git a/core/management/commands/scheduling.py b/core/management/commands/scheduling.py index a00e3e2..c235f8f 100644 --- a/core/management/commands/scheduling.py +++ b/core/management/commands/scheduling.py @@ -16,11 +16,20 @@ INTERVALS_PLATFORM = [x[0] for x in INTERVAL_CHOICES] async def aggregator_job(): - aggregators = Aggregator.objects.filter(enabled=True, fetch_accounts=True) + aggregators = Aggregator.objects.filter(enabled=True) for aggregator in aggregators: if aggregator.service == "nordigen": instance = await NordigenClient(aggregator) - await instance.get_all_account_info(store=True) + if aggregator.fetch_accounts is True: + await instance.get_all_account_info(store=True) + + fetch_tasks = [] + for bank, accounts in aggregator.account_info.items(): + for account in accounts: + account_id = account["account_id"] + task = instance.get_transactions(account_id, process=True) + fetch_tasks.append(task) + await asyncio.gather(*fetch_tasks) else: raise NotImplementedError(f"No such client library: {aggregator.service}") aggregator.fetch_accounts = False diff --git a/core/migrations/0022_transaction_transaction_id_transaction_ts_added.py b/core/migrations/0022_transaction_transaction_id_transaction_ts_added.py new file mode 100644 index 0000000..e83392a --- /dev/null +++ b/core/migrations/0022_transaction_transaction_id_transaction_ts_added.py @@ -0,0 +1,26 @@ +# Generated by Django 4.1.7 on 2023-03-12 19:21 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0021_alter_trade_ad_id'), + ] + + operations = [ + migrations.AddField( + model_name='transaction', + name='transaction_id', + field=models.CharField(default='NONE', max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name='transaction', + name='ts_added', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + ] diff --git a/core/migrations/0023_alter_trade_linked.py b/core/migrations/0023_alter_trade_linked.py new file mode 100644 index 0000000..6163c56 --- /dev/null +++ b/core/migrations/0023_alter_trade_linked.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.7 on 2023-03-13 09:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0022_transaction_transaction_id_transaction_ts_added'), + ] + + operations = [ + migrations.AlterField( + model_name='trade', + name='linked', + field=models.ManyToManyField(blank=True, to='core.transaction'), + ), + ] diff --git a/core/models.py b/core/models.py index 64b841e..3559858 100644 --- a/core/models.py +++ b/core/models.py @@ -80,7 +80,40 @@ class Aggregator(models.Model): @classmethod def get_for_platform(cls, platform): - return cls.objects.filter(user=platform.user, enabled=True) + aggregators = [] + ads = Ad.objects.filter( + platforms=platform, + enabled=True, + ) + print("ADS", ads) + for ad in ads: + for aggregator in ad.aggregators.all(): + if aggregator not in aggregators: + aggregators.append(aggregator) + + print("RET", aggregators) + return aggregators + + @property + def platforms(self): + """ + Get platforms for this aggregator. + Do this by looking up Ads with the aggregator. + Then, join them all together. + """ + platforms = [] + ads = Ad.objects.filter( + aggregators=self, + enabled=True, + ) + print("ADS", ads) + for ad in ads: + for platform in ad.platforms.all(): + if platform not in platforms: + platforms.append(platform) + + print("RET", platforms) + return platforms @classmethod def get_currencies_for_platform(cls, platform): @@ -104,6 +137,23 @@ class Aggregator(models.Model): account_info[bank].append(account) return account_info + def add_transaction(self, account_id, tx_data): + return Transaction.objects.create( + aggregator=self, + account_id=account_id, + reconciled=False, + **tx_data, + ) + + def get_transaction(self, account_id, tx_id): + transaction = Transaction.objects.filter( + account_id=account_id, + transaction_id=tx_id, + ).first() + if not transaction: + return None + return transaction + class Platform(models.Model): """ @@ -208,6 +258,13 @@ class Platform(models.Model): return references + def get_trade_by_reference(self, reference): + return Trade.objects.filter( + platform=self, + open=True, + reference=reference, + ).first() + @property def trades(self): """ @@ -249,6 +306,43 @@ class Platform(models.Model): log.info(msg) return messages + @classmethod + def get_for_aggregator(cls, aggregator): + platforms = [] + ads = Ad.objects.filter( + aggregators=aggregator, + enabled=True, + ) + print("ADS", ads) + for ad in ads: + for platform in ad.platforms.all(): + if platform not in platforms: + platforms.append(platform) + + print("RET", platforms) + return platforms + + @property + def aggregators(self): + """ + Get aggregators for this platform. + Do this by looking up Ads with the platform. + Then, join them all together. + """ + aggregators = [] + ads = Ad.objects.filter( + platforms=self, + enabled=True, + ) + print("ADS", ads) + for ad in ads: + for aggregator in ad.aggregators.all(): + if aggregator not in aggregators: + aggregators.append(aggregator) + + print("RET", aggregators) + return aggregators + class Asset(models.Model): code = models.CharField(max_length=64) @@ -320,8 +414,9 @@ class Transaction(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) aggregator = models.ForeignKey(Aggregator, on_delete=models.CASCADE) account_id = models.CharField(max_length=255) + transaction_id = models.CharField(max_length=255) - ts_added = ... + ts_added = models.DateTimeField(auto_now_add=True) recipient = models.CharField(max_length=255, null=True, blank=True) sender = models.CharField(max_length=255, null=True, blank=True) @@ -354,7 +449,7 @@ class Trade(models.Model): open = models.BooleanField(default=True) - linked = models.ManyToManyField(Transaction) + linked = models.ManyToManyField(Transaction, blank=True) reconciled = models.BooleanField(default=False) released = models.BooleanField(default=False) diff --git a/core/tests/test_transactions.py b/core/tests/test_transactions.py new file mode 100644 index 0000000..40b3572 --- /dev/null +++ b/core/tests/test_transactions.py @@ -0,0 +1,587 @@ +import logging +from unittest.mock import patch + +from django.test import TransactionTestCase + +from core.clients.aggregator import AggregatorClient +from core.models import Aggregator, Platform, Trade, Transaction, User + + +class TestTransactions(TransactionTestCase): + def setUp(self): + logging.disable(logging.CRITICAL) + self.user = User.objects.create_user( + username="testuser", email="test@example.com", password="test" + ) + self.aggregator = Aggregator.objects.create( + user=self.user, + name="Test", + service="nordigen", + secret_id="a", + secret_key="a", + ) + + self.agg_client = AggregatorClient() + self.agg_client.instance = self.aggregator + + self.platform = Platform.objects.create( + user=self.user, + name="Test", + service="agora", + token="a", + password="a", + otp_token="a", + username="myuser", + ) + self.transaction = Transaction.objects.create( + aggregator=self.aggregator, + account_id="my account id", + transaction_id="BANKTX", + amount=1, + currency="GBP", + note="TEST-1", + ) + + self.trades = { + 1: { + "contact_id": "uuid1", + "buyer": "test_buyer_1", + "currency": "GBP", + "asset": "XMR", + "amount_fiat": 1, + "amount_crypto": 0.3, + "reference": "TEST-1", + "provider": "REVOLUT", + }, + 2: { + "contact_id": "uuid2", + "buyer": "test_buyer_2", + "currency": "GBP", + "asset": "XMR", + "amount_fiat": 1, + "amount_crypto": 0.3, + "reference": "TEST-2", + "provider": "REVOLUT", + }, + 3: { + "contact_id": "uuid3", + "buyer": "test_buyer_3", + "currency": "GBP", + "asset": "XMR", + "amount_fiat": 1000, + "amount_crypto": 3, + "reference": "TEST-3", + "provider": "REVOLUT", + }, + 4: { + "contact_id": "uuid4", + "buyer": "test_buyer_4", + "currency": "GBP", + "asset": "XMR", + "amount_fiat": 10, + "amount_crypto": 0.5, + "reference": "TEST-4", + "provider": "REVOLUT", + }, + 5: { # to conflict with 1 + "contact_id": "uuid1", + "buyer": "test_buyer_2", + "currency": "GBP", + "asset": "XMR", + "amount_fiat": 1, + "amount_crypto": 0.3, + "reference": "TEST-1", + "provider": "REVOLUT", + }, + } + + def create_trades(self, *numbers): + for trade_key in self.trades.keys(): + if trade_key in numbers: + Trade.objects.create( + platform=self.platform, + # open=True, + **self.trades[trade_key], + ) + + 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 + + @patch("core.lib.notify.sendmsg") + async def test_reference_partial_check(self, _): + self.create_trades(1, 2, 3, 4) + + result = await self.agg_client.reference_partial_check( + self.platform, + "TEST-1", + "for notifications only", + "GBP", + 1, + ) + self.assertEqual(result, "TEST-1") + + @patch("core.lib.notify.sendmsg") + async def test_reference_partial_check_subset(self, _): + self.create_trades(1, 2, 3, 4) + + result = await self.agg_client.reference_partial_check( + self.platform, + "the TEST-1 in string", + "for notifications only", + "GBP", + 1, + ) + self.assertEqual(result, "TEST-1") + + @patch("core.lib.notify.sendmsg") + async def test_reference_partial_check_multiple_match(self, _): + self.create_trades(1, 2, 3, 4, 5) + + result = await self.agg_client.reference_partial_check( + self.platform, + "TEST-1", + "for notifications only", + "GBP", + 1, + ) + self.assertEqual(result, False) + + @patch("core.lib.notify.sendmsg") + async def test_reference_partial_check_none(self, _): + # self.create_trades(1, 2, 3, 4, 5) + + result = await self.agg_client.reference_partial_check( + self.platform, + "TEST-1", + "for notifications only", + "GBP", + 1, + ) + self.assertEqual(result, None) + + @patch("core.lib.notify.sendmsg") + async def test_reference_partial_check_none_match(self, _): + self.create_trades(1, 2, 3, 4, 5) + + result = await self.agg_client.reference_partial_check( + self.platform, + "NOWHERE", + "for notifications only", + "GBP", + 1, + ) + self.assertEqual(result, None) + + def test_valid_transaction(self): + result = self.agg_client.valid_transaction(self.transaction) + self.assertEqual(result, True) + + def test_valid_transaction_fail(self): + self.transaction.amount = -100 + result = self.agg_client.valid_transaction(self.transaction) + self.assertEqual(result, False) + + self.transaction.amount = 1 + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=100) + async def test_can_alt_lookup(self, *args): + result = await self.agg_client.can_alt_lookup( + self.platform, + 999999999, # ignored + "GBP", + "IGNORED", + ) + + self.assertEqual(result, True) + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=500) + async def test_can_alt_lookup_fail(self, *args): + result = await self.agg_client.can_alt_lookup( + self.platform, + 999999999, # ignored + "GBP", + "IGNORED", + ) + + self.assertEqual(result, False) + + def test_find_trade(self): + self.create_trades(1, 3, 4) + + result = self.agg_client.find_trade( + self.platform, + "BANKTX", + "GBP", + 1, + ) + + self.assertEqual(result.reference, "TEST-1") + self.assertEqual(result.currency, "GBP") + self.assertEqual(result.amount_fiat, 1) + + def test_find_trade_fail_two_match(self): + self.create_trades(1, 2, 3, 4) # 2 trades with same amount and currency + result = self.agg_client.find_trade( + self.platform, + "BANKTX", + "GBP", + 1, + ) + + self.assertEqual(result, False) + + def test_find_trade_fail_two_match_alt(self): + self.create_trades(1, 3, 4) + result = self.agg_client.find_trade( + self.platform, + "BANKTX", + "GBP", + 88, + ) + + self.assertEqual(result, False) + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=100) + async def test_amount_currency_lookup(self, *args): + self.create_trades(1, 3, 4) + result = await self.agg_client.amount_currency_lookup( + self.platform, + 1, + "GBP", + "BANKTX", + "TEST-1", + ) + + self.assertEqual(result.reference, "TEST-1") + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=100) + async def test_amount_currency_lookup_fail(self, *args): + self.create_trades(1, 3, 4) + result = await self.agg_client.amount_currency_lookup( + self.platform, + 88, + "GBP", + "BANKTX", + "TEST-1", + ) + + self.assertEqual(result, None) + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=500) + async def test_amount_currency_lookup_fail_too_high(self, *args): + self.create_trades(1, 3, 4) + result = await self.agg_client.amount_currency_lookup( + self.platform, + 1, + "GBP", + "BANKTX", + "TEST-1", + ) + + self.assertEqual(result, False) + + @patch("core.lib.notify.sendmsg") + async def test_normal_lookup(self, _): + self.create_trades(1, 2, 3, 4) + + result = await self.agg_client.normal_lookup( + self.platform, + "TEST-1", + "BANKTX", + "GBP", + 1, + ) + + self.assertEqual(result.reference, "TEST-1") + self.assertEqual(result.currency, "GBP") + self.assertEqual(result.amount_fiat, 1) + + @patch("core.lib.notify.sendmsg") + async def test_currency_check(self, _): + self.create_trades(1) + trade = Trade.objects.all().first() + result = await self.agg_client.currency_check( + "GBP", + trade, + ) + + self.assertEqual(result, True) + + @patch("core.lib.notify.sendmsg") + async def test_currency_check_fail(self, _): + self.create_trades(1) + trade = Trade.objects.all().first() + result = await self.agg_client.currency_check( + "AYZ", + trade, + ) + + self.assertEqual(result, False) + + @patch("core.lib.notify.sendmsg") + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.5, 1.5) + ) + async def test_alt_amount_check(self, *args): + self.create_trades(1) + trade = Trade.objects.all().first() + + result = await self.agg_client.alt_amount_check( + self.platform, + 1.123, + "GBP", + trade, + ) + + self.assertEqual(result, True) + + @patch("core.lib.notify.sendmsg") + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.5, 1.5) + ) + async def test_alt_amount_check_fail(self, *args): + self.create_trades(1) + trade = Trade.objects.all().first() + + result = await self.agg_client.alt_amount_check( + self.platform, + 1.501, + "GBP", + trade, + ) + + self.assertEqual(result, False) + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=1) + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.5, 1.5) + ) + @patch("core.clients.aggregator.AgoraClient.release_trade_escrow") + async def test_transaction(self, release, gam, to_usd, sendmsg): + self.create_trades(1, 2, 3, 4) + await self.agg_client.transaction([self.platform], self.transaction) + release.assert_called_once_with("uuid1", "TEST-1") + self.assertEqual(self.transaction.reconciled, True) + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=1) + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.5, 1.5) + ) + @patch("core.clients.aggregator.AgoraClient.release_trade_escrow") + async def test_transaction_second(self, release, gam, to_usd, sendmsg): + self.create_trades(1, 2, 3, 4) + self.transaction.note = "TEST-2" + await self.agg_client.transaction([self.platform], self.transaction) + release.assert_called_once_with("uuid2", "TEST-2") + self.assertEqual(self.transaction.reconciled, True) + + self.transaction.note = "TEST-1" + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=1) + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.5, 1.5) + ) + @patch("core.clients.aggregator.AgoraClient.release_trade_escrow") + async def test_transaction_invalid(self, release, gam, to_usd, sendmsg): + self.create_trades(1, 2, 3, 4) + self.transaction.amount = -1 + await self.agg_client.transaction([self.platform], self.transaction) + release.assert_not_called() + self.assertEqual(self.transaction.reconciled, False) + + self.transaction.amount = 1 + + # def test_transaction_malformed(self): + # malformed_data = self.test_data_copy + # del malformed_data["amount"] + # self.transactions.transaction(malformed_data) + # self.transactions.release_funds.assert_not_called() + + # malformed_data = self.test_data_copy + # del malformed_data["currency"] + # self.transactions.transaction(malformed_data) + # self.transactions.release_funds.assert_not_called() + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=1) + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.5, 1.5) + ) + @patch("core.clients.aggregator.AgoraClient.release_trade_escrow") + async def test_transaction_no_reference_fail(self, release, gam, to_usd, sendmsg): + self.create_trades(1, 2, 3, 4) + self.transaction.note = "none" + await self.agg_client.transaction([self.platform], self.transaction) + release.assert_not_called() + self.assertEqual(self.transaction.reconciled, False) + + self.transaction.note = "TEST-1" + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=1) + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.5, 1.5) + ) + @patch("core.clients.aggregator.AgoraClient.release_trade_escrow") + async def test_transaction_no_reference_pass(self, release, gam, to_usd, sendmsg): + self.create_trades(1) + self.transaction.note = "none" + + await self.agg_client.transaction([self.platform], self.transaction) + release.assert_called_with("uuid1", "TEST-1") + self.assertEqual(self.transaction.reconciled, True) + + self.transaction.note = "TEST-1" + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=1000) + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.5, 1) + ) + @patch("core.clients.aggregator.AgoraClient.release_trade_escrow") + async def test_transaction_large(self, release, gam, to_usd, sendmsg): + self.create_trades(1, 2, 3, 4) + self.transaction.amount = 1000 + self.transaction.note = "TEST-3" + await self.agg_client.transaction([self.platform], self.transaction) + release.assert_called_once_with("uuid3", "TEST-3") + self.assertEqual(self.transaction.reconciled, True) + + self.transaction_amount_fiat = 1 + self.transaction.note = "TEST-1" + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=1000) + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.5, 1) + ) + @patch("core.clients.aggregator.AgoraClient.release_trade_escrow") + async def test_transaction_no_reference_exceeds_max( + self, release, gam, to_usd, sendmsg + ): + self.create_trades(1, 2, 3, 4) + self.transaction.amount = 1000 + self.transaction.note = "noref" + await self.agg_client.transaction([self.platform], self.transaction) + release.assert_not_called() + self.assertEqual(self.transaction.reconciled, False) + + self.transaction.amount = 1 + self.transaction.note = "TEST-1" + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=100) + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.5, 1) + ) + @patch("core.clients.aggregator.AgoraClient.release_trade_escrow") + async def test_transaction_wrong_currency(self, release, gam, to_usd, sendmsg): + self.create_trades(1, 2, 3, 4) + self.transaction.currency = "EUR" + await self.agg_client.transaction([self.platform], self.transaction) + release.assert_not_called() + self.assertEqual(self.transaction.reconciled, False) + + self.transaction.currency = "GBP" + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=100) + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.5, 1) + ) + @patch("core.clients.aggregator.AgoraClient.release_trade_escrow") + async def test_transaction_wrong_currency_noref( + self, release, gam, to_usd, sendmsg + ): + self.transaction.currency = "EUR" + self.transaction.note = "none" + await self.agg_client.transaction([self.platform], self.transaction) + release.assert_not_called() + self.assertEqual(self.transaction.reconciled, False) + + self.transaction.currency = "GBP" + self.transaction.note = "TEST-1" + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=100) + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.8, 1.8) + ) + @patch("core.clients.aggregator.AgoraClient.release_trade_escrow") + async def test_transaction_wrong_amount(self, release, gam, to_usd, sendmsg): + self.create_trades(1, 2, 3, 4) + self.transaction.amount = 10 + await self.agg_client.transaction([self.platform], self.transaction) + release.assert_not_called() + self.assertEqual(self.transaction.reconciled, False) + + self.transaction.amount = 1 + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=100) + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.8, 1.8) + ) + @patch("core.clients.aggregator.AgoraClient.release_trade_escrow") + async def test_transaction_wrong_amount_noref(self, release, gam, to_usd, sendmsg): + self.transaction.amount = 10 + self.transaction.note = "none" + await self.agg_client.transaction([self.platform], self.transaction) + release.assert_not_called() + self.assertEqual(self.transaction.reconciled, False) + + self.transaction.amount = 1 + self.transaction.note = "TEST-1" + + # def test_transaction_pending(self): + # pending_tx = self.test_data_copy + # pending_tx["data"]["state"] = "pending" + # self.transactions.transaction(pending_tx) + # self.transactions.release_funds.assert_not_called() + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=100) + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.5, 1) + ) + @patch("core.clients.aggregator.AgoraClient.release_trade_escrow") + async def test_transaction_too_low(self, release, gam, to_usd, sendmsg): + self.create_trades(1, 2, 3, 4) + + self.transaction.amount = 5 + await self.agg_client.transaction([self.platform], self.transaction) + release.assert_not_called() + self.assertEqual(self.transaction.reconciled, False) + + self.transaction.amount = 1 + + @patch("core.lib.notify.sendmsg") + @patch("core.clients.aggregator.money.to_usd", return_value=100) + @patch( + "core.clients.aggregator.money.get_acceptable_margins", return_value=(0.5, 1) + ) + @patch("core.clients.aggregator.AgoraClient.release_trade_escrow") + async def test_transaction_too_high(self, release, gam, to_usd, sendmsg): + self.create_trades(1, 2, 3, 4) + self.transaction.amount = 15 + await self.agg_client.transaction([self.platform], self.transaction) + release.assert_not_called() + self.assertEqual(self.transaction.reconciled, False) + + self.transaction.amount = 1