diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3b8159c..23d875f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 environment: test strategy: matrix: diff --git a/CHANGELOG.md b/CHANGELOG.md index b390a060..18971b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,22 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2024-08-30 + +### Added + +- Add VAT and VAT-amount to Refund-model ([d4e72f8](https://github.com/City-of-Helsinki/parking-permits/commit/d4e72f80d6ac20fc85b69c47e843d55f8bbbb31b)) +- Add VAT to Refund in Django Admin ([273018b](https://github.com/City-of-Helsinki/parking-permits/commit/273018bd9c34c5456b1d52c0fd83ce91e9fbd1ed)) + +### Changed + +- Update low-emission criteria evaluation rules ([08aeef5](https://github.com/City-of-Helsinki/parking-permits/commit/08aeef571eab6228830ced83c9c4d2028023b5d9)) +- Explicitly use Ubuntu 20.04 base image with GitHub Actions ([8c29d7a](https://github.com/City-of-Helsinki/parking-permits/commit/8c29d7a3aa4038350bec140d4f6baa83d9f506e3)) +- Update VAT-percentages from Talpa-endpoints ([f79f9dd](https://github.com/City-of-Helsinki/parking-permits/commit/f79f9dda6b258823ec689da2ac8a48b533a87766)) +- Update VAT-percentages for Refund ([4afdb80](https://github.com/City-of-Helsinki/parking-permits/commit/4afdb80edf6f1f1fc339a24fa39f43c3fc43a4c2)) +- Use AWS ECR Docker image repository ([c7ef79d](https://github.com/City-of-Helsinki/parking-permits/commit/c7ef79d7dd9ca830297a7fd0da2ff301b576db79)) +- Change Refund VAT to be dynamic ([8e7ca2f](https://github.com/City-of-Helsinki/parking-permits/commit/8e7ca2fa716569815813bcb31b5e951aa1672f48)) +- Change Refund creation to be VAT-based ([1ce4469](https://github.com/City-of-Helsinki/parking-permits/commit/1ce44694eff95ff4a42c3d8012d1242da30c067c)) + +### Fixed + +- Fix price calculation for first days of the month ([f96ebd6](https://github.com/City-of-Helsinki/parking-permits/commit/f96ebd69a737acad40e52df9332d4c257b38f2b0)) +- Fix find_next_date-utility function ([326da62](https://github.com/City-of-Helsinki/parking-permits/commit/326da625d2fb8d46ebb1cc918854b35af24d890e)) + ## [1.1.0] - 2024-06-19 ### Added -- Add changelog to project +- Add changelog to project ([2d4300a](https://github.com/City-of-Helsinki/parking-permits/commit/2d4300a6bc329533474e33b861d2e73cc887460c)) ### Changed -- Update Python version to 3.11 from CI -- Update application packages -- Update fi/sv/en translations -- Update Azure CI-settings +- Update Python version to 3.11 from CI ([dd07848](https://github.com/City-of-Helsinki/parking-permits/commit/dd0784825344cdf09081b6a7dad937241e1c65d5)) +- Update application packages ([f0e2e5c](https://github.com/City-of-Helsinki/parking-permits/commit/f0e2e5c3dd72cfc8102d2e888651b5e60b3d6019)) +- Update fi/sv/en translations ([f60e86d](https://github.com/City-of-Helsinki/parking-permits/commit/f60e86d659daf8bf342e294c6c53ddd41becfee5)) +- Update Azure CI-settings ([456ddf4](https://github.com/City-of-Helsinki/parking-permits/commit/456ddf40b77952101877d9b7375c596fae4c447d)) ### Removed -- Remove obsolete Docker Compose version +- Remove obsolete Docker Compose version ([8e3b8ab](https://github.com/City-of-Helsinki/parking-permits/commit/8e3b8ab3d8173b575dc8d55313469b438238cbb0)) ## [1.0.0] - 2024-06-12 diff --git a/Dockerfile b/Dockerfile index 72147fc7..5b7b323b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:20.04 as base +FROM public.ecr.aws/ubuntu/ubuntu:20.04 as base WORKDIR /app diff --git a/parking_permits/admin.py b/parking_permits/admin.py index 4b6968dd..529c5769 100644 --- a/parking_permits/admin.py +++ b/parking_permits/admin.py @@ -210,6 +210,7 @@ class RefundAdmin(admin.ModelAdmin): "status", "created_at", "accepted_at", + "vat", ) list_select_related = ("order",) ordering = ("-created_at",) diff --git a/parking_permits/admin_resolvers.py b/parking_permits/admin_resolvers.py index 157ac159..9a19edb5 100644 --- a/parking_permits/admin_resolvers.py +++ b/parking_permits/admin_resolvers.py @@ -40,7 +40,7 @@ from parking_permits.services.parkkihubi import sync_with_parkkihubi from users.models import ParkingPermitGroups -from .constants import EventFields, Origin +from .constants import DEFAULT_VAT, EventFields, Origin from .decorators import ( is_customer_service, is_inspectors, @@ -817,6 +817,11 @@ def resolve_update_resident_permit( order=order, amount=-customer_total_price_change, iban=iban, + vat=( + order.order_items.first().vat + if order.order_items.exists() + else DEFAULT_VAT + ), description=f"Refund for updating permit: {permit.id}", ) refund.permits.add(permit) @@ -824,7 +829,7 @@ def resolve_update_resident_permit( ParkingPermitEventFactory.make_create_refund_event( permit, refund, created_by=request.user ) - send_refund_email(RefundEmailType.CREATED, customer, refund) + send_refund_email(RefundEmailType.CREATED, customer, [refund]) bypass_traficom_validation = permit_info.get("bypass_traficom_validation", False) @@ -1004,7 +1009,6 @@ def resolve_end_permit( request.user, permit, end_type=end_type, - payment_type=OrderPaymentType.CASHIER_PAYMENT, iban=iban, ) return {"success": True} diff --git a/parking_permits/constants.py b/parking_permits/constants.py index 8e444405..8f0e9fa1 100644 --- a/parking_permits/constants.py +++ b/parking_permits/constants.py @@ -1,5 +1,7 @@ +from decimal import Decimal + +DEFAULT_VAT = Decimal(0.255) SECONDARY_VEHICLE_PRICE_INCREASE = 50 -VAT_PERCENTAGE = 24 class Origin: diff --git a/parking_permits/customer_permit.py b/parking_permits/customer_permit.py index 4995c733..158c862b 100644 --- a/parking_permits/customer_permit.py +++ b/parking_permits/customer_permit.py @@ -390,7 +390,6 @@ def end( user, *permits, end_type=end_type, - payment_type=OrderPaymentType.ONLINE_PAYMENT, iban=iban, subscription_cancel_reason=subscription_cancel_reason, cancel_from_talpa=cancel_from_talpa, diff --git a/parking_permits/exporters.py b/parking_permits/exporters.py index cb9f1279..2152910c 100644 --- a/parking_permits/exporters.py +++ b/parking_permits/exporters.py @@ -313,7 +313,9 @@ def get_refund_content(refund): _("Customer") + ": " + f"{refund.order.customer.first_name} {refund.order.customer.last_name}", - _("Amount") + ": " + f"{refund.amount} e (" + _("incl. VAT") + " 24%)", + _("Amount") + + ": " + + f"{refund.amount} e ({_('incl. VAT')} {_format_percentage(refund.vat_percent)} %)", _("IBAN") + ": " + f"{refund.iban}", _("Status") + ": " + f"{refund.get_status_display()}", _("Extra info") + ": " + f"{refund.description}", diff --git a/parking_permits/management/commands/create_parking_zone_products.py b/parking_permits/management/commands/create_parking_zone_products.py index 6f08e1b7..f0db2933 100644 --- a/parking_permits/management/commands/create_parking_zone_products.py +++ b/parking_permits/management/commands/create_parking_zone_products.py @@ -71,7 +71,7 @@ def handle(self, *args, **options): "type": ProductType.RESIDENT, "unit_price": zone_price * Decimal(options["price_increment_factor_old_zone"]), - "vat": Decimal(0.24), + "vat": Decimal(0.255), "low_emission_discount": Decimal( options["low_emission_discount_old_zone"] ), @@ -90,7 +90,7 @@ def handle(self, *args, **options): "type": ProductType.RESIDENT, "unit_price": zone_price * Decimal(options["price_increment_factor_new_zone"]), - "vat": Decimal(0.24), + "vat": Decimal(0.255), "low_emission_discount": Decimal( options["low_emission_discount_new_zone"] ), diff --git a/parking_permits/migrations/0059_refund_vat.py b/parking_permits/migrations/0059_refund_vat.py new file mode 100644 index 00000000..f8de692b --- /dev/null +++ b/parking_permits/migrations/0059_refund_vat.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.6 on 2024-08-09 04:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("parking_permits", "0058_alter_vehicle_vehicle_class"), + ] + + operations = [ + migrations.AddField( + model_name="refund", + name="vat", + field=models.DecimalField( + decimal_places=4, default=0.24, max_digits=6, verbose_name="VAT" + ), + ), + ] diff --git a/parking_permits/models/order.py b/parking_permits/models/order.py index 1a257656..57582b2c 100644 --- a/parking_permits/models/order.py +++ b/parking_permits/models/order.py @@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _ from helsinki_gdpr.models import SerializableMixin +from ..constants import DEFAULT_VAT from ..exceptions import ( OrderCancelError, OrderCreationFailed, @@ -664,10 +665,15 @@ def cancel(self, cancel_reason, cancel_from_talpa=True, iban=""): order=order, amount=permit.total_refund_amount, iban=iban, + vat=( + order.order_items.first().vat + if order.order_items.exists() + else DEFAULT_VAT + ), description=f"Refund for ending permit {str(permit.id)}", ) refund.permits.add(permit) - send_refund_email(RefundEmailType.CREATED, permit.customer, refund) + send_refund_email(RefundEmailType.CREATED, permit.customer, [refund]) ParkingPermitEventFactory.make_create_refund_event( permit, refund, created_by=permit.customer.user ) diff --git a/parking_permits/models/parking_permit.py b/parking_permits/models/parking_permit.py index b783e07e..7ba8d316 100644 --- a/parking_permits/models/parking_permit.py +++ b/parking_permits/models/parking_permit.py @@ -459,7 +459,7 @@ def can_be_refunded(self): @property def total_refund_amount(self): - return self.get_refund_amount_for_unused_items() + return self.get_total_refund_amount_for_unused_items() @property def zone_changed(self): @@ -677,6 +677,7 @@ def get_price_change_list(self, new_zone, is_low_emission): "previous_price": previous_price, "new_price": new_price, "price_change_vat": price_change_vat, + "price_change_vat_percent": new_product.vat_percentage, "price_change": diff_price, "start_date": start_date, "end_date": end_date, @@ -720,11 +721,17 @@ def get_price_change_list(self, new_zone, is_low_emission): # quantity by 1 price_change_list[-1]["month_count"] += 1 else: + # if the price is decreased, get VAT from the previous permit order + vat = ( + self.latest_order.vat + if diff_price < 0 and self.latest_order + else new_product.vat + ) # if the product is different or diff price is different, # create a new price change item - price_change_vat = calc_vat_price( - diff_price, new_product.vat - ).quantize(Decimal("0.0001")) + price_change_vat = calc_vat_price(diff_price, vat).quantize( + Decimal("0.0001") + ) price_change_list.append( { @@ -732,6 +739,7 @@ def get_price_change_list(self, new_zone, is_low_emission): "previous_price": previous_price, "new_price": new_price, "price_change_vat": price_change_vat, + "price_change_vat_percent": vat * 100, "price_change": diff_price, "start_date": month_start_date, "month_count": 1, @@ -813,7 +821,22 @@ def extend_permit(self, additional_months): def cancel_extension_requests(self): self.permit_extension_requests.cancel_pending() - def get_refund_amount_for_unused_items(self): + def get_vat_based_refund_amounts_for_unused_items(self): + totals_per_vat = {} + if not self.can_be_refunded: + return totals_per_vat + + unused_order_items = self.get_unused_order_items_for_all_orders() + + for order_item, quantity, date_range in unused_order_items: + vat = order_item.vat + if vat not in totals_per_vat: + totals_per_vat[vat] = {"total": Decimal(0), "order": None} + totals_per_vat[vat]["total"] += order_item.unit_price * quantity + totals_per_vat[vat]["order"] = order_item.order + return totals_per_vat + + def get_total_refund_amount_for_unused_items(self): total = Decimal(0) if not self.can_be_refunded: return total @@ -824,6 +847,19 @@ def get_refund_amount_for_unused_items(self): total += order_item.unit_price * quantity return total + def can_create_single_refund(self): + if not self.can_be_refunded: + return False + + unused_order_items = self.get_unused_order_items() + if not unused_order_items: + return False + first_order_item, _, _ = unused_order_items[0] + first_vat = first_order_item.vat + return all( + order_item.vat == first_vat for order_item, _, _ in unused_order_items + ) + def parse_temporary_vehicle_times( self, start_time: str, @@ -908,22 +944,61 @@ def is_temporary_vehicle_limit_exceeded(self) -> bool: ) def get_unused_order_items(self): - unused_start_date = timezone.localdate(self.next_period_start_time) - if not self.is_fixed_period: - order_items = self.latest_order_items - return [ - [ - item, - item.quantity, - ( - timezone.localtime(item.start_time).date(), - timezone.localtime(item.end_time).date(), - ), - ] - for item in order_items + if self.is_open_ended: + return self.get_unused_order_items_for_open_ended_permit() + return self.get_unused_order_items_for_order(self.latest_order) + + def get_unused_order_items_for_open_ended_permit(self): + order_items = self.latest_order_items + return [ + [ + item, + item.quantity, + ( + timezone.localtime(item.start_time).date(), + timezone.localtime(item.end_time).date(), + ), ] + for item in order_items + ] + + def get_unused_order_items_for_all_orders(self): + if self.is_open_ended: + return self.get_unused_order_items_for_open_ended_permit() + unused_order_items = [] + for order in self.orders.all().order_by("created_at"): + unused_order_items.extend(self.get_unused_order_items_for_order(order)) + # sort by order item start time + unused_order_items.sort(key=lambda x: x[0].start_time) + + # loop over unused order items, compare dates and remove overlapping month quantities from next order items + prev_order_item_end_date = None + for unused_order_item in unused_order_items: + _, quantity, date_range = unused_order_item + start_date, end_date = date_range + if not prev_order_item_end_date and end_date: + prev_order_item_end_date = end_date + continue + if ( + quantity == 0 + or not start_date + or start_date >= prev_order_item_end_date + ): + continue + if start_date < prev_order_item_end_date: + # reduce quantity by overlapping months + overlapping_months = diff_months_ceil( + start_date, prev_order_item_end_date + ) + unused_order_item[1] -= overlapping_months + prev_order_item_end_date = end_date + + return unused_order_items + + def get_unused_order_items_for_order(self, order): + unused_start_date = timezone.localdate(self.next_period_start_time) - order_items = self.latest_order_items.filter( + order_items = order.order_items.filter( end_time__date__gte=unused_start_date ).order_by("start_time") diff --git a/parking_permits/models/product.py b/parking_permits/models/product.py index a8541e39..7933c2ff 100644 --- a/parking_permits/models/product.py +++ b/parking_permits/models/product.py @@ -184,6 +184,7 @@ def get_talpa_pricing(self, is_low_emission, is_secondary): "price_gross": "20.00", "price_net": "16.13", "price_vat": "3.87", + "vat_percentage": "25.50", } """ price_gross = self.get_modified_unit_price(is_low_emission, is_secondary) @@ -193,6 +194,7 @@ def get_talpa_pricing(self, is_low_emission, is_secondary): "price_gross": pricing.format_gross(), "price_net": pricing.format_net(), "price_vat": pricing.format_vat(), + "vat_percentage": "%.2f" % self.vat_percentage, } def get_merchant_id(self): diff --git a/parking_permits/models/refund.py b/parking_permits/models/refund.py index ff64ec4a..b03d6944 100644 --- a/parking_permits/models/refund.py +++ b/parking_permits/models/refund.py @@ -1,5 +1,3 @@ -from decimal import Decimal - from django.conf import settings from django.contrib.gis.db import models from django.utils.translation import gettext_lazy as _ @@ -8,8 +6,6 @@ from .mixins import TimestampedModelMixin, UserStampedModelMixin -VAT_PERCENT = Decimal(0.24) - class RefundStatus(models.TextChoices): OPEN = "OPEN", _("Open") @@ -51,10 +47,7 @@ class Refund(TimestampedModelMixin, UserStampedModelMixin): null=True, 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 + vat = models.DecimalField(_("VAT"), max_digits=6, decimal_places=4, default=0.24) class Meta: verbose_name = _("Refund") @@ -64,9 +57,10 @@ def __str__(self): return f"{self.name} ({self.iban})" @property - def vat(self): - """Calculate the VAT amount. - 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 calc_vat_price(self.amount, self.vat_percent) + def vat_percent(self): + return self.vat * 100 + + @property + def vat_amount(self): + """Calculate the VAT amount.""" + return calc_vat_price(self.amount, self.vat) diff --git a/parking_permits/resolver_utils.py b/parking_permits/resolver_utils.py index 5f864a31..1d156e7b 100644 --- a/parking_permits/resolver_utils.py +++ b/parking_permits/resolver_utils.py @@ -1,12 +1,8 @@ -from typing import Optional, Tuple - -from parking_permits.models import Order, ParkingPermit, Refund, Subscription -from parking_permits.models.order import ( - OrderPaymentType, - OrderStatus, - OrderType, - SubscriptionCancelReason, -) +from decimal import Decimal +from typing import Optional + +from parking_permits.models import ParkingPermit, Refund, Subscription +from parking_permits.models.order import SubscriptionCancelReason from users.models import User from .models.parking_permit import ( @@ -29,7 +25,6 @@ def end_permits( *permits: ParkingPermit, iban: Optional[str] = None, end_type: ParkingPermitEndType, - payment_type: OrderPaymentType, **kwargs, ) -> None: """ @@ -43,11 +38,10 @@ def end_permits( create relevant events. """ - create_fixed_period_refund( + create_fixed_period_refunds( user, *permits, iban=iban, - payment_type=payment_type, ) for permit in permits: @@ -60,21 +54,20 @@ def end_permits( ) -def create_fixed_period_refund( +def create_fixed_period_refunds( user: Optional[User], *permits: ParkingPermit, iban: Optional[str], - payment_type: OrderPaymentType, -) -> Tuple[Optional[Refund], bool]: - """Creates a refund model from the permits provided. +) -> list[Refund]: + """Creates VAT-based summary refunds from the permits provided. - If refund is created, then will send refund receipt email to customer - and create the relevant event. + If refunds are created, then will send refund receipt email to customer + and create the relevant events. If OPEN ENDED permits, the Refund will always be None: refunds are issued for open-ended permits when cancelling the subscription (see `end_permit()`). - Returns the Refund instance, or None if not available, and True or False if refund is created. + Returns the created refund instances, or empty list if not available. """ refundable_permits = [ permit @@ -82,54 +75,54 @@ def create_fixed_period_refund( if permit.can_be_refunded and permit.is_fixed_period ] if not refundable_permits: - return None, False + return [] - first_permit = refundable_permits[0] - latest_order = first_permit.latest_order - customer = first_permit.customer + customer = refundable_permits[0].customer - created = False + refunds = [] - if refund := Refund.objects.filter(order=latest_order).first(): - order = Order.objects.create_renewal_order( - first_permit.customer, - status=OrderStatus.CONFIRMED, - order_type=OrderType.CREATED, - payment_type=payment_type, - iban=iban, - user=user, - create_renew_order_event=False, - ) - total_sum = order.total_price - order.order_items.all().delete() + total_sums_per_vat = {} - else: - total_sum = sum( - [ - permit.get_refund_amount_for_unused_items() - for permit in refundable_permits - ] - ) - order = latest_order + handled_orders = set() - if total_sum > 0: - refund = Refund.objects.create( - name=customer.full_name, - order=order, - amount=total_sum, - iban=iban, - description=f"Refund for ending permits {','.join([str(permit.id) for permit in permits])}", - ) - refund.permits.set(permits) - send_refund_email(RefundEmailType.CREATED, customer, refund) + for permit in refundable_permits: + data_per_vat = permit.get_vat_based_refund_amounts_for_unused_items() + for vat, vat_data in data_per_vat.items(): + order = vat_data.get("order") + if order in handled_orders: + continue + if vat not in total_sums_per_vat: + total_sums_per_vat[vat] = {} + total_sums_per_vat[vat]["total"] = Decimal(0) + total_sums_per_vat[vat]["total"] += vat_data.get("total") or Decimal(0) + total_sums_per_vat[vat]["order"] = vat_data.get("order") + handled_orders.add(order) + + total_sum = sum([vat["total"] for vat in total_sums_per_vat.values()]) - for permit in permits: - ParkingPermitEventFactory.make_create_refund_event( - permit, refund, created_by=user + if total_sum > 0: + refunds = [] + for vat, data in total_sums_per_vat.items(): + refund = Refund.objects.create( + name=customer.full_name, + order=data["order"], + amount=data["total"], + iban=iban, + vat=vat, + description=f"Refund for ending permits {','.join([str(permit.id) for permit in permits])}", ) + refund.permits.set(permits) + refunds.append(refund) + + for permit in permits: + ParkingPermitEventFactory.make_create_refund_event( + permit, refund, created_by=user + ) + + if refunds: + send_refund_email(RefundEmailType.CREATED, customer, refunds) - created = True - return refund, created + return refunds def end_permit( diff --git a/parking_permits/resolvers.py b/parking_permits/resolvers.py index dfca9bd0..25cc21f1 100644 --- a/parking_permits/resolvers.py +++ b/parking_permits/resolvers.py @@ -16,7 +16,7 @@ from audit_logger import AuditMsg from project.settings import BASE_DIR -from .constants import EventFields, Origin +from .constants import DEFAULT_VAT, EventFields, Origin from .customer_permit import CustomerPermit from .decorators import is_authenticated from .exceptions import ( @@ -486,19 +486,23 @@ def resolve_update_permit_vehicle( checkout_url = TalpaOrderManager.send_to_talpa(new_order) permit.status = ParkingPermitStatus.PAYMENT_IN_PROGRESS talpa_order_created = True - else: - """New price is lower: generate a refund""" - refund = Refund.objects.create( - name=customer.full_name, - order=new_order, - amount=abs(permit_total_price_change), - iban=iban if iban else "", - description=f"Refund for updating permits, customer switched vehicle to: {new_vehicle}", - ) - refund.permits.add(permit) - logger.info(f"Refund for updating permits created: {refund}") - send_refund_email(RefundEmailType.CREATED, customer, refund) + refunds = [] + """New price is lower: generate refunds""" + for item in price_change_list: + amount = item["price_change"] * item["month_count"] + refund = Refund.objects.create( + name=customer.full_name, + order=new_order, + amount=abs(amount), + iban=iban if iban else "", + description=f"Refund for updating permits, customer switched vehicle to: {new_vehicle}", + ) + refund.permits.add(permit) + refunds.append(refund) + logger.info(f"Refund for updating permits created: {refund}") + + send_refund_email(RefundEmailType.CREATED, customer, refunds) ParkingPermitEventFactory.make_create_refund_event( permit, refund, created_by=request.user ) @@ -716,12 +720,17 @@ def resolve_change_address( name=customer.full_name, order=order, amount=-order_total_price_change, + vat=( + order.order_items.first().vat + if order.order_items.exists() + else DEFAULT_VAT + ), iban=iban if iban else "", description=f"Refund for updating permits zone (customer switch address to: {address})", ) refund.permits.set(order.permits.all()) logger.info(f"Refund for updating permits zone created: {refund}") - send_refund_email(RefundEmailType.CREATED, customer, refund) + send_refund_email(RefundEmailType.CREATED, customer, [refund]) for permit in order.permits.all(): ParkingPermitEventFactory.make_create_refund_event( permit, refund, created_by=request.user diff --git a/parking_permits/schema/parking_permit.graphql b/parking_permits/schema/parking_permit.graphql index ee156477..645c17b6 100644 --- a/parking_permits/schema/parking_permit.graphql +++ b/parking_permits/schema/parking_permit.graphql @@ -112,6 +112,7 @@ type PermitPriceChangeItem { newPrice: Float! priceChange: Float! priceChangeVat: Float! + priceChangeVatPercent: Float! startDate: String! endDate: String! monthCount: Int! diff --git a/parking_permits/schema/parking_permit_admin.graphql b/parking_permits/schema/parking_permit_admin.graphql index 082e618d..0812f308 100644 --- a/parking_permits/schema/parking_permit_admin.graphql +++ b/parking_permits/schema/parking_permit_admin.graphql @@ -322,6 +322,7 @@ type PermitPriceChange { newPrice: Float! priceChange: Float! priceChangeVat: Float! + priceChangeVatPercent: Float! startDate: String! endDate: String! monthCount: Int! diff --git a/parking_permits/services/mail.py b/parking_permits/services/mail.py index a24dbe0f..fa7f367f 100644 --- a/parking_permits/services/mail.py +++ b/parking_permits/services/mail.py @@ -125,12 +125,13 @@ class RefundEmailType: } -def send_refund_email(action, customer, refund): +def send_refund_email(action, customer, refunds): with translation.override(customer.language): - logger.info(f"Sending refund {refund.pk} {action} email") + refund_ids = ", ".join([str(refund.pk) for refund in refunds]) + logger.info(f"Sending refund {refund_ids} {action} email") subject = refund_email_subjects[action] template = refund_email_templates[action] - html_message = render_to_string(template, context={"refund": refund}) + html_message = render_to_string(template, context={"refunds": refunds}) plain_message = strip_tags(html_message) recipient_list = [customer.email] try: diff --git a/parking_permits/services/traficom.py b/parking_permits/services/traficom.py index 33e4ad51..b7b37f96 100644 --- a/parking_permits/services/traficom.py +++ b/parking_permits/services/traficom.py @@ -12,6 +12,7 @@ from parking_permits.models.driving_licence import DrivingLicence from parking_permits.models.vehicle import ( EmissionType, + LowEmissionCriteria, Vehicle, VehicleClass, VehiclePowerType, @@ -177,14 +178,40 @@ def fetch_vehicle_details(self, registration_number, permit=None): emissions = motor.findall("kayttovoimat/kayttovoima/kulutukset/kulutus") inspection_detail = et.find(".//ajoneuvonPerustiedot") last_inspection_date = inspection_detail.find("mkAjanLoppupvm") + + try: + now = tz.now() + le_criteria = LowEmissionCriteria.objects.get( + start_date__lte=now, + end_date__gte=now, + ) + except LowEmissionCriteria.DoesNotExist: + le_criteria = None + logger.warning( + "Low emission criteria not found. Please update LowEmissionCriteria to contain active criteria" + ) + emission_type = EmissionType.NEDC co2emission = None for e in emissions: kulutuslaji = e.find("kulutuslaji").text if kulutuslaji in CONSUMPTION_TYPE_NEDC + CONSUMPTION_TYPE_WLTP: co2emission = e.find("maara").text + # if emission are under or equal of the max value of one of the consumption + # types (WLTP|NEDC) the emission type and value that makes the vehicle eligible + # for low emissions pricing should be saved to db. if kulutuslaji in CONSUMPTION_TYPE_WLTP: emission_type = EmissionType.WLTP + if le_criteria: + if float(co2emission) <= le_criteria.wltp_max_emission_limit: + break + + elif kulutuslaji in CONSUMPTION_TYPE_NEDC: + emission_type = EmissionType.NEDC + if le_criteria: + if float(co2emission) <= le_criteria.nedc_max_emission_limit: + break + euro_class = EURO_CLASS if not co2emission: euro_class = EURO_CLASS_WITHOUT_EMISSIONS diff --git a/parking_permits/talpa/order.py b/parking_permits/talpa/order.py index b81224dc..8be95d1b 100644 --- a/parking_permits/talpa/order.py +++ b/parking_permits/talpa/order.py @@ -51,7 +51,7 @@ def create_item_data(cls, order, order_item): "unit": _("pcm"), "startDate": date_time_to_helsinki(order_item.permit.start_time), "quantity": order_item.quantity, - "vatPercentage": cls.round_int(float(order_item.vat_percentage)), + "vatPercentage": cls.round_up(float(order_item.vat_percentage)), "priceNet": unit_pricing.format_net(), "priceVat": unit_pricing.format_vat(), "priceGross": unit_pricing.format_gross(), diff --git a/parking_permits/templates/emails/refund_accepted.html b/parking_permits/templates/emails/refund_accepted.html index b09a4047..e6d96eda 100644 --- a/parking_permits/templates/emails/refund_accepted.html +++ b/parking_permits/templates/emails/refund_accepted.html @@ -7,8 +7,10 @@

