From 78a1673a98e89045df866e319d0acc0b58983849 Mon Sep 17 00:00:00 2001 From: Dan Jacob Date: Tue, 16 Jan 2024 09:24:35 +0200 Subject: [PATCH 1/4] Fix VAT rate for fixed period price changes --- parking_permits/models/parking_permit.py | 7 ++++--- parking_permits/tests/models/test_parking_permit.py | 10 ++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/parking_permits/models/parking_permit.py b/parking_permits/models/parking_permit.py index e8c2c754..c62155c7 100644 --- a/parking_permits/models/parking_permit.py +++ b/parking_permits/models/parking_permit.py @@ -506,9 +506,10 @@ def get_price_change_list(self, new_zone, is_low_emission): else: # if the product is different or diff price is different, # create a new price change item - price_change_vat = (diff_price * new_product.vat).quantize( - Decimal("0.0001") - ) + price_change_vat = calc_vat_price( + diff_price, new_product.vat + ).quantize(Decimal("0.0001")) + price_change_list.append( { "product": new_product.name, diff --git a/parking_permits/tests/models/test_parking_permit.py b/parking_permits/tests/models/test_parking_permit.py index 072e7f62..723ea798 100644 --- a/parking_permits/tests/models/test_parking_permit.py +++ b/parking_permits/tests/models/test_parking_permit.py @@ -543,7 +543,7 @@ def test_parking_permit_change_price_list_when_prices_go_down(self): self.assertEqual(price_change_list[0]["new_price"], Decimal("15")) self.assertEqual(price_change_list[0]["price_change"], Decimal("-5")) self.assertEqual( - price_change_list[0]["price_change_vat"], Decimal("-1.2") + price_change_list[0]["price_change_vat"], Decimal("-0.9677") ) self.assertEqual(price_change_list[0]["month_count"], 2) self.assertEqual(price_change_list[0]["start_date"], date(2021, 5, 1)) @@ -555,7 +555,7 @@ def test_parking_permit_change_price_list_when_prices_go_down(self): self.assertEqual(price_change_list[1]["new_price"], Decimal("20")) self.assertEqual(price_change_list[1]["price_change"], Decimal("-10")) self.assertEqual( - price_change_list[1]["price_change_vat"], Decimal("-2.4") + price_change_list[1]["price_change_vat"], Decimal("-1.9355") ) self.assertEqual(price_change_list[1]["month_count"], 6) self.assertEqual(price_change_list[1]["start_date"], date(2021, 7, 1)) @@ -611,7 +611,7 @@ def test_parking_permit_change_price_list_when_prices_go_up(self): self.assertEqual(price_change_list[0]["new_price"], Decimal("30")) self.assertEqual(price_change_list[0]["price_change"], Decimal("20")) self.assertEqual( - price_change_list[0]["price_change_vat"], Decimal("4.8") + price_change_list[0]["price_change_vat"], Decimal("3.8710") ) self.assertEqual(price_change_list[0]["month_count"], 2) self.assertEqual( @@ -626,7 +626,9 @@ def test_parking_permit_change_price_list_when_prices_go_up(self): self.assertEqual(price_change_list[1]["previous_price"], Decimal("15")) self.assertEqual(price_change_list[1]["new_price"], Decimal("40")) self.assertEqual(price_change_list[1]["price_change"], Decimal("25")) - self.assertEqual(price_change_list[1]["price_change_vat"], Decimal("6")) + self.assertEqual( + price_change_list[1]["price_change_vat"], Decimal("4.8387") + ) self.assertEqual(price_change_list[1]["month_count"], 6) self.assertEqual( price_change_list[1]["start_date"], date(CURRENT_YEAR, 7, 1) From e68ad0c135e8559a31d84fde12b818b5973d5596 Mon Sep 17 00:00:00 2001 From: Dan Jacob Date: Tue, 16 Jan 2024 09:49:27 +0200 Subject: [PATCH 2/4] Add type checks and comments to calc_net_price and calc_vat_price Refs: PV-763 --- parking_permits/models/refund.py | 13 +++++++--- parking_permits/utils.py | 42 +++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/parking_permits/models/refund.py b/parking_permits/models/refund.py index 8a297a45..999609e1 100644 --- a/parking_permits/models/refund.py +++ b/parking_permits/models/refund.py @@ -4,9 +4,9 @@ from django.contrib.gis.db import models from django.utils.translation import gettext_lazy as _ -from .mixins import TimestampedModelMixin, UserStampedModelMixin +from parking_permits.utils import calc_vat_price -VAT_PERCENT = Decimal(0.24) +from .mixins import TimestampedModelMixin, UserStampedModelMixin class RefundStatus(models.TextChoices): @@ -59,4 +59,11 @@ def __str__(self): @property def vat(self): - return Decimal(self.amount) * VAT_PERCENT + """Calculate the VAT amount. + We need to calculate this based on the products of the individual order items. + + Returns zero if no order items. + """ + return self.amount * 0.24 + # vat_percent = self.order.order_items.aggregate(models.Avg("vat"))["vat__avg"] + # return calc_vat_price(self.amount, vat_percent) diff --git a/parking_permits/utils.py b/parking_permits/utils.py index 9e97cd12..0f27654b 100644 --- a/parking_permits/utils.py +++ b/parking_permits/utils.py @@ -4,12 +4,12 @@ from collections import OrderedDict from collections.abc import Callable from datetime import datetime -from decimal import ROUND_UP, Decimal +from decimal import Decimal from itertools import chain +from typing import Optional, Union from ariadne import convert_camel_case_to_snake from dateutil.relativedelta import relativedelta -from django.conf import settings from django.db import models from django.utils import timezone as tz from graphql import GraphQLResolveInfo @@ -17,6 +17,8 @@ HELSINKI_TZ = zoneinfo.ZoneInfo("Europe/Helsinki") +Currency = Optional[Union[str, float, Decimal]] + class DefaultOrderedDict(OrderedDict): def __init__(self, default_factory=None, *a, **kw): @@ -337,12 +339,40 @@ def flatten_dict(d, separator="__", prefix="", _output_ref=None) -> dict: return output -def calc_net_price(gross_price, vat): - return Decimal(gross_price) / Decimal(1 + (vat or 0)) if gross_price else Decimal(0) +def calc_net_price(gross_price: Currency, vat: Currency) -> Decimal: + """Returns the net price based on the gross and VAT e.g. 0.24 + + Net price is calculated thus: + + gross / (1 + vat) + + For example, gross 100 EUR, VAT 24% would be: + 100 / 1.24 = ~80.64 + + If gross or vat is zero or None, returns zero. + """ + return ( + Decimal(gross_price) / Decimal(1 + (Decimal(vat or 0))) + if gross_price and vat + else Decimal(0) + ) -def calc_vat_price(gross_price, vat): - return gross_price - calc_net_price(gross_price, vat) if gross_price else Decimal(0) + +def calc_vat_price(gross_price: Currency, vat: Currency) -> Decimal: + """Returns the VAT price based on the gross and VAT e.g. 0.24 + + VAT price is equal to the gross minus the net. + + For example, gross 100 EUR, VAT 24% would be net price of ~80.64. + + VAT price would therefore be 100-80.64 = 19.36. + """ + return ( + Decimal(gross_price) - calc_net_price(gross_price, vat) + if gross_price and vat + else Decimal(0) + ) def round_up(v): From e2e5ec0da389aa619465a0fac94954cf61b09869 Mon Sep 17 00:00:00 2001 From: Dan Jacob Date: Tue, 16 Jan 2024 10:17:38 +0200 Subject: [PATCH 3/4] Calculate refund vat price based on individual order items --- parking_permits/models/refund.py | 6 +++--- parking_permits/tests/models/test_refund.py | 22 +++++++++++++++++++++ parking_permits/utils.py | 3 ++- 3 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 parking_permits/tests/models/test_refund.py diff --git a/parking_permits/models/refund.py b/parking_permits/models/refund.py index 999609e1..d5432db2 100644 --- a/parking_permits/models/refund.py +++ b/parking_permits/models/refund.py @@ -64,6 +64,6 @@ def vat(self): Returns zero if no order items. """ - return self.amount * 0.24 - # vat_percent = self.order.order_items.aggregate(models.Avg("vat"))["vat__avg"] - # return calc_vat_price(self.amount, vat_percent) + return Decimal( + sum([item.total_price_vat for item in self.order.order_items.all()]) + ) diff --git a/parking_permits/tests/models/test_refund.py b/parking_permits/tests/models/test_refund.py new file mode 100644 index 00000000..56ed2c17 --- /dev/null +++ b/parking_permits/tests/models/test_refund.py @@ -0,0 +1,22 @@ +from decimal import Decimal + +from django.test import TestCase + +from parking_permits.tests.factories.order import OrderFactory, OrderItemFactory +from parking_permits.tests.factories.refund import RefundFactory + + +class TestRefund(TestCase): + def test_vat_no_order_items(self): + refund = RefundFactory() + assert refund.vat == Decimal(0) + + def test_vat_with_different_rates(self): + order = OrderFactory() + + OrderItemFactory(unit_price=10, vat=0.24, order=order, quantity=2) + OrderItemFactory(unit_price=10, vat=0.20, order=order, quantity=1) + + refund = RefundFactory(order=order, amount=200) + + self.assertAlmostEqual(refund.vat, Decimal(5.53), delta=Decimal("0.01")) diff --git a/parking_permits/utils.py b/parking_permits/utils.py index 0f27654b..600f33e5 100644 --- a/parking_permits/utils.py +++ b/parking_permits/utils.py @@ -4,12 +4,13 @@ from collections import OrderedDict from collections.abc import Callable from datetime import datetime -from decimal import Decimal +from decimal import ROUND_UP, Decimal from itertools import chain from typing import Optional, Union from ariadne import convert_camel_case_to_snake from dateutil.relativedelta import relativedelta +from django.conf import settings from django.db import models from django.utils import timezone as tz from graphql import GraphQLResolveInfo From b5a3e8ed01e4a227444ed7512216bda8890670a1 Mon Sep 17 00:00:00 2001 From: Dan Jacob Date: Tue, 16 Jan 2024 10:30:26 +0200 Subject: [PATCH 4/4] Use hard-coded percentage for refund VAT Refs: PV-763 --- parking_permits/models/refund.py | 15 +++++++++------ parking_permits/tests/models/test_refund.py | 16 +++++----------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/parking_permits/models/refund.py b/parking_permits/models/refund.py index d5432db2..ff64ec4a 100644 --- a/parking_permits/models/refund.py +++ b/parking_permits/models/refund.py @@ -8,6 +8,8 @@ from .mixins import TimestampedModelMixin, UserStampedModelMixin +VAT_PERCENT = Decimal(0.24) + class RefundStatus(models.TextChoices): OPEN = "OPEN", _("Open") @@ -50,6 +52,10 @@ class Refund(TimestampedModelMixin, UserStampedModelMixin): blank=True, ) + # this is hard coded at the moment. At some point should be + # a field we can calculate when the refund is generated. + vat_percent = VAT_PERCENT + class Meta: verbose_name = _("Refund") verbose_name_plural = _("Refunds") @@ -60,10 +66,7 @@ def __str__(self): @property def vat(self): """Calculate the VAT amount. - We need to calculate this based on the products of the individual order items. - - Returns zero if no order items. + The VAT amount is hard coded here because we do not know the % of individual + unused items in this model. In future this should probably be stored as a separate field. """ - return Decimal( - sum([item.total_price_vat for item in self.order.order_items.all()]) - ) + return calc_vat_price(self.amount, self.vat_percent) diff --git a/parking_permits/tests/models/test_refund.py b/parking_permits/tests/models/test_refund.py index 56ed2c17..1744b8f9 100644 --- a/parking_permits/tests/models/test_refund.py +++ b/parking_permits/tests/models/test_refund.py @@ -2,21 +2,15 @@ from django.test import TestCase -from parking_permits.tests.factories.order import OrderFactory, OrderItemFactory from parking_permits.tests.factories.refund import RefundFactory class TestRefund(TestCase): - def test_vat_no_order_items(self): - refund = RefundFactory() + def test_vat_zero_amount(self): + refund = RefundFactory(amount=0) assert refund.vat == Decimal(0) - def test_vat_with_different_rates(self): - order = OrderFactory() + def test_vat(self): + refund = RefundFactory(amount=100) - OrderItemFactory(unit_price=10, vat=0.24, order=order, quantity=2) - OrderItemFactory(unit_price=10, vat=0.20, order=order, quantity=1) - - refund = RefundFactory(order=order, amount=200) - - self.assertAlmostEqual(refund.vat, Decimal(5.53), delta=Decimal("0.01")) + self.assertAlmostEqual(refund.vat, Decimal(19.35), delta=Decimal("0.01"))