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 @@ +