{% translate "Your refund has been accepted and will appear on the account you reported within about a week" %}

+{% for refund in refunds %}

{% translate "Total refundable amount" %} - {{ refund.amount }}€ ({% translate "incl. VAT" %} 24%, {{ refund.vat|floatformat:2 }} €) + {{ refund.amount|floatformat:"2u" }}€ ({% translate "incl. VAT" %} {{ refund.vat_percent|floatformat:"2u" }}%, {{ refund.vat_amount|floatformat:"2u" }}€)

+{% endfor %} {% endblock %} diff --git a/parking_permits/templates/emails/refund_created.html b/parking_permits/templates/emails/refund_created.html index d5e66c76..61ba0274 100644 --- a/parking_permits/templates/emails/refund_created.html +++ b/parking_permits/templates/emails/refund_created.html @@ -7,8 +7,10 @@

{% translate "You are entitled to a refund and it has been registered. You will receive a separate notification when the return has been approved." %}

+{% for refund in refunds %}

{% translate "Total refundable amount" %} - {{ refund.amount|floatformat:"2u" }}€ ({% translate "incl. VAT" %} 24%, {{ refund.vat|floatformat:"2u" }} €) + {{ refund.amount|floatformat:"2u" }}€ ({% translate "incl. VAT" %} {{ refund.vat_percent|floatformat:"2u" }}%, {{ refund.vat_amount|floatformat:"2u" }}€)

