From 9627fb7d414d2fa55dfd344659c0fb5ef950cb58 Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Mon, 20 Mar 2023 11:06:37 +0000 Subject: [PATCH] Implement profit sharing system and write tests --- app/urls.py | 5 + core/forms.py | 17 ++ core/lib/money.py | 64 +++++- core/migrations/0032_operatorwallets.py | 25 ++ core/migrations/0033_platform_throughput.py | 18 ++ core/models.py | 14 ++ core/templates/base.html | 3 + core/templates/partials/linkgroup-info.html | 5 +- core/tests/test_money.py | 239 ++++++++++++++++++++ core/tests/test_platform.py | 3 + core/views/wallets.py | 22 +- 11 files changed, 411 insertions(+), 4 deletions(-) create mode 100644 core/migrations/0032_operatorwallets.py create mode 100644 core/migrations/0033_platform_throughput.py create mode 100644 core/tests/test_money.py diff --git a/app/urls.py b/app/urls.py index 80e90bc..086e731 100644 --- a/app/urls.py +++ b/app/urls.py @@ -226,6 +226,11 @@ urlpatterns = [ wallets.WalletUpdate.as_view(), name="wallet_update", ), + path( + "operator_wallets//update/", + wallets.OperatorWalletsUpdate.as_view(), + name="operator_wallets_update", + ), path( "wallets//delete//", wallets.WalletDelete.as_view(), diff --git a/core/forms.py b/core/forms.py index 0d23fef..050f516 100644 --- a/core/forms.py +++ b/core/forms.py @@ -10,6 +10,7 @@ from .models import ( Asset, LinkGroup, NotificationSettings, + OperatorWallets, Platform, Provider, Requisition, @@ -311,3 +312,19 @@ class LinkGroupForm(RestrictedFormMixin, ModelForm): ) return return cleaned_data + + +class OperatorWalletsForm(RestrictedFormMixin, ModelForm): + class Meta: + model = OperatorWallets + fields = ("payees",) + help_texts = { + "payees": "Wallets to designate as payees for this operator.", + } + + payees = forms.ModelMultipleChoiceField( + queryset=Wallet.objects.all(), + widget=forms.CheckboxSelectMultiple, + help_text=Meta.help_texts["payees"], + required=False, + ) diff --git a/core/lib/money.py b/core/lib/money.py index 64268a7..ff99221 100644 --- a/core/lib/money.py +++ b/core/lib/money.py @@ -10,7 +10,7 @@ from elasticsearch import AsyncElasticsearch from forex_python.converter import CurrencyRates # Other library imports -from core.models import Aggregator, Platform +from core.models import Aggregator, OperatorWallets, Platform # TODO: secure ES traffic properly urllib3.disable_warnings() @@ -525,5 +525,67 @@ class Money(object): await self.write_to_es("get_total_with_trades", cast_es) return total_with_trades + def get_pay_list(self, linkgroup, requisitions, platforms, user, profit): + pay_list = {} # Wallet: [(amount, reason), (amount, reason), ...] + + # Get the total amount of money we have + total_throughput_platform = 0 + total_throughput_requisition = 0 + for requisition in requisitions: + total_throughput_requisition += requisition.throughput + for platform in platforms: + total_throughput_platform += platform.throughput + + cut_platform = profit * (linkgroup.platform_owner_cut_percentage / 100) + cut_req = profit * (linkgroup.requisition_owner_cut_percentage / 100) + cut_operator = profit * (linkgroup.operator_cut_percentage / 100) + + # Add the operator payment + operator_wallets = OperatorWallets.objects.filter(user=user).first() + operator_length = len(operator_wallets.payees.all()) + payment_per_operator = cut_operator / operator_length + for wallet in operator_wallets.payees.all(): + if wallet not in pay_list: + pay_list[wallet] = [] + detail = ( + f"Operator cut for 1 of {operator_length} operators, total " + f"{cut_operator}" + ) + pay_list[wallet].append((payment_per_operator, detail)) + + # Add the platform payment + for platform in platforms: + # Get ratio of platform.throughput to the total platform throughput + ratio = platform.throughput / total_throughput_platform + platform_payment = cut_platform * ratio + payees_length = len(platform.payees.all()) + payment_per_payee = platform_payment / payees_length + for wallet in platform.payees.all(): + if wallet not in pay_list: + pay_list[wallet] = [] + detail = ( + f"Platform {platform} cut for 1 of {payees_length} payees, " + f"total {cut_platform}" + ) + pay_list[wallet].append((payment_per_payee, detail)) + + # Add the requisition payment + for requisition in requisitions: + # Get ratio of requisition.throughput to the requisition cut + ratio = requisition.throughput / total_throughput_requisition + req_payment = cut_req * ratio + payees_length = len(requisition.payees.all()) + payment_per_payee = req_payment / payees_length + for wallet in requisition.payees.all(): + if wallet not in pay_list: + pay_list[wallet] = [] + detail = ( + f"Requisition {requisition} cut for 1 of {payees_length} payees, " + f"total {cut_req}" + ) + pay_list[wallet].append((payment_per_payee, detail)) + + return pay_list + money = Money() diff --git a/core/migrations/0032_operatorwallets.py b/core/migrations/0032_operatorwallets.py new file mode 100644 index 0000000..86c0445 --- /dev/null +++ b/core/migrations/0032_operatorwallets.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.7 on 2023-03-20 09:35 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0031_remove_ad_aggregators_remove_ad_platforms_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='OperatorWallets', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('payees', models.ManyToManyField(blank=True, to='core.wallet')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/core/migrations/0033_platform_throughput.py b/core/migrations/0033_platform_throughput.py new file mode 100644 index 0000000..6ffc5bc --- /dev/null +++ b/core/migrations/0033_platform_throughput.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.7 on 2023-03-20 09:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0032_operatorwallets'), + ] + + operations = [ + migrations.AddField( + model_name='platform', + name='throughput', + field=models.FloatField(default=0), + ), + ] diff --git a/core/models.py b/core/models.py index 0af0532..181f229 100644 --- a/core/models.py +++ b/core/models.py @@ -260,6 +260,7 @@ class Platform(models.Model): ) enabled = models.BooleanField(default=True) + throughput = models.FloatField(default=0) def __str__(self): return self.name @@ -584,6 +585,19 @@ class Requisition(models.Model): throughput = models.FloatField(default=0) payees = models.ManyToManyField(Wallet, blank=True) + def __str__(self): + return f"Aggregator: {self.aggregator.name} ID: {self.requisition_id}" + + +class OperatorWallets(models.Model): + """ + A list of wallets to designate as operator wallets for this user. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE) + payees = models.ManyToManyField(Wallet, blank=True) + assets = { "XMR": "Monero", diff --git a/core/templates/base.html b/core/templates/base.html index bb67e42..a0b5c3b 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -292,6 +292,9 @@ Notifications + + Operator Wallets + {% endif %} diff --git a/core/templates/partials/linkgroup-info.html b/core/templates/partials/linkgroup-info.html index 958d35f..29db554 100644 --- a/core/templates/partials/linkgroup-info.html +++ b/core/templates/partials/linkgroup-info.html @@ -88,7 +88,10 @@

Simulation for $1000

-

Assuming equal throughput for platforms and requisitions.

+

+ Assuming equal throughput for platforms and requisitions. + Note that this is just a simulation, equal throughput is highly unlikely. +

diff --git a/core/tests/test_money.py b/core/tests/test_money.py new file mode 100644 index 0000000..6364fe0 --- /dev/null +++ b/core/tests/test_money.py @@ -0,0 +1,239 @@ +from django.test import TransactionTestCase + +from core.clients.platform import LocalPlatformClient +from core.lib.money import money +from core.models import LinkGroup, OperatorWallets, Platform, Requisition, Wallet +from core.tests.helpers import AggregatorPlatformMixin + + +class TestMoney(AggregatorPlatformMixin, TransactionTestCase): + def setUp(self): + super().setUp() + self.plat_client = LocalPlatformClient() + self.plat_client.instance = self.platform + + self.linkgroup = LinkGroup.objects.create( + user=self.user, + name="Test", + platform_owner_cut_percentage=30, + requisition_owner_cut_percentage=40, + operator_cut_percentage=30, + ) + + self.aggregator.link_group = self.linkgroup + self.aggregator.save() + + self.platform.link_group = self.linkgroup + self.platform.save() + + self.req = Requisition.objects.create( + user=self.user, + aggregator=self.aggregator, + requisition_id="3ba3e65d-f44c-4c4e-9e28-08cc080830f6", + payment_details="CUSTOM PAYMENT", + ) + + def create_wallets(self): + self.wallet_1 = Wallet.objects.create( + user=self.user, + name="Platform wallet 1", + address="alpha", + ) + self.wallet_2 = Wallet.objects.create( + user=self.user, + name="Requisition wallet 1", + address="beta", + ) + self.wallet_3 = Wallet.objects.create( + user=self.user, + name="Operator wallet 1", + address="gamma", + ) + + self.platform.payees.set([self.wallet_1]) + + self.req.payees.set([self.wallet_2]) + + op, _ = OperatorWallets.objects.get_or_create(user=self.user) + op.payees.set([self.wallet_3]) + + self.platform.save() + self.req.save() + op.save() + + # Platform: 30 + # Requisition: 40 + # Operator: 30 + + def test_get_payment_list_full(self): + self.create_wallets() + self.platform.throughput = 1000 + self.req.throughput = 1000 + + profit = 100 + + pay_list = money.get_pay_list( + self.linkgroup, + [self.req], + [self.platform], + self.user, + profit, + ) + + expected_list = { + self.wallet_3: [(30.0, "Operator cut for 1 of 1 operators, total 30.0")], + self.wallet_1: [(30.0, "Platform Test cut for 1 of 1 payees, total 30.0")], + self.wallet_2: [ + ( + 40.0, + ( + f"Requisition Aggregator: {self.aggregator.name} ID: " + f"{self.req.requisition_id} cut for 1 of 1 payees, total 40.0" + ), + ) + ], + } + + self.assertDictEqual(pay_list, expected_list) + + def test_get_payment_list_full_duplicates(self): + self.create_wallets() + self.req.payees.set([self.wallet_1]) + self.platform.throughput = 1000 + self.req.throughput = 1000 + + profit = 100 + + pay_list = money.get_pay_list( + self.linkgroup, + [self.req], + [self.platform], + self.user, + profit, + ) + expected_list = { + self.wallet_3: [(30.0, "Operator cut for 1 of 1 operators, total 30.0")], + self.wallet_1: [ + (30.0, "Platform Test cut for 1 of 1 payees, total 30.0"), + ( + 40.0, + ( + f"Requisition Aggregator: {self.aggregator.name} ID: " + f"{self.req.requisition_id} cut for 1 of 1 payees, total 40.0" + ), + ), + ], + } + self.assertDictEqual(pay_list, expected_list) + + def create_wallets_for_operator(self, num): + wallets = [] + for i in range(num): + wallet = Wallet.objects.create( + user=self.user, + name=f"Operator wallet {i}", + address=f"operator{i}", + ) + wallets.append(wallet) + op, _ = OperatorWallets.objects.get_or_create(user=self.user) + op.payees.set(wallets) + + return wallets + + def create_platforms_and_wallets(self, num): + platforms = [] + wallets = [] + for i in range(num): + platform = Platform.objects.create( + user=self.user, + name=f"Platform {i}", + service="agora", + token="a", + password="a", + otp_token="a", + username="myuser", + link_group=self.linkgroup, + ) + wallet = Wallet.objects.create( + user=self.user, + name=f"Platform wallet {i}", + address=f"platform{i}", + ) + platform.payees.set([wallet]) + platforms.append(platform) + wallets.append(wallet) + return platforms, wallets + + def create_requisitions_and_wallets(self, num): + requisitions = [] + wallets = [] + for i in range(num): + req = Requisition.objects.create( + user=self.user, + aggregator=self.aggregator, + requisition_id=f"3ba3e65d-f44c-4c4e-9e28-08cc080830f6{i}", + ) + wallet = Wallet.objects.create( + user=self.user, + name=f"Requisition wallet {i}", + address=f"requisition{i}", + ) + req.payees.set([wallet]) + requisitions.append(req) + wallets.append(wallet) + return requisitions, wallets + + def test_get_payment_list_multi(self): + wallet_num_operator = 3 + wallet_num_platform = 3 + wallet_num_requisition = 3 + + wallets_operator = self.create_wallets_for_operator(wallet_num_operator) + platforms, wallets_platforms = self.create_platforms_and_wallets( + wallet_num_platform + ) + requisitions, wallets_requisition = self.create_requisitions_and_wallets( + wallet_num_requisition + ) + + profit = 100 + throughput_platform = [400, 300, 300] + throughput_requisition = [300, 400, 300] + + expected_operator = [10, 10, 10] + expected_platform = [12, 9, 9] + expected_requisition = [12, 16, 12] + + for index, platform in enumerate(platforms): + platform.throughput = throughput_platform[index] + + for index, req in enumerate(requisitions): + req.throughput = throughput_requisition[index] + + pay_list = money.get_pay_list( + self.linkgroup, + requisitions, + platforms, + self.user, + profit, + ) + for wallet, payments in pay_list.items(): + if wallet in wallets_operator: + for amount, detail in payments: + self.assertEqual( + amount, expected_operator[wallets_operator.index(wallet)] + ) + self.assertIn("Operator cut", detail) + elif wallet in wallets_platforms: + for amount, detail in payments: + self.assertEqual( + amount, expected_platform[wallets_platforms.index(wallet)] + ) + self.assertIn("Platform", detail) + + elif wallet in wallets_requisition: + for amount, detail in payments: + self.assertEqual( + amount, expected_requisition[wallets_requisition.index(wallet)] + ) + self.assertIn("Requisition", detail) diff --git a/core/tests/test_platform.py b/core/tests/test_platform.py index 398990c..2956b36 100644 --- a/core/tests/test_platform.py +++ b/core/tests/test_platform.py @@ -101,6 +101,9 @@ class TestPlatform(AggregatorPlatformMixin, TransactionTestCase): self.linkgroup = LinkGroup.objects.create( user=self.user, name="Test", + platform_owner_cut_percentage=30, + requisition_owner_cut_percentage=40, + operator_cut_percentage=30, ) self.aggregator.link_group = self.linkgroup diff --git a/core/views/wallets.py b/core/views/wallets.py index b56ed2d..36b9738 100644 --- a/core/views/wallets.py +++ b/core/views/wallets.py @@ -2,8 +2,8 @@ from django.contrib.auth.mixins import LoginRequiredMixin from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate from two_factor.views.mixins import OTPRequiredMixin -from core.forms import WalletForm -from core.models import Wallet +from core.forms import OperatorWalletsForm, WalletForm +from core.models import OperatorWallets, Wallet class WalletList(LoginRequiredMixin, OTPRequiredMixin, ObjectList): @@ -33,3 +33,21 @@ class WalletUpdate(LoginRequiredMixin, OTPRequiredMixin, ObjectUpdate): class WalletDelete(LoginRequiredMixin, OTPRequiredMixin, ObjectDelete): model = Wallet + + +class OperatorWalletsUpdate(LoginRequiredMixin, ObjectUpdate): + model = OperatorWallets + form_class = OperatorWalletsForm + + page_title = "Update your designated wallets." + + submit_url_name = "operator_wallets_update" + submit_url_args = ["type"] + + pk_required = False + + hide_cancel = True + + def get_object(self, **kwargs): + wallet, _ = OperatorWallets.objects.get_or_create(user=self.request.user) + return wallet