Skip to content

Commit 030dfb2

Browse files
authored
Merge pull request #607 from City-of-Helsinki/release-1.9.1
Release 1.9.1
2 parents b1a6f93 + 7f40c7a commit 030dfb2

File tree

3 files changed

+217
-16
lines changed

3 files changed

+217
-16
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.9.1] - 2025-10-16
11+
12+
### Fixed
13+
14+
- Do not create superfluous zero-amount refunds for open-ended permits ([41833a9](https://github.com/City-of-Helsinki/parking-permits/commit/41833a94f5857fd084feb2862c3b176f01c9ab79))
15+
1016
## [1.9.0] - 2025-09-25
1117

1218
### Fixed

parking_permits/models/order.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -712,22 +712,24 @@ def cancel(self, cancel_reason, cancel_from_talpa=True, iban=""):
712712
self.save()
713713

714714
if permit.can_be_refunded:
715-
logger.info(f"Creating Refund for permit {str(permit.id)}")
716-
from ..resolver_utils import create_refund
717-
718-
create_refund(
719-
user=permit.customer.user,
720-
permits=[permit],
721-
orders=[order],
722-
amount=permit.total_refund_amount,
723-
iban=iban,
724-
vat=(order.vat if order.vat else DEFAULT_VAT),
725-
description=f"Refund for ending permit {str(permit.id)}",
726-
)
727-
# Mark the order item as refunded
728-
order_item.is_refunded = True
729-
order_item.save()
730-
logger.info(f"Refund for permit {str(permit.id)} created successfully")
715+
total_refund_amount = permit.total_refund_amount
716+
if total_refund_amount > 0:
717+
logger.info(f"Creating Refund for permit {str(permit.id)}")
718+
from ..resolver_utils import create_refund
719+
720+
create_refund(
721+
user=permit.customer.user,
722+
permits=[permit],
723+
orders=[order],
724+
amount=total_refund_amount,
725+
iban=iban,
726+
vat=(order.vat if order.vat else DEFAULT_VAT),
727+
description=f"Refund for ending permit {str(permit.id)}",
728+
)
729+
# Mark the order item as refunded
730+
order_item.is_refunded = True
731+
order_item.save()
732+
logger.info(f"Refund for permit {str(permit.id)} created successfully")
731733

732734
# Try to cancel subscription from Talpa as well
733735
if cancel_from_talpa:
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import datetime
2+
from decimal import Decimal
3+
from unittest.mock import patch
4+
5+
import pytest
6+
from dateutil.relativedelta import relativedelta
7+
from django.test import TestCase
8+
from django.urls import reverse, reverse_lazy
9+
from freezegun import freeze_time
10+
11+
from parking_permits.models.order import (
12+
Order,
13+
SubscriptionStatus,
14+
)
15+
from parking_permits.models.parking_permit import (
16+
ParkingPermitStatus,
17+
)
18+
from parking_permits.models.refund import Refund
19+
from parking_permits.tests.factories.customer import CustomerFactory
20+
from parking_permits.tests.factories.order import (
21+
OrderItemFactory,
22+
SubscriptionFactory,
23+
)
24+
from parking_permits.tests.factories.parking_permit import ParkingPermitFactory
25+
from parking_permits.tests.factories.zone import ParkingZoneFactory
26+
from parking_permits.tests.test_resolver_utils import _create_zone_products
27+
28+
MOCK_SYNC_WITH_PARKKIHUBI = "parking_permits.resolver_utils.sync_with_parkkihubi"
29+
30+
MOCK_VALIDATE_ORDER = "parking_permits.models.order.OrderValidator.validate_order"
31+
32+
MOCK_SEND_PERMIT_EMAIL = "parking_permits.resolver_utils.send_permit_email"
33+
34+
MOCK_SEND_VEHICLE_DISCOUNT_EMAIL = (
35+
"parking_permits.resolver_utils.send_vehicle_low_emission_discount_email"
36+
)
37+
38+
39+
def get_validated_order_data(talpa_order_id, talpa_order_item_id):
40+
return {
41+
"orderId": talpa_order_id,
42+
"checkoutUrl": "https://test.com",
43+
"loggedInCheckoutUrl": "https://test.com",
44+
"receiptUrl": "https://test.com",
45+
"items": [
46+
{
47+
"orderItemId": talpa_order_item_id,
48+
"startDate": "2023-04-01T15:46:05.619",
49+
"priceGross": "45.00",
50+
"rowPriceTotal": "45.00",
51+
"vatPercentage": "25.50",
52+
"quantity": 1,
53+
}
54+
],
55+
}
56+
57+
58+
class TestSubscriptionTestCase(TestCase):
59+
@pytest.mark.django_db()
60+
@patch(MOCK_SYNC_WITH_PARKKIHUBI)
61+
@patch(MOCK_SEND_PERMIT_EMAIL)
62+
@patch(MOCK_SEND_VEHICLE_DISCOUNT_EMAIL)
63+
@patch(MOCK_VALIDATE_ORDER)
64+
def test_subscription_cancel_does_not_generate_additional_zero_refund(
65+
self,
66+
mock_validate_order,
67+
mock_send_vehicle_discount_email,
68+
mock_send_permit_email,
69+
mock_sync_with_parkkihubi,
70+
):
71+
talpa_order_id = "d86ca61d-97e9-410a-a1e3-4894873b1b35"
72+
talpa_order_item_id = "2f20c06d-2a9a-4a60-be4b-504d8a2f8c02"
73+
talpa_subscription_id = "f769b803-0bd0-489d-aa81-b35af391f391"
74+
75+
zone = ParkingZoneFactory(name="A")
76+
77+
customer = CustomerFactory()
78+
permit_start_time = datetime.datetime(
79+
2023, 4, 30, 10, 00, 0, tzinfo=datetime.timezone.utc
80+
)
81+
permit_end_time = permit_start_time + relativedelta(months=1)
82+
permit = ParkingPermitFactory(
83+
status=ParkingPermitStatus.VALID,
84+
customer=customer,
85+
parking_zone=zone,
86+
start_time=permit_start_time,
87+
end_time=permit_end_time,
88+
month_count=1,
89+
)
90+
91+
unit_price = Decimal(45)
92+
products_start_date = permit_start_time.date() - relativedelta(years=1)
93+
products_end_date = permit_start_time.date() + relativedelta(years=5)
94+
products = _create_zone_products(
95+
zone,
96+
[
97+
[
98+
(products_start_date, products_end_date),
99+
unit_price,
100+
],
101+
],
102+
)
103+
104+
subscription = SubscriptionFactory(
105+
talpa_subscription_id=talpa_subscription_id,
106+
status=SubscriptionStatus.CONFIRMED,
107+
)
108+
109+
initial_order = Order.objects.create_for_permits([permit])
110+
initial_order.save()
111+
112+
initial_order_item = OrderItemFactory(
113+
talpa_order_item_id=talpa_order_id,
114+
order=initial_order,
115+
product=products[0],
116+
permit=permit,
117+
subscription=subscription,
118+
start_time=permit_start_time,
119+
end_time=permit_end_time,
120+
)
121+
122+
mock_validate_order.return_value = get_validated_order_data(
123+
talpa_order_id, talpa_order_item_id
124+
)
125+
126+
with freeze_time(permit_start_time + relativedelta(days=20)):
127+
# Renew subscription
128+
order_view_url = reverse("parking_permits:order-notify")
129+
subscription_renewal_data = {
130+
"eventType": "SUBSCRIPTION_RENEWAL_ORDER_CREATED",
131+
"subscriptionId": talpa_subscription_id,
132+
"orderId": talpa_order_id,
133+
}
134+
response = self.client.post(order_view_url, subscription_renewal_data)
135+
self.assertEqual(response.status_code, 200)
136+
137+
with freeze_time(permit_start_time + relativedelta(days=20, minutes=5)):
138+
# Subscription renewal payment
139+
payment_view_url = reverse_lazy("parking_permits:payment-notify")
140+
renewal_payment_data = {
141+
"eventType": "PAYMENT_PAID",
142+
"orderId": talpa_order_id,
143+
}
144+
response = self.client.post(payment_view_url, renewal_payment_data)
145+
self.assertEqual(response.status_code, 200)
146+
147+
# There should be no refunds before ending the permit.
148+
refund_count_before_end = Refund.objects.count()
149+
self.assertEqual(refund_count_before_end, 0)
150+
151+
# Cancel sub/end the permit, do this early enough to trigger a refund.
152+
with freeze_time(permit_start_time + relativedelta(days=20, minutes=6)):
153+
subscription_cancel_view_url = reverse(
154+
"parking_permits:subscription-notify"
155+
)
156+
subscription_cancel_data = {
157+
"eventType": "SUBSCRIPTION_CANCELLED",
158+
"subscriptionId": talpa_subscription_id,
159+
"orderId": talpa_order_id,
160+
"orderItemId": talpa_order_item_id,
161+
}
162+
163+
response = self.client.post(
164+
subscription_cancel_view_url, subscription_cancel_data
165+
)
166+
self.assertEqual(response.status_code, 200)
167+
168+
# Initial order item should not have refunds due to the logic
169+
# in subscription cancellation being run immediately after the
170+
# usual refund-logic when ending a permit.
171+
initial_order_item.refresh_from_db()
172+
order_item_refunds = initial_order_item.order.refunds.all()
173+
self.assertEqual(order_item_refunds.count(), 0)
174+
175+
# The usual refunds are created before the sub is cancelled,
176+
# these should have positive amount.
177+
refunds_after_end = permit.refunds.all()
178+
assert len(refunds_after_end) > 0
179+
for refund in refunds_after_end:
180+
assert refund.amount > 0
181+
182+
subscription.refresh_from_db()
183+
assert subscription.status == SubscriptionStatus.CANCELLED
184+
185+
# VALID due to subscription cancellation using
186+
# AFTER_CURRENT_PERIOD as the end type.
187+
permit.refresh_from_db()
188+
assert permit.status == ParkingPermitStatus.VALID
189+
190+
mock_sync_with_parkkihubi.assert_called()
191+
mock_validate_order.assert_called()
192+
mock_send_permit_email.assert_called()
193+
mock_send_vehicle_discount_email.assert_not_called()

0 commit comments

Comments
 (0)