+{% endfor %} {% endblock %} diff --git a/parking_permits/tests/factories/order.py b/parking_permits/tests/factories/order.py index 92d34e0d..f6cbd2f9 100644 --- a/parking_permits/tests/factories/order.py +++ b/parking_permits/tests/factories/order.py @@ -36,7 +36,7 @@ class OrderItemFactory(factory.django.DjangoModelFactory): subscription = factory.SubFactory(SubscriptionFactory) unit_price = Decimal(30) payment_unit_price = Decimal(30) - vat = Decimal(0.24) + vat = Decimal(0.255) quantity = 6 class Meta: diff --git a/parking_permits/tests/factories/product.py b/parking_permits/tests/factories/product.py index 11fe7fcd..894fc7c7 100644 --- a/parking_permits/tests/factories/product.py +++ b/parking_permits/tests/factories/product.py @@ -13,7 +13,7 @@ class ProductFactory(factory.django.DjangoModelFactory): start_date = date(2021, 1, 1) end_date = date(2021, 12, 31) unit_price = Decimal(30) - vat = Decimal(0.24) + vat = Decimal(0.255) low_emission_discount = Decimal(0.5) class Meta: diff --git a/parking_permits/tests/models/test_parking_permit.py b/parking_permits/tests/models/test_parking_permit.py index ef292f20..4dca225c 100644 --- a/parking_permits/tests/models/test_parking_permit.py +++ b/parking_permits/tests/models/test_parking_permit.py @@ -352,7 +352,7 @@ def test_get_refund_amount_for_unused_items_should_return_correct_total(self): permit.save() with freeze_time(datetime(2021, 4, 15)): - refund_amount = permit.get_refund_amount_for_unused_items() + refund_amount = permit.get_total_refund_amount_for_unused_items() self.assertEqual(refund_amount, Decimal("220")) def test_get_products_with_quantities_should_return_a_single_product_for_open_ended( @@ -600,7 +600,7 @@ def test_open_ended_parking_permit_change_price_list_when_prices_go_down(self): price_change_list[0]["price_change"], Decimal("-10.00") ) self.assertEqual( - price_change_list[0]["price_change_vat"], Decimal("-1.9355") + price_change_list[0]["price_change_vat"], Decimal("-2.0319") ) self.assertEqual(price_change_list[0]["month_count"], 0) self.assertEqual(price_change_list[0]["start_date"], date(2021, 5, 15)) @@ -641,7 +641,7 @@ def test_open_ended_parking_permit_change_price_list_when_prices_go_down_end_dat price_change_list[0]["price_change"], Decimal("-10.00") ) self.assertEqual( - price_change_list[0]["price_change_vat"], Decimal("-1.9355") + price_change_list[0]["price_change_vat"], Decimal("-2.0319") ) self.assertEqual(price_change_list[0]["month_count"], 1) self.assertEqual(price_change_list[0]["start_date"], date(2021, 5, 15)) @@ -683,7 +683,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("-0.9677") + price_change_list[0]["price_change_vat"], Decimal("-1.0159") ) self.assertEqual(price_change_list[0]["month_count"], 2) self.assertEqual(price_change_list[0]["start_date"], date(2021, 5, 1)) @@ -695,7 +695,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("-1.9355") + price_change_list[1]["price_change_vat"], Decimal("-2.0319") ) self.assertEqual(price_change_list[1]["month_count"], 6) self.assertEqual(price_change_list[1]["start_date"], date(2021, 7, 1)) @@ -751,7 +751,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("3.8710") + price_change_list[0]["price_change_vat"], Decimal("4.0637") ) self.assertEqual(price_change_list[0]["month_count"], 2) self.assertEqual( @@ -767,7 +767,7 @@ def test_parking_permit_change_price_list_when_prices_go_up(self): 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("4.8387") + price_change_list[1]["price_change_vat"], Decimal("5.0797") ) self.assertEqual(price_change_list[1]["month_count"], 6) self.assertEqual( @@ -883,8 +883,8 @@ def test_get_price_list_for_extended_permit(self): self.assertEqual(price_list[0]["month_count"], 2) self.assertEqual(price_list[0]["price"], Decimal("60.00")) self.assertEqual(price_list[0]["unit_price"], Decimal("30.00")) - self.assertEqual(price_list[0]["net_price"], "48.39") - self.assertEqual(price_list[0]["vat_price"], "11.61") + self.assertEqual(price_list[0]["net_price"], "47.81") + self.assertEqual(price_list[0]["vat_price"], "12.19") # 1x second product self.assertEqual(price_list[1]["start_date"], date(2024, 4, 13)) @@ -892,8 +892,8 @@ def test_get_price_list_for_extended_permit(self): self.assertEqual(price_list[1]["month_count"], 1) self.assertEqual(price_list[1]["price"], Decimal("40.00")) self.assertEqual(price_list[1]["unit_price"], Decimal("40.00")) - self.assertEqual(price_list[1]["net_price"], "32.26") - self.assertEqual(price_list[1]["vat_price"], "7.74") + self.assertEqual(price_list[1]["net_price"], "31.87") + self.assertEqual(price_list[1]["vat_price"], "8.13") def test_max_extension_month_count_for_primary_vehicle(self): permit = ParkingPermitFactory( diff --git a/parking_permits/tests/models/test_product.py b/parking_permits/tests/models/test_product.py index db47bc30..bcead7f7 100644 --- a/parking_permits/tests/models/test_product.py +++ b/parking_permits/tests/models/test_product.py @@ -97,6 +97,19 @@ def test_get_modified_unit_price_return_modified_price(self): self.assertEqual(secondary_vehicle_low_emission_price, Decimal(7.5)) def test_get_talpa_pricing(self): + product = ProductFactory( + unit_price=Decimal(60), + vat=0.255, + ) + pricing = product.get_talpa_pricing(False, False) + assert pricing == { + "price_gross": "60.00", + "price_net": "47.81", + "price_vat": "12.19", + "vat_percentage": "25.50", + } + + def test_get_talpa_pricing_prev_vat(self): product = ProductFactory( unit_price=Decimal(20), vat=0.24, @@ -106,9 +119,24 @@ def test_get_talpa_pricing(self): "price_gross": "20.00", "price_net": "16.13", "price_vat": "3.87", + "vat_percentage": "24.00", } def test_get_talpa_pricing_with_discount(self): + product = ProductFactory( + unit_price=Decimal(60), + low_emission_discount=Decimal(0.5), + vat=0.255, + ) + pricing = product.get_talpa_pricing(True, False) + assert pricing == { + "price_gross": "30.00", + "price_net": "23.90", + "price_vat": "6.10", + "vat_percentage": "25.50", + } + + def test_get_talpa_pricing_with_discount_prev_vat(self): product = ProductFactory( unit_price=Decimal(20), low_emission_discount=Decimal(0.5), @@ -119,4 +147,5 @@ def test_get_talpa_pricing_with_discount(self): "price_gross": "10.00", "price_net": "8.06", "price_vat": "1.94", + "vat_percentage": "24.00", } diff --git a/parking_permits/tests/models/test_refund.py b/parking_permits/tests/models/test_refund.py index 1744b8f9..06d4b009 100644 --- a/parking_permits/tests/models/test_refund.py +++ b/parking_permits/tests/models/test_refund.py @@ -8,9 +8,12 @@ class TestRefund(TestCase): def test_vat_zero_amount(self): refund = RefundFactory(amount=0) - assert refund.vat == Decimal(0) + assert refund.vat_amount == Decimal(0) def test_vat(self): - refund = RefundFactory(amount=100) + refund = RefundFactory(amount=100, vat=Decimal(0.255)) + self.assertAlmostEqual(refund.vat_amount, Decimal(20.32), delta=Decimal("0.01")) - self.assertAlmostEqual(refund.vat, Decimal(19.35), delta=Decimal("0.01")) + def test_prev_vat(self): + refund = RefundFactory(amount=100, vat=Decimal(0.24)) + self.assertAlmostEqual(refund.vat_amount, Decimal(19.35), delta=Decimal("0.01")) diff --git a/parking_permits/tests/services/mocks/traficom/vehicle_nedc_and_wltp.xml b/parking_permits/tests/services/mocks/traficom/vehicle_nedc_and_wltp.xml new file mode 100644 index 00000000..46cca94b --- /dev/null +++ b/parking_permits/tests/services/mocks/traficom/vehicle_nedc_and_wltp.xml @@ -0,0 +1,301 @@ + + + TPSUOTIEDOTLAAJAHAKUOUT + + + TPSUO + TUOTANTO,TUOTANTO + + + + + + 111-500 + 90909090909 + 0990909090 + + + M1 + 2264 + BMW i + i3 Sedan (AA) 4ov 647cm3 A + 3 + 01 + + 8 + 2023-09-22 + + + 2 + 2019-09-06 + + + + e1*2007/46*1213*03 + 1Z41 + 6A040000 + i3 + 20150507 + 2023-09-22 + 2025-05-07 + 2023-09-22 + 2 + A-Katsastus Oy/Helsinki Tullinpuomi + false + 1 + 276 + M B16792 + 08 + false + 01 + + 9 + 0 + 4 + + BMWi-1 + 2017-03-17 + + + 128475 + 2023-09-22 + + + + + + 3001 + 1074/3001 + Rek.katsastuksessa ei ole suoritettu määräaikaiskatsastusta + vastaavaa tarkastusta. 1. Suomessa tehtävä määräaikaiskatsastus on + suoritettava muista tälle todistukselle merkityistä tiedoista poiketen + VNa 1245/2002 4 §:n mukaisesti viimeistään 07.05.2018. + true + + + + + XXXXXX-X + Pankki X + Osamaksut + 1 + 01 + 2019-09-06 + false + PL 308 + 00013 + POHJOLA + fi + AM + + + XXXXX-XXXX + Aku + Ankka + 0 + 03 + 2019-09-06 + 04 + false + Pääkatu 63 + 00100 + HELSINKI + fi + + + + 41 + 01 + IF + XXXXXX-XXXX + Ankku, Aku + 2019-09-06 + + + 01 + 2 + false + 2 + b-pilari, oikea + false + + + 1 + true + false + false + false + false + false + false + false + + 1 + 1 + 155/70 R 19 + 84Q + + + 1 + 1 + 175/60 R 19 + 86Q + + + + 2 + false + false + false + false + true + false + false + false + + 2 + 1 + 155/70 R 19 + 84Q + + + 2 + 1 + 175/60 R 19 + 86Q + + + + + + 01 + 647 + 28.0 + 150 + 2 + false + false + 394 + + 02 + 03 + 04 + 07 + + + + 01 + 80.0 + 68.0 + 2200 + + + 06 + 01 + 53.10 + + + 06 + 03 + 2.00 + + + 06 + 06 + 3.90 + + + 06 + 07 + 3.70 + + + + + 10 + 50.0 + + + 12 + 2.6 + + + 7 + 53.0 + + + + + true + 01 + + + 1390 + 1730 + 1730 + + + 3999 + 1775 + 1578 + + + 1 + 2570 + + + + + AA + 1 + 4 + + + 05 + + 1 + + + + + et + 1 + 1 + 1 + + + st + 1 + 1 + 1 + + + vk + 1 + 1 + 1 + + + et + 1 + 3 + 1 + + + st + 1 + 3 + 1 + + + vk + 1 + 3 + 1 + + + + + + + diff --git a/parking_permits/tests/services/test_talpa.py b/parking_permits/tests/services/test_talpa.py index 6442a387..7a4ba56b 100644 --- a/parking_permits/tests/services/test_talpa.py +++ b/parking_permits/tests/services/test_talpa.py @@ -13,16 +13,16 @@ [ pytest.param(None, "0.00", "0.00", "0.00", id="gross: None"), pytest.param(0, "0.00", "0.00", "0.00", id="gross: 0"), - pytest.param(30, "30.00", "24.19", "5.81", id="gross: 30"), - pytest.param(30.50, "30.50", "24.60", "5.90", id="gross: 30.50"), - pytest.param(60, "60.00", "48.39", "11.61", id="gross: 60"), - pytest.param(100, "100.00", "80.64", "19.36", id="gross: 100"), - pytest.param(120, "120.00", "96.77", "23.23", id="gross: 120"), - pytest.param(720.01, "720.01", "580.65", "139.36", id="gross: 720.01"), + pytest.param(30, "30.00", "23.90", "6.10", id="gross: 30"), + pytest.param(30.50, "30.50", "24.30", "6.20", id="gross: 30.50"), + pytest.param(60, "60.00", "47.81", "12.19", id="gross: 60"), + pytest.param(100, "100.00", "79.68", "20.32", id="gross: 100"), + pytest.param(120, "120.00", "95.62", "24.38", id="gross: 120"), + pytest.param(720.01, "720.01", "573.71", "146.30", id="gross: 720.01"), ], ) def test_pricing(value, gross, net, vat): - pricing = Pricing.calculate(value, 0.24) + pricing = Pricing.calculate(value, 0.255) assert pricing.net + pricing.vat == pricing.gross @@ -34,12 +34,12 @@ def test_pricing(value, gross, net, vat): def test_add_pricing(): total_pricing = Pricing() - total_pricing += Pricing.calculate(60, 0.24) - total_pricing += Pricing.calculate(120, 0.24) + total_pricing += Pricing.calculate(60, 0.255) + total_pricing += Pricing.calculate(120, 0.255) assert total_pricing.format_gross() == "180.00" - assert total_pricing.format_net() == "145.16" - assert total_pricing.format_vat() == "34.84" + assert total_pricing.format_net() == "143.42" + assert total_pricing.format_vat() == "36.58" class TestTalpaOrderManager(TestCase): @@ -48,7 +48,7 @@ def test_create_order_data(self): unit_price=Decimal(30), payment_unit_price=Decimal(30), quantity=2, - vat=Decimal(0.24), + vat=Decimal(0.255), ) OrderItemFactory( @@ -56,15 +56,38 @@ def test_create_order_data(self): payment_unit_price=Decimal(60), quantity=3, order=order_item.order, - vat=Decimal(0.24), + vat=Decimal(0.255), ) data = TalpaOrderManager.create_order_data(order_item.order) - self.assertEqual(data["priceNet"], "193.55") - self.assertEqual(data["priceVat"], "46.45") self.assertEqual(data["priceTotal"], "240.00") + self.assertEqual(data["priceNet"], "191.23") + self.assertEqual(data["priceVat"], "48.77") def test_create_item_data(self): + order_item = OrderItemFactory( + unit_price=Decimal(60), + payment_unit_price=Decimal(60), + quantity=2, + vat=Decimal(0.255), + ) + + data, pricing = TalpaOrderManager.create_item_data(order_item.order, order_item) + + self.assertEqual(data["priceNet"], "47.81") + self.assertEqual(data["priceVat"], "12.19") + self.assertEqual(data["priceGross"], "60.00") + self.assertEqual(data["vatPercentage"], "25.50") + + self.assertEqual(data["rowPriceNet"], "95.62") + self.assertEqual(data["rowPriceVat"], "24.38") + self.assertEqual(data["rowPriceTotal"], "120.00") + + self.assertEqual(pricing.format_gross(), "120.00") + self.assertEqual(pricing.format_net(), "95.62") + self.assertEqual(pricing.format_vat(), "24.38") + + def test_create_item_data_prev_vat(self): order_item = OrderItemFactory( unit_price=Decimal(30), payment_unit_price=Decimal(30), @@ -77,7 +100,7 @@ def test_create_item_data(self): self.assertEqual(data["priceNet"], "24.19") self.assertEqual(data["priceVat"], "5.81") self.assertEqual(data["priceGross"], "30.00") - self.assertEqual(data["vatPercentage"], "24") + self.assertEqual(data["vatPercentage"], "24.00") self.assertEqual(data["rowPriceNet"], "48.39") self.assertEqual(data["rowPriceVat"], "11.61") diff --git a/parking_permits/tests/services/test_traficom.py b/parking_permits/tests/services/test_traficom.py index a1acb88f..fb1ef56c 100644 --- a/parking_permits/tests/services/test_traficom.py +++ b/parking_permits/tests/services/test_traficom.py @@ -791,6 +791,38 @@ def test_fetch_vehicle_nedc(self): assert vehicle.emission_type == EmissionType.NEDC assert vehicle.emission == 13 + @override_settings(TRAFICOM_MOCK=False) + def test_fetch_vehicle_with_nedc_and_wltp(self): + with mock.patch( + "requests.post", + return_value=MockResponse(get_mock_xml("vehicle_nedc_and_wltp.xml")), + ): + LowEmissionCriteriaFactory( + nedc_max_emission_limit=37, + wltp_max_emission_limit=50, + start_date=datetime.datetime(2024, 1, 1), + end_date=datetime.datetime(2024, 12, 31), + euro_min_class_limit=6, + ) + vehicle = self.traficom.fetch_vehicle_details("111-500") + self.assertEqual(vehicle.registration_number, "111-500") + + assert vehicle.emission_type == EmissionType.WLTP + assert vehicle.emission == 50 + assert vehicle._is_low_emission + + @override_settings(TRAFICOM_MOCK=False) + def test_fetch_vehicle_with_nedc_and_wltp_no_criteria(self): + with mock.patch( + "requests.post", + return_value=MockResponse(get_mock_xml("vehicle_nedc_and_wltp.xml")), + ): + vehicle = self.traficom.fetch_vehicle_details("111-500") + self.assertEqual(vehicle.registration_number, "111-500") + + assert vehicle.emission_type == EmissionType.NEDC + assert vehicle.emission == 53 + @override_settings(TRAFICOM_MOCK=False) def test_fetch_vehicle_already_exists(self): vehicle = VehicleFactory(registration_number="BCI-707") diff --git a/parking_permits/tests/test_resolver_utils.py b/parking_permits/tests/test_resolver_utils.py index 2200815e..7abfade4 100644 --- a/parking_permits/tests/test_resolver_utils.py +++ b/parking_permits/tests/test_resolver_utils.py @@ -7,19 +7,15 @@ from freezegun import freeze_time from parking_permits.models import Order, Refund -from parking_permits.models.order import ( - OrderPaymentType, - OrderStatus, - SubscriptionStatus, -) +from parking_permits.models.order import OrderStatus, OrderType, SubscriptionStatus from parking_permits.models.parking_permit import ( ContractType, ParkingPermitEndType, ParkingPermitStatus, ) -from parking_permits.models.product import ProductType +from parking_permits.models.product import Product, ProductType from parking_permits.resolver_utils import ( - create_fixed_period_refund, + create_fixed_period_refunds, end_permit, end_permits, ) @@ -27,7 +23,6 @@ from parking_permits.tests.factories.order import OrderItemFactory, SubscriptionFactory from parking_permits.tests.factories.parking_permit import ParkingPermitFactory from parking_permits.tests.factories.product import ProductFactory -from parking_permits.tests.factories.refund import RefundFactory from parking_permits.tests.factories.vehicle import ( TemporaryVehicleFactory, VehicleFactory, @@ -89,7 +84,6 @@ def test_end_open_ended_permit_with_subscription( permit.customer.user, permit, end_type=ParkingPermitEndType.IMMEDIATELY, - payment_type=OrderPaymentType.ONLINE_PAYMENT, iban=IBAN, cancel_from_talpa=False, ) @@ -153,7 +147,6 @@ def test_fixed_period_permit( permit.customer.user, permit, end_type=ParkingPermitEndType.IMMEDIATELY, - payment_type=OrderPaymentType.ONLINE_PAYMENT, iban=IBAN, ) @@ -228,7 +221,6 @@ def test_multiple_fixed_period_permit( *[permit_a, permit_b], force_end=True, end_type=ParkingPermitEndType.IMMEDIATELY, - payment_type=OrderPaymentType.ONLINE_PAYMENT, iban=IBAN, ) @@ -396,7 +388,7 @@ def test_new_refund_fixed_period(self, mock_send_refund_email, zone): [ [ (start_time.date(), end_time.date()), - Decimal("30"), + Decimal("60"), ], ], ) @@ -406,7 +398,7 @@ def test_new_refund_fixed_period(self, mock_send_refund_email, zone): status=ParkingPermitStatus.VALID, start_time=start_time, end_time=end_time, - month_count=12, + month_count=6, parking_zone=zone, ) @@ -414,18 +406,16 @@ def test_new_refund_fixed_period(self, mock_send_refund_email, zone): order.status = OrderStatus.CONFIRMED order.save() - refund, created = create_fixed_period_refund( + refunds = create_fixed_period_refunds( permit.customer.user, permit, iban=IBAN, - payment_type=OrderPaymentType.ONLINE_PAYMENT, ) - assert refund is not None - assert created is True + assert refunds != [] - # 3 months unused at 30 EUR/month - assert refund.amount == 90 + # 3 months unused at 60 EUR/month + assert refunds[0].amount == 180 mock_send_refund_email.assert_called() @@ -441,7 +431,7 @@ def test_new_refund_open_ended(self, mock_send_refund_email, zone): [ [ (start_time.date(), end_time.date()), - Decimal("30"), + Decimal("60"), ], ], ) @@ -459,15 +449,13 @@ def test_new_refund_open_ended(self, mock_send_refund_email, zone): order.status = OrderStatus.CONFIRMED order.save() - refund, created = create_fixed_period_refund( + refunds = create_fixed_period_refunds( permit.customer.user, permit, iban=IBAN, - payment_type=OrderPaymentType.ONLINE_PAYMENT, ) - assert refund is None - assert created is False + assert refunds == [] mock_send_refund_email.assert_not_called() @@ -483,7 +471,7 @@ def test_new_refund_multiple_permits(self, mock_send_refund_email, zone): [ [ (start_time.date(), end_time.date()), - Decimal("30"), + Decimal("60"), ], ], ) @@ -493,7 +481,7 @@ def test_new_refund_multiple_permits(self, mock_send_refund_email, zone): status=ParkingPermitStatus.VALID, start_time=start_time, end_time=timezone.make_aware(datetime(2024, 4, 30)), - month_count=12, + month_count=4, parking_zone=zone, ) permit_b = ParkingPermitFactory( @@ -501,7 +489,7 @@ def test_new_refund_multiple_permits(self, mock_send_refund_email, zone): status=ParkingPermitStatus.VALID, start_time=timezone.make_aware(datetime(2024, 5, 1)), end_time=end_time, - month_count=12, + month_count=2, parking_zone=zone, customer=permit_a.customer, ) @@ -512,34 +500,34 @@ def test_new_refund_multiple_permits(self, mock_send_refund_email, zone): order.status = OrderStatus.CONFIRMED order.save() - refund, created = create_fixed_period_refund( + refunds = create_fixed_period_refunds( permit_a.customer.user, *permits, iban=IBAN, - payment_type=OrderPaymentType.ONLINE_PAYMENT, ) - assert refund is not None - assert created is True + assert refunds != [] - # 3 months unused at 30 EUR/month - assert refund.amount == 90 + # 3 months unused at 60 EUR/month + assert refunds[0].amount == 180 mock_send_refund_email.assert_called() @pytest.mark.django_db() @patch(MOCK_SEND_REFUND_EMAIL) - def test_existing_refund(self, mock_send_refund_email, zone): + def test_new_refund_with_permit_extension_request( + self, mock_send_refund_email, zone + ): with freeze_time("2024-3-26"): start_time = timezone.make_aware(datetime(2024, 1, 1)) - end_time = timezone.make_aware(datetime(2024, 6, 30)) + end_time = timezone.make_aware(datetime(2024, 12, 31)) _create_zone_products( zone, [ [ (start_time.date(), end_time.date()), - Decimal("30"), + Decimal("60"), ], ], ) @@ -549,29 +537,116 @@ def test_existing_refund(self, mock_send_refund_email, zone): status=ParkingPermitStatus.VALID, start_time=start_time, end_time=timezone.make_aware(datetime(2024, 4, 30)), - month_count=12, + month_count=4, parking_zone=zone, ) order = Order.objects.create_for_permits([permit]) order.status = OrderStatus.CONFIRMED order.save() - RefundFactory(order=order) + ext_request_order = Order.objects.create_for_extended_permit( + permit, + 2, + status=OrderStatus.CONFIRMED, + type=OrderType.CREATED, + ) + ext_request = permit.permit_extension_requests.create( + order=ext_request_order, + month_count=2, + ) + # approve and extend permit immediately + ext_request.approve() - refund, created = create_fixed_period_refund( + refunds = create_fixed_period_refunds( permit.customer.user, permit, iban=IBAN, - payment_type=OrderPaymentType.ONLINE_PAYMENT, ) - assert refund is not None - assert created is True + assert refunds != [] + + refund = refunds[0] + # 1. order: 1 month unused at 60 EUR/month + # 2. extension request order: 2 months unused at 60 EUR/month + # total: 180 EUR + assert refund.amount == 180 + delta = Decimal(0.01) + assert refund.vat == pytest.approx(Decimal(0.24), delta) + assert refund.vat_percent == pytest.approx(Decimal(24.0), delta) + assert refund.vat_amount == pytest.approx(Decimal(34.84), delta) + + mock_send_refund_email.assert_called + + @pytest.mark.django_db() + @patch(MOCK_SEND_REFUND_EMAIL) + def test_new_multiple_vat_refunds_with_permit_extension_request( + self, mock_send_refund_email, zone + ): + with freeze_time("2024-3-26"): + start_time = timezone.make_aware(datetime(2024, 1, 1)) + end_time = timezone.make_aware(datetime(2024, 12, 31)) + + _create_zone_products( + zone, + [ + [ + (start_time.date(), end_time.date()), + Decimal("60"), + ], + ], + ) + + permit = ParkingPermitFactory( + contract_type=ContractType.FIXED_PERIOD, + status=ParkingPermitStatus.VALID, + start_time=start_time, + end_time=timezone.make_aware(datetime(2024, 4, 30)), + month_count=4, + parking_zone=zone, + ) + order = Order.objects.create_for_permits([permit]) + order.status = OrderStatus.CONFIRMED + order.save() + + for product in Product.objects.all(): + product.vat = Decimal("0.255") + product.save() + + ext_request_order = Order.objects.create_for_extended_permit( + permit, + 2, + status=OrderStatus.CONFIRMED, + type=OrderType.CREATED, + ) - # based on total order amount i.e. 30 EUR - assert refund.amount == 30 + ext_request = permit.permit_extension_requests.create( + order=ext_request_order, + month_count=2, + ) + # approve and extend permit immediately + ext_request.approve() - # created with renewal order - assert refund.order != order + refunds = create_fixed_period_refunds( + permit.customer.user, + permit, + iban=IBAN, + ) + assert refunds != [] + + # 2 refunds with different vat + assert len(refunds) == 2 + # 1. order: 1 month unused at 60 EUR/month = 60 EUR, VAT 24% + refund = refunds[0] + assert refund.amount == 60 + delta = Decimal(0.01) + assert refund.vat == pytest.approx(Decimal(0.24), delta) + assert refund.vat_percent == pytest.approx(Decimal(24.0), delta) + assert refund.vat_amount == pytest.approx(Decimal(11.61), delta) + # 2. extension request order: 2 months unused at 60 EUR/month = 120 EUR, VAT 25.5% + second_refund = refunds[1] + assert second_refund.amount == 120 + assert second_refund.vat == pytest.approx(Decimal(0.255), delta) + assert second_refund.vat_percent == pytest.approx(Decimal(25.5), delta) + assert second_refund.vat_amount == pytest.approx(Decimal(24.38), delta) mock_send_refund_email.assert_called @@ -587,7 +662,7 @@ def test_not_refundable(self, mock_send_refund_email, zone): [ [ (start_time.date(), end_time.date()), - Decimal("30"), + Decimal("60"), ], ], ) @@ -605,15 +680,13 @@ def test_not_refundable(self, mock_send_refund_email, zone): order.status = OrderStatus.CONFIRMED order.save() - refund, created = create_fixed_period_refund( + refunds = create_fixed_period_refunds( permit.customer.user, permit, iban=IBAN, - payment_type=OrderPaymentType.ONLINE_PAYMENT, ) - assert refund is None - assert created is False + assert refunds == [] mock_send_refund_email.assert_not_called() @@ -647,15 +720,13 @@ def test_refundable_amount_zero(self, mock_send_refund_email, zone): order.status = OrderStatus.CONFIRMED order.save() - refund, created = create_fixed_period_refund( + refunds = create_fixed_period_refunds( permit.customer.user, permit, iban=IBAN, - payment_type=OrderPaymentType.ONLINE_PAYMENT, ) - assert refund is None - assert created is False + assert refunds == [] mock_send_refund_email.assert_not_called() @@ -670,6 +741,7 @@ def _create_zone_products(zone, product_detail_list): start_date=start_date, end_date=end_date, unit_price=unit_price, + vat=Decimal("0.24"), ) products.append(product) return products diff --git a/parking_permits/tests/test_utils.py b/parking_permits/tests/test_utils.py index 3f4b1b31..f8116399 100644 --- a/parking_permits/tests/test_utils.py +++ b/parking_permits/tests/test_utils.py @@ -23,9 +23,10 @@ @pytest.mark.parametrize( "gross_price,vat,net_price,vat_price", [ - pytest.param(100, 0.24, 80.65, 19.36, id="default"), + pytest.param(100, 0.255, 79.68, 20.32, id="VAT 25.5%"), + pytest.param(100, 0.24, 80.65, 19.36, id="VAT 24%"), pytest.param(100, None, 0, 0, id="VAT none"), - pytest.param(None, 0.24, 0, 0, id="gross none"), + pytest.param(None, 0.255, 0, 0, id="gross none"), ], ) def test_calc_prices(gross_price, vat, net_price, vat_price): @@ -67,10 +68,12 @@ def test_diff_months_ceil(self): class FindNextDateTestCase(TestCase): def test_find_next_date(self): - self.assertEqual(find_next_date(date(2021, 1, 10), 5), date(2021, 2, 5)) + self.assertEqual(find_next_date(date(2021, 1, 10), 5), date(2021, 1, 31)) self.assertEqual(find_next_date(date(2021, 1, 10), 10), date(2021, 1, 10)) self.assertEqual(find_next_date(date(2021, 1, 10), 20), date(2021, 1, 20)) self.assertEqual(find_next_date(date(2021, 2, 10), 31), date(2021, 2, 28)) + self.assertEqual(find_next_date(date(2024, 9, 1), 28), date(2024, 9, 28)) + self.assertEqual(find_next_date(date(2024, 11, 27), 31), date(2024, 11, 30)) @pytest.mark.django_db diff --git a/parking_permits/tests/test_views.py b/parking_permits/tests/test_views.py index 7cc20eb9..44e04e38 100644 --- a/parking_permits/tests/test_views.py +++ b/parking_permits/tests/test_views.py @@ -77,7 +77,7 @@ def get_validated_order_data(talpa_order_id, talpa_order_item_id): "startDate": "2023-06-01T15:46:05.619", "priceGross": "45.00", "rowPriceTotal": "45.00", - "vatPercentage": 24, + "vatPercentage": "25.50", "quantity": 1, } ], @@ -388,8 +388,8 @@ def test_resolve_price_view_for_normal_emission_vehicle(self): response.data.get("subscriptionId"), self.talpa_subscription_id ) self.assertEqual(response.data.get("userId"), self.user_id) - self.assertEqual(response.data.get("priceNet"), "48.39") - self.assertEqual(response.data.get("priceVat"), "11.61") + self.assertEqual(response.data.get("priceNet"), "47.81") + self.assertEqual(response.data.get("priceVat"), "12.19") self.assertEqual(response.data.get("priceGross"), "60.00") def test_resolve_price_view_for_low_emission_vehicle(self): @@ -413,8 +413,8 @@ def test_resolve_price_view_for_low_emission_vehicle(self): ) self.assertEqual(response.data.get("userId"), self.user_id) self.assertEqual(response.data.get("priceGross"), "45.00") - self.assertEqual(response.data.get("priceVat"), "8.71") - self.assertEqual(response.data.get("priceNet"), "36.29") + self.assertEqual(response.data.get("priceVat"), "9.14") + self.assertEqual(response.data.get("priceNet"), "35.86") def test_resolve_price_view_for_secondary_normal_emission_vehicle(self): unit_price = Decimal(60) @@ -437,8 +437,8 @@ def test_resolve_price_view_for_secondary_normal_emission_vehicle(self): ) self.assertEqual(response.data.get("userId"), self.user_id) self.assertEqual(response.data.get("priceGross"), "90.00") - self.assertEqual(response.data.get("priceVat"), "17.42") - self.assertEqual(response.data.get("priceNet"), "72.58") + self.assertEqual(response.data.get("priceVat"), "18.29") + self.assertEqual(response.data.get("priceNet"), "71.71") def test_resolve_price_view_for_secondary_low_emission_vehicle(self): unit_price = Decimal(60) @@ -461,8 +461,8 @@ def test_resolve_price_view_for_secondary_low_emission_vehicle(self): ) self.assertEqual(response.data.get("userId"), self.user_id) self.assertEqual(response.data.get("priceGross"), "67.50") - self.assertEqual(response.data.get("priceVat"), "13.06") - self.assertEqual(response.data.get("priceNet"), "54.44") + self.assertEqual(response.data.get("priceVat"), "13.72") + self.assertEqual(response.data.get("priceNet"), "53.78") def test_resolve_price_view_should_return_error_if_permit_products_missing( self, @@ -939,12 +939,12 @@ def test_right_of_purchase_view_is_valid( "productDescription": "12.09.2023 - 11.10.2023", "unit": "kk", "quantity": 1, - "rowPriceNet": "34.20", - "rowPriceVat": "10.80", + "rowPriceNet": "35.86", + "rowPriceVat": "9.14", "rowPriceTotal": "45.00", - "vatPercentage": "24", - "priceNet": "34.20", - "priceVat": "10.80", + "vatPercentage": "25.50", + "priceNet": "35.86", + "priceVat": "9.14", "priceGross": "45.00", "originalPriceNet": None, "originalPriceVat": None, @@ -1566,7 +1566,7 @@ def test_subscription_cancellation(self, mock_validate_order): ) order.permits.add(permit) order.save() - unit_price = Decimal(30) + unit_price = Decimal(60) product = ProductFactory(unit_price=unit_price) subscription = SubscriptionFactory( talpa_subscription_id=talpa_subscription_id, @@ -1605,8 +1605,81 @@ def test_subscription_cancellation(self, mock_validate_order): @override_settings(DEBUG=True) @patch.object(OrderValidator, "validate_order") - @freeze_time("2023-05-30") + @freeze_time("2024-05-30") def test_subscription_cancellation_with_refund(self, mock_validate_order): + talpa_order_id = "d86ca61d-97e9-410a-a1e3-4894873b1b35" + talpa_order_item_id = "819daecd-5ebb-4a94-924e-9710069e9285" + talpa_subscription_id = "f769b803-0bd0-489d-aa81-b35af391f391" + customer = CustomerFactory() + permit_start_time = datetime.datetime( + 2024, 3, 16, 10, 00, 0, tzinfo=datetime.timezone.utc + ) + permit_end_time = datetime.datetime( + 2024, 7, 15, 23, 59, 0, tzinfo=datetime.timezone.utc + ) + permit = ParkingPermitFactory( + status=ParkingPermitStatus.VALID, + customer=customer, + start_time=permit_start_time, + end_time=permit_end_time, + ) + order = OrderFactory( + talpa_order_id=talpa_order_id, + customer=customer, + status=OrderStatus.CONFIRMED, + ) + order.permits.add(permit) + order.save() + unit_price = Decimal(60) + product = ProductFactory(unit_price=unit_price) + subscription = SubscriptionFactory( + talpa_subscription_id=talpa_subscription_id, + status=SubscriptionStatus.CONFIRMED, + ) + OrderItemFactory( + order=order, + product=product, + permit=permit, + subscription=subscription, + quantity=1, + unit_price=unit_price, + ) + + url = reverse("parking_permits:subscription-notify") + data = { + "eventType": "SUBSCRIPTION_CANCELLED", + "subscriptionId": talpa_subscription_id, + "orderId": talpa_order_id, + "orderItemId": talpa_order_item_id, + } + + mock_validate_order.return_value = get_validated_order_data( + talpa_order_id, order.order_items.first().talpa_order_item_id + ) + + response = self.client.post(url, data) + self.assertEqual(response.status_code, 200) + subscription.refresh_from_db() + subscription_order = subscription.order_items.first().order + self.assertEqual(str(subscription.talpa_subscription_id), talpa_subscription_id) + self.assertEqual(str(subscription_order.talpa_order_id), talpa_order_id) + self.assertEqual(subscription.status, SubscriptionStatus.CANCELLED) + order.refresh_from_db() + self.assertEqual(subscription_order, order) + self.assertEqual(subscription_order.status, OrderStatus.CANCELLED) + self.assertEqual(permit.status, ParkingPermitStatus.VALID) + refund = Refund.objects.get(order=order) + self.assertEqual(refund.order, order) + self.assertEqual(refund.amount, Decimal("60.00")) + self.assertEqual(refund.vat_percent, Decimal("25.5")) + self.assertAlmostEqual(refund.vat_amount, Decimal(12.19), delta=Decimal("0.01")) + self.assertEqual(refund.name, permit.customer.full_name) + self.assertEqual(refund.status, RefundStatus.OPEN) + + @override_settings(DEBUG=True) + @patch.object(OrderValidator, "validate_order") + @freeze_time("2023-05-30") + def test_subscription_cancellation_with_refund_prev_vat(self, mock_validate_order): talpa_order_id = "d86ca61d-97e9-410a-a1e3-4894873b1b35" talpa_order_item_id = "819daecd-5ebb-4a94-924e-9710069e9285" talpa_subscription_id = "f769b803-0bd0-489d-aa81-b35af391f391" @@ -1630,7 +1703,7 @@ def test_subscription_cancellation_with_refund(self, mock_validate_order): ) order.permits.add(permit) order.save() - unit_price = Decimal(30) + unit_price = Decimal(60) product = ProductFactory(unit_price=unit_price) subscription = SubscriptionFactory( talpa_subscription_id=talpa_subscription_id, @@ -1642,7 +1715,8 @@ def test_subscription_cancellation_with_refund(self, mock_validate_order): permit=permit, subscription=subscription, quantity=1, - unit_price=60.00, + unit_price=unit_price, + vat=Decimal(0.24), ) url = reverse("parking_permits:subscription-notify") @@ -1671,6 +1745,8 @@ def test_subscription_cancellation_with_refund(self, mock_validate_order): refund = Refund.objects.get(order=order) self.assertEqual(refund.order, order) self.assertEqual(refund.amount, Decimal("60.00")) + self.assertEqual(refund.vat_percent, Decimal("24.0")) + self.assertAlmostEqual(refund.vat_amount, Decimal(11.61), delta=Decimal("0.01")) self.assertEqual(refund.name, permit.customer.full_name) self.assertEqual(refund.status, RefundStatus.OPEN) @@ -1701,7 +1777,7 @@ def test_subscription_cancellation_already_cancelled(self, mock_validate_order): ) order.permits.add(permit) order.save() - unit_price = Decimal(30) + unit_price = Decimal(60) product = ProductFactory(unit_price=unit_price) subscription = SubscriptionFactory( talpa_subscription_id=talpa_subscription_id, diff --git a/parking_permits/utils.py b/parking_permits/utils.py index 29e96e69..4186779d 100644 --- a/parking_permits/utils.py +++ b/parking_permits/utils.py @@ -161,6 +161,9 @@ def find_next_date(dt, day): If the day number of given date matches the day, the original date will be returned. + If the next date would be in the following month, last day + of current month is returned. + Args: dt (datetime.date): the starting date to search for day (int): the day number of found date @@ -174,7 +177,8 @@ def find_next_date(dt, day): _, month_end = calendar.monthrange(dt.year, dt.month) found = dt.replace(day=month_end) if found < dt: - found += relativedelta(months=1) + _, month_end = calendar.monthrange(dt.year, dt.month) + found = found.replace(day=month_end) return found @@ -232,7 +236,9 @@ def get_permit_prices( start_date = max(product.start_date, permit_start_date) end_date = min(product.end_date, permit_end_date) quantity = diff_months_ceil(start_date, end_date) - if index == product_count: + # remove one month from the last product if there are multiple products + # and the start date is not first day of the month + if index == product_count and permit_start_date.day != 1: quantity -= 1 permit_prices.append( { @@ -376,15 +382,15 @@ def flatten_dict(d, separator="__", prefix="", _output_ref=None) -> dict: def calc_net_price(gross_price: Currency, vat: Currency) -> Decimal: - """Returns the net price based on the gross and VAT e.g. 0.24 + """Returns the net price based on the gross and VAT e.g. 0.255 Net price is calculated thus: gross / (1 + vat) - For example, gross 100 EUR, VAT 24% would be: + For example, gross 100 EUR, VAT 25.5% would be: - 100 / 1.24 = ~80.64 + 100 / 1.255 = ~79.68 If gross or vat is zero or None, returns zero. """ @@ -396,13 +402,13 @@ def calc_net_price(gross_price: Currency, vat: Currency) -> Decimal: def calc_vat_price(gross_price: Currency, vat: Currency) -> Decimal: - """Returns the VAT price based on the gross and VAT e.g. 0.24 + """Returns the VAT price based on the gross and VAT e.g. 0.255 VAT price is equal to the gross minus the net. - For example, gross 100 EUR, VAT 24% would be net price of ~80.64. + For example, gross 100 EUR, VAT 25.5% would be net price of ~79.68. - VAT price would therefore be 100-80.64 = 19.36. + VAT price would therefore be 100-79.68 = ~20.32 """ return ( Decimal(gross_price) - calc_net_price(gross_price, vat)