From d71730b8b2e93173b29670eae92787627bf7b976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Dlouh=C3=BD?= Date: Mon, 21 Nov 2022 16:34:29 +0100 Subject: [PATCH] (experimental) add running totals functionality --- hordak/admin.py | 2 +- .../commands/recalculate_running_totals.py | 19 + hordak/migrations/0034_runningtotal.py | 372 ++++++++++++++++++ hordak/models/core.py | 69 ++++ hordak/tests/models/test_core.py | 70 ++++ hordak/tests/test_commands.py | 33 +- 6 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 hordak/management/commands/recalculate_running_totals.py create mode 100644 hordak/migrations/0034_runningtotal.py diff --git a/hordak/admin.py b/hordak/admin.py index 37c28a40..4d3face3 100644 --- a/hordak/admin.py +++ b/hordak/admin.py @@ -27,7 +27,7 @@ class AccountAdmin(MPTTModelAdmin): "balance_sum", "income", ) - readonly_fields = ("balance",) + readonly_fields = ("balance", "balance_sum", "income") raw_id_fields = ("parent",) search_fields = ( "code", diff --git a/hordak/management/commands/recalculate_running_totals.py b/hordak/management/commands/recalculate_running_totals.py new file mode 100644 index 00000000..4f760412 --- /dev/null +++ b/hordak/management/commands/recalculate_running_totals.py @@ -0,0 +1,19 @@ +from django.core.management.base import BaseCommand + +from hordak.models import Account, RunningTotal + + +class Command(BaseCommand): + help = "Recalculate running totals for all accounts" + + def handle(self, *args, **options): + print("Recalculating running totals for all accounts") + queryset = Account.objects.all() + for account in queryset[:1000]: + total = account.balance() + for currency in total.currencies(): + RunningTotal.objects.update_or_create( + account=account, + currency=currency, + defaults={"balance": total[currency]}, + ) diff --git a/hordak/migrations/0034_runningtotal.py b/hordak/migrations/0034_runningtotal.py new file mode 100644 index 00000000..c3406ea5 --- /dev/null +++ b/hordak/migrations/0034_runningtotal.py @@ -0,0 +1,372 @@ +# Generated by Django 4.0.8 on 2022-12-20 06:11 + +import django.db.models.deletion +import djmoney.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("hordak", "0033_alter_account_currencies"), + ] + + operations = [ + migrations.CreateModel( + name="RunningTotal", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("currency", models.CharField(max_length=15)), + ( + "balance_currency", + djmoney.models.fields.CurrencyField( + choices=[ + ("XUA", "ADB Unit of Account"), + ("AFN", "Afghan Afghani"), + ("AFA", "Afghan Afghani (1927–2002)"), + ("ALL", "Albanian Lek"), + ("ALK", "Albanian Lek (1946–1965)"), + ("DZD", "Algerian Dinar"), + ("ADP", "Andorran Peseta"), + ("AOA", "Angolan Kwanza"), + ("AOK", "Angolan Kwanza (1977–1991)"), + ("AON", "Angolan New Kwanza (1990–2000)"), + ("AOR", "Angolan Readjusted Kwanza (1995–1999)"), + ("ARA", "Argentine Austral"), + ("ARS", "Argentine Peso"), + ("ARM", "Argentine Peso (1881–1970)"), + ("ARP", "Argentine Peso (1983–1985)"), + ("ARL", "Argentine Peso Ley (1970–1983)"), + ("AMD", "Armenian Dram"), + ("AWG", "Aruban Florin"), + ("AUD", "Australian Dollar"), + ("ATS", "Austrian Schilling"), + ("AZN", "Azerbaijani Manat"), + ("AZM", "Azerbaijani Manat (1993–2006)"), + ("BSD", "Bahamian Dollar"), + ("BHD", "Bahraini Dinar"), + ("BDT", "Bangladeshi Taka"), + ("BBD", "Barbadian Dollar"), + ("BYN", "Belarusian Ruble"), + ("BYB", "Belarusian Ruble (1994–1999)"), + ("BYR", "Belarusian Ruble (2000–2016)"), + ("BEF", "Belgian Franc"), + ("BEC", "Belgian Franc (convertible)"), + ("BEL", "Belgian Franc (financial)"), + ("BZD", "Belize Dollar"), + ("BMD", "Bermudan Dollar"), + ("BTN", "Bhutanese Ngultrum"), + ("BOB", "Bolivian Boliviano"), + ("BOL", "Bolivian Boliviano (1863–1963)"), + ("BOV", "Bolivian Mvdol"), + ("BOP", "Bolivian Peso"), + ("BAM", "Bosnia-Herzegovina Convertible Mark"), + ("BAD", "Bosnia-Herzegovina Dinar (1992–1994)"), + ("BAN", "Bosnia-Herzegovina New Dinar (1994–1997)"), + ("BWP", "Botswanan Pula"), + ("BRC", "Brazilian Cruzado (1986–1989)"), + ("BRZ", "Brazilian Cruzeiro (1942–1967)"), + ("BRE", "Brazilian Cruzeiro (1990–1993)"), + ("BRR", "Brazilian Cruzeiro (1993–1994)"), + ("BRN", "Brazilian New Cruzado (1989–1990)"), + ("BRB", "Brazilian New Cruzeiro (1967–1986)"), + ("BRL", "Brazilian Real"), + ("GBP", "British Pound"), + ("BND", "Brunei Dollar"), + ("BGL", "Bulgarian Hard Lev"), + ("BGN", "Bulgarian Lev"), + ("BGO", "Bulgarian Lev (1879–1952)"), + ("BGM", "Bulgarian Socialist Lev"), + ("BUK", "Burmese Kyat"), + ("BIF", "Burundian Franc"), + ("XPF", "CFP Franc"), + ("KHR", "Cambodian Riel"), + ("CAD", "Canadian Dollar"), + ("CVE", "Cape Verdean Escudo"), + ("KYD", "Cayman Islands Dollar"), + ("XAF", "Central African CFA Franc"), + ("CLE", "Chilean Escudo"), + ("CLP", "Chilean Peso"), + ("CLF", "Chilean Unit of Account (UF)"), + ("CNX", "Chinese People’s Bank Dollar"), + ("CNY", "Chinese Yuan"), + ("CNH", "Chinese Yuan (offshore)"), + ("COP", "Colombian Peso"), + ("COU", "Colombian Real Value Unit"), + ("KMF", "Comorian Franc"), + ("CDF", "Congolese Franc"), + ("CRC", "Costa Rican Colón"), + ("HRD", "Croatian Dinar"), + ("HRK", "Croatian Kuna"), + ("CUC", "Cuban Convertible Peso"), + ("CUP", "Cuban Peso"), + ("CYP", "Cypriot Pound"), + ("CZK", "Czech Koruna"), + ("CSK", "Czechoslovak Hard Koruna"), + ("DKK", "Danish Krone"), + ("DJF", "Djiboutian Franc"), + ("DOP", "Dominican Peso"), + ("NLG", "Dutch Guilder"), + ("XCD", "East Caribbean Dollar"), + ("DDM", "East German Mark"), + ("ECS", "Ecuadorian Sucre"), + ("ECV", "Ecuadorian Unit of Constant Value"), + ("EGP", "Egyptian Pound"), + ("GQE", "Equatorial Guinean Ekwele"), + ("ERN", "Eritrean Nakfa"), + ("EEK", "Estonian Kroon"), + ("ETB", "Ethiopian Birr"), + ("EUR", "Euro"), + ("XBA", "European Composite Unit"), + ("XEU", "European Currency Unit"), + ("XBB", "European Monetary Unit"), + ("XBC", "European Unit of Account (XBC)"), + ("XBD", "European Unit of Account (XBD)"), + ("FKP", "Falkland Islands Pound"), + ("FJD", "Fijian Dollar"), + ("FIM", "Finnish Markka"), + ("FRF", "French Franc"), + ("XFO", "French Gold Franc"), + ("XFU", "French UIC-Franc"), + ("GMD", "Gambian Dalasi"), + ("GEK", "Georgian Kupon Larit"), + ("GEL", "Georgian Lari"), + ("DEM", "German Mark"), + ("GHS", "Ghanaian Cedi"), + ("GHC", "Ghanaian Cedi (1979–2007)"), + ("GIP", "Gibraltar Pound"), + ("XAU", "Gold"), + ("GRD", "Greek Drachma"), + ("GTQ", "Guatemalan Quetzal"), + ("GWP", "Guinea-Bissau Peso"), + ("GNF", "Guinean Franc"), + ("GNS", "Guinean Syli"), + ("GYD", "Guyanaese Dollar"), + ("HTG", "Haitian Gourde"), + ("HNL", "Honduran Lempira"), + ("HKD", "Hong Kong Dollar"), + ("HUF", "Hungarian Forint"), + ("IMP", "IMP"), + ("ISK", "Icelandic Króna"), + ("ISJ", "Icelandic Króna (1918–1981)"), + ("INR", "Indian Rupee"), + ("IDR", "Indonesian Rupiah"), + ("IRR", "Iranian Rial"), + ("IQD", "Iraqi Dinar"), + ("IEP", "Irish Pound"), + ("ILS", "Israeli New Shekel"), + ("ILP", "Israeli Pound"), + ("ILR", "Israeli Shekel (1980–1985)"), + ("ITL", "Italian Lira"), + ("JMD", "Jamaican Dollar"), + ("JPY", "Japanese Yen"), + ("JOD", "Jordanian Dinar"), + ("KZT", "Kazakhstani Tenge"), + ("KES", "Kenyan Shilling"), + ("KWD", "Kuwaiti Dinar"), + ("KGS", "Kyrgystani Som"), + ("LAK", "Laotian Kip"), + ("LVL", "Latvian Lats"), + ("LVR", "Latvian Ruble"), + ("LBP", "Lebanese Pound"), + ("LSL", "Lesotho Loti"), + ("LRD", "Liberian Dollar"), + ("LYD", "Libyan Dinar"), + ("LTL", "Lithuanian Litas"), + ("LTT", "Lithuanian Talonas"), + ("LUL", "Luxembourg Financial Franc"), + ("LUC", "Luxembourgian Convertible Franc"), + ("LUF", "Luxembourgian Franc"), + ("MOP", "Macanese Pataca"), + ("MKD", "Macedonian Denar"), + ("MKN", "Macedonian Denar (1992–1993)"), + ("MGA", "Malagasy Ariary"), + ("MGF", "Malagasy Franc"), + ("MWK", "Malawian Kwacha"), + ("MYR", "Malaysian Ringgit"), + ("MVR", "Maldivian Rufiyaa"), + ("MVP", "Maldivian Rupee (1947–1981)"), + ("MLF", "Malian Franc"), + ("MTL", "Maltese Lira"), + ("MTP", "Maltese Pound"), + ("MRU", "Mauritanian Ouguiya"), + ("MRO", "Mauritanian Ouguiya (1973–2017)"), + ("MUR", "Mauritian Rupee"), + ("MXV", "Mexican Investment Unit"), + ("MXN", "Mexican Peso"), + ("MXP", "Mexican Silver Peso (1861–1992)"), + ("MDC", "Moldovan Cupon"), + ("MDL", "Moldovan Leu"), + ("MCF", "Monegasque Franc"), + ("MNT", "Mongolian Tugrik"), + ("MAD", "Moroccan Dirham"), + ("MAF", "Moroccan Franc"), + ("MZE", "Mozambican Escudo"), + ("MZN", "Mozambican Metical"), + ("MZM", "Mozambican Metical (1980–2006)"), + ("MMK", "Myanmar Kyat"), + ("NAD", "Namibian Dollar"), + ("NPR", "Nepalese Rupee"), + ("ANG", "Netherlands Antillean Guilder"), + ("TWD", "New Taiwan Dollar"), + ("NZD", "New Zealand Dollar"), + ("NIO", "Nicaraguan Córdoba"), + ("NIC", "Nicaraguan Córdoba (1988–1991)"), + ("NGN", "Nigerian Naira"), + ("KPW", "North Korean Won"), + ("NOK", "Norwegian Krone"), + ("OMR", "Omani Rial"), + ("PKR", "Pakistani Rupee"), + ("XPD", "Palladium"), + ("PAB", "Panamanian Balboa"), + ("PGK", "Papua New Guinean Kina"), + ("PYG", "Paraguayan Guarani"), + ("PEI", "Peruvian Inti"), + ("PEN", "Peruvian Sol"), + ("PES", "Peruvian Sol (1863–1965)"), + ("PHP", "Philippine Peso"), + ("XPT", "Platinum"), + ("PLN", "Polish Zloty"), + ("PLZ", "Polish Zloty (1950–1995)"), + ("PTE", "Portuguese Escudo"), + ("GWE", "Portuguese Guinea Escudo"), + ("QAR", "Qatari Rial"), + ("XRE", "RINET Funds"), + ("RHD", "Rhodesian Dollar"), + ("RON", "Romanian Leu"), + ("ROL", "Romanian Leu (1952–2006)"), + ("RUB", "Russian Ruble"), + ("RUR", "Russian Ruble (1991–1998)"), + ("RWF", "Rwandan Franc"), + ("SVC", "Salvadoran Colón"), + ("WST", "Samoan Tala"), + ("SAR", "Saudi Riyal"), + ("RSD", "Serbian Dinar"), + ("CSD", "Serbian Dinar (2002–2006)"), + ("SCR", "Seychellois Rupee"), + ("SLL", "Sierra Leonean Leone"), + ("XAG", "Silver"), + ("SGD", "Singapore Dollar"), + ("SKK", "Slovak Koruna"), + ("SIT", "Slovenian Tolar"), + ("SBD", "Solomon Islands Dollar"), + ("SOS", "Somali Shilling"), + ("ZAR", "South African Rand"), + ("ZAL", "South African Rand (financial)"), + ("KRH", "South Korean Hwan (1953–1962)"), + ("KRW", "South Korean Won"), + ("KRO", "South Korean Won (1945–1953)"), + ("SSP", "South Sudanese Pound"), + ("SUR", "Soviet Rouble"), + ("ESP", "Spanish Peseta"), + ("ESA", "Spanish Peseta (A account)"), + ("ESB", "Spanish Peseta (convertible account)"), + ("XDR", "Special Drawing Rights"), + ("LKR", "Sri Lankan Rupee"), + ("SHP", "St. Helena Pound"), + ("XSU", "Sucre"), + ("SDD", "Sudanese Dinar (1992–2007)"), + ("SDG", "Sudanese Pound"), + ("SDP", "Sudanese Pound (1957–1998)"), + ("SRD", "Surinamese Dollar"), + ("SRG", "Surinamese Guilder"), + ("SZL", "Swazi Lilangeni"), + ("SEK", "Swedish Krona"), + ("CHF", "Swiss Franc"), + ("SYP", "Syrian Pound"), + ("STN", "São Tomé & Príncipe Dobra"), + ("STD", "São Tomé & Príncipe Dobra (1977–2017)"), + ("TVD", "TVD"), + ("TJR", "Tajikistani Ruble"), + ("TJS", "Tajikistani Somoni"), + ("TZS", "Tanzanian Shilling"), + ("XTS", "Testing Currency Code"), + ("THB", "Thai Baht"), + ( + "XXX", + "The codes assigned for transactions where no currency is involved", + ), + ("TPE", "Timorese Escudo"), + ("TOP", "Tongan Paʻanga"), + ("TTD", "Trinidad & Tobago Dollar"), + ("TND", "Tunisian Dinar"), + ("TRY", "Turkish Lira"), + ("TRL", "Turkish Lira (1922–2005)"), + ("TMT", "Turkmenistani Manat"), + ("TMM", "Turkmenistani Manat (1993–2009)"), + ("USD", "US Dollar"), + ("USN", "US Dollar (Next day)"), + ("USS", "US Dollar (Same day)"), + ("UGX", "Ugandan Shilling"), + ("UGS", "Ugandan Shilling (1966–1987)"), + ("UAH", "Ukrainian Hryvnia"), + ("UAK", "Ukrainian Karbovanets"), + ("AED", "United Arab Emirates Dirham"), + ("UYW", "Uruguayan Nominal Wage Index Unit"), + ("UYU", "Uruguayan Peso"), + ("UYP", "Uruguayan Peso (1975–1993)"), + ("UYI", "Uruguayan Peso (Indexed Units)"), + ("UZS", "Uzbekistani Som"), + ("VUV", "Vanuatu Vatu"), + ("VES", "Venezuelan Bolívar"), + ("VEB", "Venezuelan Bolívar (1871–2008)"), + ("VEF", "Venezuelan Bolívar (2008–2018)"), + ("VND", "Vietnamese Dong"), + ("VNN", "Vietnamese Dong (1978–1985)"), + ("CHE", "WIR Euro"), + ("CHW", "WIR Franc"), + ("XOF", "West African CFA Franc"), + ("YDD", "Yemeni Dinar"), + ("YER", "Yemeni Rial"), + ("YUN", "Yugoslavian Convertible Dinar (1990–1992)"), + ("YUD", "Yugoslavian Hard Dinar (1966–1990)"), + ("YUM", "Yugoslavian New Dinar (1994–2002)"), + ("YUR", "Yugoslavian Reformed Dinar (1992–1993)"), + ("ZWN", "ZWN"), + ("ZRN", "Zairean New Zaire (1993–1998)"), + ("ZRZ", "Zairean Zaire (1971–1993)"), + ("ZMW", "Zambian Kwacha"), + ("ZMK", "Zambian Kwacha (1968–2012)"), + ("ZWD", "Zimbabwean Dollar (1980–2008)"), + ("ZWR", "Zimbabwean Dollar (2008)"), + ("ZWL", "Zimbabwean Dollar (2009)"), + ], + default="EUR", + editable=False, + max_length=3, + null=True, + ), + ), + ( + "balance", + djmoney.models.fields.MoneyField( + blank=True, + decimal_places=2, + default_currency="EUR", + max_digits=13, + null=True, + ), + ), + ( + "account", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="running_totals", + to="hordak.account", + ), + ), + ], + options={ + "verbose_name": "Running Total", + "verbose_name_plural": "Running Totals", + }, + ), + ] diff --git a/hordak/models/core.py b/hordak/models/core.py index 98d3897c..66dc75ae 100644 --- a/hordak/models/core.py +++ b/hordak/models/core.py @@ -25,6 +25,8 @@ from django.db import models from django.db import transaction as db_transaction from django.db.models import JSONField +from django.db.models.signals import post_delete, pre_save +from django.dispatch import receiver from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django_smalluuid.models import SmallUUIDField, uuid_default @@ -59,6 +61,41 @@ def get_by_natural_key(self, uuid): return self.get(uuid=uuid) +class RunningTotal(models.Model): + """ + A running total of the balance of an account. + + This is used to speed up the calculation of account balances. + + This field should be considered an estimated value calculated for performance reasons. + It is not guaranteed to be accurate. + + Running totals are calculated through post_delete and pre_save signals on the Transaction model. + This means that they are not updated when a transaction is mass updated. + """ + + account = models.ForeignKey( + "hordak.Account", on_delete=models.CASCADE, related_name="running_totals" + ) + currency = models.CharField( + max_length=15, + ) + balance = MoneyField( + max_digits=MAX_DIGITS, + decimal_places=DECIMAL_PLACES, + default_currency=defaults.DEFAULT_CURRENCY, + null=True, + blank=True, + ) + + class Meta: + verbose_name = _("Running Total") + verbose_name_plural = _("Running Totals") + + def __str__(self): + return f"{self.account}: {self.balance}" + + class Account(MPTTModel): """Represents an account @@ -549,6 +586,38 @@ class Meta: verbose_name = _("Leg") +@receiver(pre_save, sender=Leg) +def update_running_totals(sender, instance, **kwargs): + """Update the running total of the account associated with the leg""" + if instance.pk: + # We are updating the leg, so we need to get the old amount + old_amount = sender.objects.get(pk=instance.pk).amount + amount_change = instance.amount - old_amount + else: + amount_change = instance.amount + + running_total, _ = RunningTotal.objects.get_or_create( + account=instance.account, + currency=instance.amount.currency, + ) + if running_total.balance: + running_total.balance += amount_change + else: + running_total.balance = amount_change + running_total.save() + + +@receiver(post_delete, sender=Leg) +def update_running_totals_on_delete(sender, instance, **kwargs): + """Update the running total of the account associated with the leg""" + running_total = RunningTotal.objects.get( + account=instance.account, + currency=instance.amount.currency, + ) + running_total.balance -= instance.amount + running_total.save() + + class StatementImportManager(models.Manager): def get_by_natural_key(self, uuid): return self.get(uuid=uuid) diff --git a/hordak/tests/models/test_core.py b/hordak/tests/models/test_core.py index 60dd111b..fe72d6ca 100644 --- a/hordak/tests/models/test_core.py +++ b/hordak/tests/models/test_core.py @@ -137,6 +137,72 @@ def test_balance_simple(self): self.assertEqual(account1.simple_balance(), Balance(100, "EUR")) self.assertEqual(account2.simple_balance(), Balance(-100, "EUR")) + # Test also running totals: + self.assertEqual(account1.running_totals.all()[0].balance, Money(100, "EUR")) + self.assertEqual(account2.running_totals.all()[0].balance, Money(-100, "EUR")) + + def test_balance_delete(self): + """Account balance and running totals should match after deleting a transaction""" + account1 = self.account() + account2 = self.account() + + with db_transaction.atomic(): + transaction = Transaction.objects.create() + Leg.objects.create( + transaction=transaction, account=account1, amount=Money(100, "EUR") + ) + Leg.objects.create( + transaction=transaction, account=account2, amount=Money(-100, "EUR") + ) + + self.assertEqual(account1.simple_balance(), Balance(100, "EUR")) + self.assertEqual(account2.simple_balance(), Balance(-100, "EUR")) + # Test also running totals: + self.assertEqual(account1.running_totals.all()[0].balance, Money(100, "EUR")) + self.assertEqual(account2.running_totals.all()[0].balance, Money(-100, "EUR")) + + transaction.delete() + + self.assertEqual(account1.simple_balance(), Balance(0, "EUR")) + self.assertEqual(account2.simple_balance(), Balance(0, "EUR")) + # Test also running totals: + self.assertEqual(account1.running_totals.all()[0].balance, Money(0, "EUR")) + self.assertEqual(account2.running_totals.all()[0].balance, Money(0, "EUR")) + + def test_balance_update(self): + """Account balance and running totals should match after updating a transaction""" + account1 = self.account() + account2 = self.account() + + with db_transaction.atomic(): + transaction = Transaction.objects.create() + Leg.objects.create( + transaction=transaction, account=account1, amount=Money(100, "EUR") + ) + Leg.objects.create( + transaction=transaction, account=account2, amount=Money(-100, "EUR") + ) + + self.assertEqual(account1.simple_balance(), Balance(100, "EUR")) + self.assertEqual(account2.simple_balance(), Balance(-100, "EUR")) + # Test also running totals: + self.assertEqual(account1.running_totals.all()[0].balance, Money(100, "EUR")) + self.assertEqual(account2.running_totals.all()[0].balance, Money(-100, "EUR")) + + with db_transaction.atomic(): + leg_1 = transaction.legs.all()[0] + leg_1.amount = Money(200, "EUR") + leg_1.save() + leg_2 = transaction.legs.all()[1] + leg_2.amount = Money(-200, "EUR") + leg_2.save() + + # Test also running totals: + self.assertEqual(account1.running_totals.all()[0].balance, Money(200, "EUR")) + self.assertEqual(account2.running_totals.all()[0].balance, Money(-200, "EUR")) + + self.assertEqual(account1.simple_balance(), Balance(200, "EUR")) + self.assertEqual(account2.simple_balance(), Balance(-200, "EUR")) def test_balance_3legs(self): account1 = self.account() @@ -158,6 +224,10 @@ def test_balance_3legs(self): self.assertEqual(account1.simple_balance(), Balance(100, "EUR")) self.assertEqual(account2.simple_balance(), Balance(-40, "EUR")) self.assertEqual(account3.simple_balance(), Balance(-60, "EUR")) + # Test also running totals: + self.assertEqual(account1.running_totals.all()[0].balance, Money(100, "EUR")) + self.assertEqual(account2.running_totals.all()[0].balance, Money(-40, "EUR")) + self.assertEqual(account3.running_totals.all()[0].balance, Money(-60, "EUR")) def test_balance_simple_as_of(self): account1 = self.account() diff --git a/hordak/tests/test_commands.py b/hordak/tests/test_commands.py index 733d3b6f..2cb8302f 100644 --- a/hordak/tests/test_commands.py +++ b/hordak/tests/test_commands.py @@ -1,7 +1,11 @@ from django.core.management import call_command +from django.db import transaction as db_transaction from django.test.testcases import TestCase +from django.test.testcases import TransactionTestCase as DbTransactionTestCase +from moneyed.classes import Money -from hordak.models import Account +from hordak.models import Account, Leg, RunningTotal, Transaction +from hordak.tests.utils import DataProvider class CreateChartOfAccountsTestCase(TestCase): @@ -16,3 +20,30 @@ def test_multi_currency(self): self.assertGreater(Account.objects.count(), 10) account = Account.objects.all()[0] self.assertEqual(account.currencies, ["USD", "EUR"]) + + +class RecalculateRunningTotalsTestCase(DataProvider, DbTransactionTestCase): + def test_simple(self): + + account1 = self.account() + account2 = self.account() + with db_transaction.atomic(): + transaction = Transaction.objects.create() + Leg.objects.create( + transaction=transaction, account=account1, amount=Money(100, "EUR") + ) + Leg.objects.create( + transaction=transaction, account=account2, amount=Money(-100, "EUR") + ) + + RunningTotal.objects.all().delete() + + call_command("recalculate_running_totals") + account1.refresh_from_db() + account2.refresh_from_db() + self.assertEqual( + account1.running_totals.get(currency="EUR").balance, Money(100, "EUR") + ) + self.assertEqual( + account2.running_totals.get(currency="EUR").balance, Money(-100, "EUR") + )