Repository: City-of-Helsinki/tilavarauspalvelu-core
Pull request #1808: Create payment orders when approving paid reservations
Author: login: matti-lamppu
Labels: name: feature
description: Adds a new feature
Created: 2025-05-15T12:39:11Z | Merged: 2025-05-20T12:08:53Z

## 🛠️ Changelog
[//]: # "Describe the changes in this pull request here."

- Create pending payment order when payment should be done in webshop
- Create manually paid order if paid on site
- Handle previous payment orders if reservation was previously approved, then set back to handling and then approved
- Add feature flag `PAYMENT_ORDERS_FOR_HANDLED_RESERVATIONS_ENABLED` for enabling this functionality later

## 🧪 Test plan
[//]: # "Help your fellow reviewer and write a short description of the fastest way to test your changes."

- Automated tests

## 🚧 Dependencies
[//]: # "Is this PR dependent on other changes outside this repository? Describe the changes, or add links to them."
[//]: # "e.g. This PR breaks X and is waiting for frontend changes before merging."

- Frontend should handle approval with the new logic. Test locally by setting `PAYMENT_ORDERS_FOR_HANDLED_RESERVATIONS_ENABLED=True` in local settings.

## 🎫 Tickets
[//]: # "This pull request resolves all or part of the following ticket(s)."

- [TILA-4034](https://helsinkisolutionoffice.atlassian.net/browse/TILA-4034)


[TILA-4034]: https://helsinkisolutionoffice.atlassian.net/browse/TILA-4034?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/backend/config/settings.py b/backend/config/settings.py
index f8105ddac5..51b70616c8 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -498,6 +498,7 @@ def CACHES(cls):
     UPDATE_RESERVATION_UNIT_THUMBNAILS = True
     DOWNLOAD_IMAGES_FOR_TEST_DATA = True
     FRONTEND_TESTING_API_ENABLED = False
+    PAYMENT_ORDERS_FOR_HANDLED_RESERVATIONS_ENABLED = False
 
     PRUNE_RESERVATIONS_OLDER_THAN_MINUTES = 20
     REMOVE_RESERVATION_STATS_OLDER_THAN_YEARS = 5
@@ -862,6 +863,8 @@ class AutomatedTests(EmptyDefaults, Common, dotenv_path=None, overrides_from=Aut
     SENTRY_LOGGER_ALWAYS_RE_RAISE = True
     # Enable frontend testing API for testing
     FRONTEND_TESTING_API_ENABLED = True
+    # Enable feature flag for testing
+    PAYMENT_ORDERS_FOR_HANDLED_RESERVATIONS_ENABLED = True
 
     EMAIL_VARAAMO_EXT_LINK = "https://fake.varaamo.hel.fi"
     EMAIL_FEEDBACK_EXT_LINK = "https://fake.varaamo.hel.fi/feedback"
diff --git a/backend/tests/factories/reservation_unit.py b/backend/tests/factories/reservation_unit.py
index 9b01264ab2..bf34e19649 100644
--- a/backend/tests/factories/reservation_unit.py
+++ b/backend/tests/factories/reservation_unit.py
@@ -10,7 +10,14 @@
 from factory import LazyAttribute
 from factory.fuzzy import FuzzyInteger
 
-from tilavarauspalvelu.enums import AccessType, AuthenticationType, PriceUnit, ReservationKind, ReservationStartInterval
+from tilavarauspalvelu.enums import (
+    AccessType,
+    AuthenticationType,
+    PaymentType,
+    PriceUnit,
+    ReservationKind,
+    ReservationStartInterval,
+)
 from tilavarauspalvelu.models import ReservationUnit
 from utils.date_utils import local_start_of_day
 from utils.utils import as_p_tags
@@ -171,6 +178,23 @@ def create_reservable_now(cls, **kwargs: Any) -> ReservationUnit:
 
         return reservation_unit
 
+    @classmethod
+    def create_paid_on_site(cls, **kwargs: Any) -> ReservationUnit:
+        kwargs.setdefault("pricings__lowest_price", Decimal("10.00"))
+        kwargs.setdefault("pricings__highest_price", Decimal("20.00"))
+        kwargs.setdefault("pricings__tax_percentage__value", Decimal("25.50"))
+        kwargs.setdefault("pricings__payment_type", PaymentType.ON_SITE)
+        return cls.create(**kwargs)
+
+    @classmethod
+    def create_paid_in_webshop(cls, **kwargs: Any) -> ReservationUnit:
+        kwargs.setdefault("pricings__lowest_price", Decimal("10.00"))
+        kwargs.setdefault("pricings__highest_price", Decimal("20.00"))
+        kwargs.setdefault("pricings__tax_percentage__value", Decimal("25.50"))
+        kwargs.setdefault("pricings__payment_type", PaymentType.ONLINE)
+        kwargs.setdefault("payment_product__id", uuid.uuid4())
+        return cls.create(**kwargs)
+
 
 class ReservationUnitBuilder(ModelFactoryBuilder[ReservationUnit]):
     factory = ReservationUnitFactory
diff --git a/backend/tests/test_graphql_api/test_reservation/test_approve.py b/backend/tests/test_graphql_api/test_reservation/test_approve.py
index c1e83b629a..efec6df9cb 100644
--- a/backend/tests/test_graphql_api/test_reservation/test_approve.py
+++ b/backend/tests/test_graphql_api/test_reservation/test_approve.py
@@ -1,18 +1,25 @@
 from __future__ import annotations
 
+from decimal import Decimal
+from typing import TYPE_CHECKING
+
 import pytest
+from freezegun import freeze_time
 
-from tilavarauspalvelu.enums import AccessType, ReservationStateChoice
+from tilavarauspalvelu.enums import AccessType, OrderStatus, PaymentType, ReservationStateChoice
 from tilavarauspalvelu.integrations.keyless_entry import PindoraService
 from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraAPIError
 from tilavarauspalvelu.integrations.sentry import SentryLogger
-from utils.date_utils import local_datetime
+from utils.date_utils import DEFAULT_TIMEZONE, local_datetime
 
-from tests.factories import ReservationFactory, ReservationUnitFactory
+from tests.factories import PaymentOrderFactory, ReservationFactory, ReservationUnitFactory
 from tests.helpers import patch_method
 
 from .helpers import APPROVE_MUTATION, get_approve_data
 
+if TYPE_CHECKING:
+    from tilavarauspalvelu.models import PaymentOrder
+
 pytestmark = [
     pytest.mark.django_db,
 ]
@@ -20,7 +27,7 @@
 
 @patch_method(PindoraService.activate_access_code)
 def test_reservation__approve__succeeds(graphql):
-    reservation_unit = ReservationUnitFactory.create()
+    reservation_unit = ReservationUnitFactory.create_paid_on_site()
     reservation = ReservationFactory.create(
         state=ReservationStateChoice.REQUIRES_HANDLING,
         reservation_units=[reservation_unit],
@@ -39,7 +46,7 @@ def test_reservation__approve__succeeds(graphql):
 
 
 def test_reservation__approve__cant_approve_if_status_not_requires_handling(graphql):
-    reservation_unit = ReservationUnitFactory.create()
+    reservation_unit = ReservationUnitFactory.create_paid_on_site()
     reservation = ReservationFactory.create(
         state=ReservationStateChoice.CREATED,
         reservation_units=[reservation_unit],
@@ -57,7 +64,7 @@ def test_reservation__approve__cant_approve_if_status_not_requires_handling(grap
 
 
 def test_reservation__approve__approving_fails_when_price_missing(graphql):
-    reservation_unit = ReservationUnitFactory.create()
+    reservation_unit = ReservationUnitFactory.create_paid_on_site()
     reservation = ReservationFactory.create(
         state=ReservationStateChoice.REQUIRES_HANDLING,
         reservation_units=[reservation_unit],
@@ -75,7 +82,7 @@ def test_reservation__approve__approving_fails_when_price_missing(graphql):
 
 
 def test_reservation__approve__fails_when_handling_details_missing(graphql):
-    reservation_unit = ReservationUnitFactory.create()
+    reservation_unit = ReservationUnitFactory.create_paid_on_site()
     reservation = ReservationFactory.create(
         state=ReservationStateChoice.REQUIRES_HANDLING,
         reservation_units=[reservation_unit],
@@ -93,7 +100,7 @@ def test_reservation__approve__fails_when_handling_details_missing(graphql):
 
 
 def test_reservation__approve__succeeds_with_empty_handling_details(graphql):
-    reservation_unit = ReservationUnitFactory.create()
+    reservation_unit = ReservationUnitFactory.create_paid_on_site()
     reservation = ReservationFactory.create(
         state=ReservationStateChoice.REQUIRES_HANDLING,
         reservation_units=[reservation_unit],
@@ -113,8 +120,11 @@ def test_reservation__approve__succeeds_with_empty_handling_details(graphql):
 @patch_method(PindoraService.activate_access_code)
 @patch_method(PindoraService.create_access_code)
 def test_reservation__approve__succeeds__pindora_api__call_succeeds(graphql):
+    reservation_unit = ReservationUnitFactory.create_paid_on_site(
+        access_types__access_type=AccessType.ACCESS_CODE,
+    )
     reservation = ReservationFactory.create(
-        reservation_units__access_types__access_type=AccessType.ACCESS_CODE,
+        reservation_units=[reservation_unit],
         state=ReservationStateChoice.REQUIRES_HANDLING,
         access_type=AccessType.ACCESS_CODE,
         access_code_is_active=False,
@@ -138,8 +148,11 @@ def test_reservation__approve__succeeds__pindora_api__call_succeeds(graphql):
 @patch_method(PindoraService.create_access_code)
 @patch_method(SentryLogger.log_exception)
 def test_reservation__approve__succeeds__pindora_api__call_fails(graphql):
+    reservation_unit = ReservationUnitFactory.create_paid_on_site(
+        access_types__access_type=AccessType.ACCESS_CODE,
+    )
     reservation = ReservationFactory.create(
-        reservation_units__access_types__access_type=AccessType.ACCESS_CODE,
+        reservation_units=[reservation_unit],
         state=ReservationStateChoice.REQUIRES_HANDLING,
         access_type=AccessType.ACCESS_CODE,
         access_code_is_active=False,
@@ -161,3 +174,227 @@ def test_reservation__approve__succeeds__pindora_api__call_fails(graphql):
     assert PindoraService.create_access_code.called is False
 
     assert SentryLogger.log_exception.called is True
+
+
+@freeze_time(local_datetime(2024, 1, 1, 12))
+def test_reservation__approve__paid_in_webshop(graphql):
+    reservation_unit = ReservationUnitFactory.create_paid_in_webshop()
+    reservation = ReservationFactory.create(
+        state=ReservationStateChoice.REQUIRES_HANDLING,
+        reservation_units=[reservation_unit],
+        begin=local_datetime(2024, 1, 10, 12),
+        end=local_datetime(2024, 1, 10, 13),
+        tax_percentage_value=Decimal("24.0"),
+    )
+
+    graphql.login_with_superuser()
+    data = get_approve_data(reservation)
+    response = graphql(APPROVE_MUTATION, input_data=data)
+
+    assert response.has_errors is False, response.errors
+
+    reservation.refresh_from_db()
+
+    # Reservation is confirmed, even though payment is not completed
+    assert reservation.state == ReservationStateChoice.CONFIRMED
+
+    # Tax percentage is taken from the latest pricing
+    assert reservation.tax_percentage_value == Decimal("25.5")
+
+    assert hasattr(reservation, "payment_order")
+
+    payment_order: PaymentOrder = reservation.payment_order
+
+    # Verkkokauppa order has not been created yet
+    assert payment_order.remote_id is None
+    assert payment_order.checkout_url == ""
+    assert payment_order.receipt_url == ""
+
+    # Order is pending
+    assert payment_order.payment_type == PaymentType.ONLINE
+    assert payment_order.status == OrderStatus.PENDING
+
+    # Pricing is correct
+    assert payment_order.price_net == Decimal("8.44")
+    assert payment_order.price_vat == Decimal("2.15")
+    assert payment_order.price_total == Decimal("10.59")
+
+    # Handled payment due by is set 3 days from now
+    handled_payment_due_by = payment_order.handled_payment_due_by.astimezone(DEFAULT_TIMEZONE)
+    assert handled_payment_due_by == local_datetime(2024, 1, 4, 12)
+
+
+@freeze_time(local_datetime(2024, 1, 1, 12))
+def test_reservation__approve__paid_in_webshop__close_to_begin_date(graphql):
+    reservation_unit = ReservationUnitFactory.create_paid_in_webshop()
+    reservation = ReservationFactory.create(
+        state=ReservationStateChoice.REQUIRES_HANDLING,
+        reservation_units=[reservation_unit],
+        begin=local_datetime(2024, 1, 2, 12),
+        end=local_datetime(2024, 1, 2, 13),
+    )
+
+    graphql.login_with_superuser()
+    data = get_approve_data(reservation)
+    response = graphql(APPROVE_MUTATION, input_data=data)
+
+    assert response.has_errors is False, response.errors
+
+    reservation.refresh_from_db()
+    assert reservation.state == ReservationStateChoice.CONFIRMED
+
+    assert hasattr(reservation, "payment_order")
+
+    payment_order = reservation.payment_order
+
+    # Handled payment due by is set 1 hour before reservation begins
+    handled_payment_due_by = payment_order.handled_payment_due_by.astimezone(DEFAULT_TIMEZONE)
+    assert handled_payment_due_by == local_datetime(2024, 1, 2, 11)
+
+
+@freeze_time(local_datetime(2024, 1, 1, 12))
+def test_reservation__approve__paid_in_webshop__has_payment__paid_online__with_same_price(graphql):
+    reservation_unit = ReservationUnitFactory.create_paid_in_webshop()
+    reservation = ReservationFactory.create(
+        state=ReservationStateChoice.REQUIRES_HANDLING,
+        reservation_units=[reservation_unit],
+        begin=local_datetime(2024, 1, 2, 12),
+        end=local_datetime(2024, 1, 2, 13),
+        tax_percentage_value=Decimal("24.0"),
+    )
+
+    payment_order = PaymentOrderFactory.create(
+        reservation=reservation,
+        payment_type=PaymentType.ONLINE,
+        status=OrderStatus.PAID,
+        price_net=Decimal("8.44"),
+        price_vat=Decimal("2.15"),
+        price_total=Decimal("10.59"),
+    )
+
+    graphql.login_with_superuser()
+    data = get_approve_data(reservation)
+    response = graphql(APPROVE_MUTATION, input_data=data)
+
+    assert response.has_errors is False, response.errors
+
+    reservation.refresh_from_db()
+    assert reservation.state == ReservationStateChoice.CONFIRMED
+    # Tax percentage is not changed, since we reuse the existing payment order
+    assert reservation.tax_percentage_value == Decimal("24.0")
+
+    # Same payment order should be reused
+    assert hasattr(reservation, "payment_order")
+    assert reservation.payment_order == payment_order
+
+
+@freeze_time(local_datetime(2024, 1, 1, 12))
+def test_reservation__approve__paid_in_webshop__has_payment__paid_online__different_price(graphql):
+    reservation_unit = ReservationUnitFactory.create_paid_in_webshop()
+    reservation = ReservationFactory.create(
+        state=ReservationStateChoice.REQUIRES_HANDLING,
+        reservation_units=[reservation_unit],
+        begin=local_datetime(2024, 1, 2, 12),
+        end=local_datetime(2024, 1, 2, 13),
+    )
+
+    PaymentOrderFactory.create(
+        reservation=reservation,
+        payment_type=PaymentType.ONLINE,
+        status=OrderStatus.PAID,
+        price_net=Decimal("10.0"),
+        price_vat=Decimal("2.0"),
+        price_total=Decimal("12.0"),
+    )
+
+    graphql.login_with_superuser()
+    data = get_approve_data(reservation)
+    response = graphql(APPROVE_MUTATION, input_data=data)
+
+    assert response.error_message() == "Mutation was unsuccessful."
+    assert response.field_error_messages() == ["Reservation already has a paid payment order with a different price."]
+
+
+@freeze_time(local_datetime(2024, 1, 1, 12))
+@pytest.mark.parametrize("status", [OrderStatus.PAID_MANUALLY, OrderStatus.REFUNDED, OrderStatus.CANCELLED])
+def test_reservation__approve__paid_in_webshop__has_payment__not_paid_online(graphql, status):
+    reservation_unit = ReservationUnitFactory.create_paid_in_webshop()
+    reservation = ReservationFactory.create(
+        state=ReservationStateChoice.REQUIRES_HANDLING,
+        reservation_units=[reservation_unit],
+        begin=local_datetime(2024, 1, 2, 12),
+        end=local_datetime(2024, 1, 2, 13),
+    )
+
+    old_payment_order = PaymentOrderFactory.create(
+        reservation=reservation,
+        payment_type=PaymentType.ONLINE,
+        status=status,
+        price_net=Decimal("10.0"),
+        price_vat=Decimal("2.0"),
+        price_total=Decimal("12.0"),
+    )
+
+    graphql.login_with_superuser()
+    data = get_approve_data(reservation)
+    response = graphql(APPROVE_MUTATION, input_data=data)
+
+    assert response.has_errors is False, response.errors
+
+    reservation.refresh_from_db()
+    assert reservation.state == ReservationStateChoice.CONFIRMED
+
+    # Payment order should be replaced
+    assert hasattr(reservation, "payment_order")
+    new_payment_order = reservation.payment_order
+    assert new_payment_order != old_payment_order
+
+    # Order is pending
+    assert new_payment_order.payment_type == PaymentType.ONLINE
+    assert new_payment_order.status == OrderStatus.PENDING
+
+    # Pricing is correct
+    assert new_payment_order.price_net == Decimal("8.44")
+    assert new_payment_order.price_vat == Decimal("2.15")
+    assert new_payment_order.price_total == Decimal("10.59")
+
+
+@freeze_time(local_datetime(2024, 1, 1, 12))
+def test_reservation__approve__no_pricing(graphql):
+    reservation_unit = ReservationUnitFactory.create()
+    reservation = ReservationFactory.create(
+        state=ReservationStateChoice.REQUIRES_HANDLING,
+        reservation_units=[reservation_unit],
+        begin=local_datetime(2024, 1, 10, 12),
+        end=local_datetime(2024, 1, 10, 13),
+    )
+
+    graphql.login_with_superuser()
+    data = get_approve_data(reservation)
+    response = graphql(APPROVE_MUTATION, input_data=data)
+
+    assert response.error_message() == "Mutation was unsuccessful."
+    assert response.field_error_messages() == ["No pricing found for reservation's begin date."]
+
+
+@freeze_time(local_datetime(2024, 1, 1, 12))
+def test_reservation__approve__no_payment_product_if_paid_online(graphql):
+    reservation_unit = ReservationUnitFactory.create(
+        pricings__lowest_price=Decimal("10.00"),
+        pricings__highest_price=Decimal("20.00"),
+        pricings__tax_percentage__value=Decimal("25.50"),
+        pricings__payment_type=PaymentType.ONLINE,
+    )
+    reservation = ReservationFactory.create(
+        state=ReservationStateChoice.REQUIRES_HANDLING,
+        reservation_units=[reservation_unit],
+        begin=local_datetime(2024, 1, 10, 12),
+        end=local_datetime(2024, 1, 10, 13),
+    )
+
+    graphql.login_with_superuser()
+    data = get_approve_data(reservation)
+    response = graphql(APPROVE_MUTATION, input_data=data)
+
+    assert response.error_message() == "Mutation was unsuccessful."
+    assert response.field_error_messages() == ["Reservation unit is missing payment product"]
diff --git a/backend/tests/test_graphql_api/test_reservation/test_approve_permissions.py b/backend/tests/test_graphql_api/test_reservation/test_approve_permissions.py
index 52e29f250a..adda5e54df 100644
--- a/backend/tests/test_graphql_api/test_reservation/test_approve_permissions.py
+++ b/backend/tests/test_graphql_api/test_reservation/test_approve_permissions.py
@@ -4,7 +4,7 @@
 
 from tilavarauspalvelu.enums import ReservationStateChoice, UserRoleChoice
 
-from tests.factories import ReservationFactory
+from tests.factories import ReservationFactory, ReservationUnitFactory
 
 from .helpers import APPROVE_MUTATION, get_approve_data
 
@@ -15,10 +15,14 @@
 
 @pytest.mark.parametrize("role", [UserRoleChoice.HANDLER, UserRoleChoice.ADMIN])
 def test_reservation__approve__allowed(graphql, role):
-    graphql.login_user_with_role(role=role)
-    reservation = ReservationFactory.create(state=ReservationStateChoice.REQUIRES_HANDLING)
+    reservation_unit = ReservationUnitFactory.create_paid_on_site()
+    reservation = ReservationFactory.create(
+        state=ReservationStateChoice.REQUIRES_HANDLING,
+        reservation_units=[reservation_unit],
+    )
 
     data = get_approve_data(reservation)
+    graphql.login_user_with_role(role=role)
     response = graphql(APPROVE_MUTATION, input_data=data)
 
     assert response.has_errors is False, response.errors
@@ -27,7 +31,13 @@ def test_reservation__approve__allowed(graphql, role):
 def test_reservation__approve__allowed__own(graphql):
     # Reservers are allowed to approve their own reservations.
     user = graphql.login_user_with_role(role=UserRoleChoice.RESERVER)
-    reservation = ReservationFactory.create(state=ReservationStateChoice.REQUIRES_HANDLING, user=user)
+
+    reservation_unit = ReservationUnitFactory.create_paid_on_site()
+    reservation = ReservationFactory.create(
+        state=ReservationStateChoice.REQUIRES_HANDLING,
+        reservation_units=[reservation_unit],
+        user=user,
+    )
 
     data = get_approve_data(reservation)
     response = graphql(APPROVE_MUTATION, input_data=data)
@@ -37,7 +47,12 @@ def test_reservation__approve__allowed__own(graphql):
 
 def test_reservation__approve__not_allowed(graphql):
     graphql.login_with_regular_user()
-    reservation = ReservationFactory.create(state=ReservationStateChoice.REQUIRES_HANDLING)
+
+    reservation_unit = ReservationUnitFactory.create_paid_on_site()
+    reservation = ReservationFactory.create(
+        state=ReservationStateChoice.REQUIRES_HANDLING,
+        reservation_units=[reservation_unit],
+    )
 
     data = get_approve_data(reservation)
     response = graphql(APPROVE_MUTATION, input_data=data)
@@ -49,7 +64,13 @@ def test_reservation__approve__not_allowed(graphql):
 def test_reservation__approve__not_allowed__own(graphql):
     # Regular users are not approve to approve their own reservations without required role.
     user = graphql.login_with_regular_user()
-    reservation = ReservationFactory.create(state=ReservationStateChoice.REQUIRES_HANDLING, user=user)
+
+    reservation_unit = ReservationUnitFactory.create_paid_on_site()
+    reservation = ReservationFactory.create(
+        state=ReservationStateChoice.REQUIRES_HANDLING,
+        reservation_units=[reservation_unit],
+        user=user,
+    )
 
     data = get_approve_data(reservation)
     response = graphql(APPROVE_MUTATION, input_data=data)
diff --git a/backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py b/backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py
index fa2cb40b3f..fe4eef4e2b 100644
--- a/backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py
+++ b/backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py
@@ -100,6 +100,7 @@
 RESERVATION_MODIFICATION_NOT_ALLOWED = "RESERVATION_MODIFICATION_NOT_ALLOWED"
 RESERVATION_NO_PAYMENT_ORDER = "RESERVATION_NO_PAYMENT_ORDER"
 RESERVATION_NOT_WITHIN_ALLOWED_TIME_RANGE = "RESERVATION_NOT_WITHIN_ALLOWED_TIME_RANGE"
+RESERVATION_PRICE_CANNOT_BE_CHANGED = "RESERVATION_PRICE_CANNOT_BE_CHANGED"
 RESERVATION_SERIES_ALREADY_STARTED = "RESERVATION_SERIES_ALREADY_STARTED"
 RESERVATION_SERIES_HAS_ENDED = "RESERVATION_SERIES_HAS_ENDED"
 RESERVATION_SERIES_INVALID_RECURRENCE_IN_DAYS = " RESERVATION_SERIES_INVALID_RECURRENCE_IN_DAYS"
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/approve_serializers.py b/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/approve_serializers.py
index 4e193e1623..c37e075b43 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/approve_serializers.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/approve_serializers.py
@@ -1,21 +1,26 @@
 from __future__ import annotations
 
+import datetime
 from typing import TYPE_CHECKING
 
+from django.conf import settings
 from graphene_django_extensions import NestingModelSerializer
 from graphene_django_extensions.fields import EnumFriendlyChoiceField
+from rest_framework.exceptions import ValidationError
 from rest_framework.fields import IntegerField
 
-from tilavarauspalvelu.enums import AccessType, ReservationStateChoice
+from tilavarauspalvelu.api.graphql.extensions import error_codes
+from tilavarauspalvelu.enums import AccessType, OrderStatus, PaymentType, ReservationStateChoice
 from tilavarauspalvelu.integrations.email.main import EmailService
 from tilavarauspalvelu.integrations.keyless_entry import PindoraService
 from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraNotFoundError
 from tilavarauspalvelu.integrations.sentry import SentryLogger
 from tilavarauspalvelu.models import Reservation
-from utils.date_utils import local_datetime
+from utils.date_utils import DEFAULT_TIMEZONE, local_datetime
 from utils.external_service.errors import ExternalServiceError
 
 if TYPE_CHECKING:
+    from tilavarauspalvelu.models import ReservationUnit
     from tilavarauspalvelu.typing import ReservationApproveData
 
 __all__ = [
@@ -55,9 +60,58 @@ def validate(self, data: ReservationApproveData) -> ReservationApproveData:
         self.instance.validators.validate_reservation_state_allows_approving()
         data["state"] = ReservationStateChoice.CONFIRMED
         data["handled_at"] = local_datetime()
+
+        if data["price"] == 0:
+            return data
+
+        if settings.PAYMENT_ORDERS_FOR_HANDLED_RESERVATIONS_ENABLED:
+            return self.validate_payment_order(data)
+
+        return data
+
+    def validate_payment_order(self, data: ReservationApproveData) -> ReservationApproveData:
+        data["should_delete_previous_payment_order"] = False
+
+        # In case reservation is approved -> set back to handled -> approved, there might be a payment order already
+        if hasattr(self.instance, "payment_order"):
+            payment_order = self.instance.payment_order
+
+            paid_in_webshop = payment_order.status in OrderStatus.paid_in_webshop
+            data["should_delete_previous_payment_order"] = not paid_in_webshop
+
+            if paid_in_webshop:
+                if payment_order.price_total != data["price"]:
+                    msg = "Reservation already has a paid payment order with a different price."
+                    raise ValidationError(msg, code=error_codes.RESERVATION_PRICE_CANNOT_BE_CHANGED)
+
+                # If price hasn't changed, we can just use the existing payment order.
+                return data
+
+        reservation_unit: ReservationUnit = self.instance.reservation_units.first()
+        begin = self.instance.begin.astimezone(DEFAULT_TIMEZONE)
+
+        pricing = reservation_unit.actions.get_active_pricing(by_date=begin.date())
+        if pricing is None:
+            msg = "No pricing found for reservation's begin date."
+            raise ValidationError(msg, code=error_codes.RESERVATION_UNIT_NO_ACTIVE_PRICING)
+
+        if pricing.payment_type != PaymentType.ON_SITE:
+            reservation_unit.validators.validate_has_payment_product()
+
+        data["payment_type"] = PaymentType(pricing.payment_type)
+        # Tax percentage needs to be updated in case it has changed since the reservation was created
+        data["tax_percentage_value"] = pricing.tax_percentage.value
+
+        three_days_from_now = local_datetime() + datetime.timedelta(days=3)
+        one_hour_before_begin = begin - datetime.timedelta(hours=1)
+        data["handled_payment_due_by"] = min(three_days_from_now, one_hour_before_begin)
+
         return data
 
     def update(self, instance: Reservation, validated_data: ReservationApproveData) -> Reservation:
+        if settings.PAYMENT_ORDERS_FOR_HANDLED_RESERVATIONS_ENABLED:
+            self.create_payment_order_if_required(validated_data)
+
         instance = super().update(instance=instance, validated_data=validated_data)
 
         if instance.access_type == AccessType.ACCESS_CODE:
@@ -76,3 +130,25 @@ def update(self, instance: Reservation, validated_data: ReservationApproveData)
         EmailService.send_reservation_approved_email(reservation=instance)
         EmailService.send_staff_notification_reservation_made_email(reservation=instance)
         return instance
+
+    def create_payment_order_if_required(self, validated_data: ReservationApproveData) -> None:
+        state = validated_data["state"]
+
+        # Delete any non-paid/refunded payment order
+        if validated_data["should_delete_previous_payment_order"]:
+            self.instance.payment_order.delete()
+            self.instance.refresh_from_db()
+
+        # If a payment order still exists, it should be paid and have the same price, so don't create a new one.
+        if hasattr(self.instance, "payment_order"):
+            return
+
+        # Set price info on instance early so that payment order data is calculated correctly
+        self.instance.price = validated_data["price"]
+        self.instance.tax_percentage_value = validated_data["tax_percentage_value"]
+
+        if self.instance.price_net > 0 and state.should_create_payment_order:
+            self.instance.actions.create_payment_order_paid_after_handling(
+                payment_type=validated_data["payment_type"],
+                handled_payment_due_by=validated_data["handled_payment_due_by"],
+            )
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/confirm_serializers.py b/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/confirm_serializers.py
index 8963221bb2..b237dcd9c1 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/confirm_serializers.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/confirm_serializers.py
@@ -82,7 +82,7 @@ def update(self, instance: Reservation, validated_data: ReservationConfirmData)
         state = validated_data["state"]
 
         if self.instance.price_net > 0 and state.should_create_payment_order:
-            self.instance.actions.create_payment_order(payment_type=validated_data["payment_type"])
+            self.instance.actions.create_payment_order_paid_immediately(payment_type=validated_data["payment_type"])
 
         instance = super().update(instance=instance, validated_data=validated_data)
 
diff --git a/backend/tilavarauspalvelu/enums.py b/backend/tilavarauspalvelu/enums.py
index 51634067e6..6c32240c21 100644
--- a/backend/tilavarauspalvelu/enums.py
+++ b/backend/tilavarauspalvelu/enums.py
@@ -722,6 +722,13 @@ def requires_verkkokauppa(cls) -> list[PaymentType]:
             cls.ONLINE_OR_INVOICE,
         ]
 
+    @classproperty
+    def types_that_can_be_pending(cls) -> list[PaymentType]:
+        return [
+            cls.ONLINE,
+            cls.ONLINE_OR_INVOICE,
+        ]
+
 
 class PriceUnit(models.TextChoices):
     PRICE_UNIT_PER_15_MINS = "per_15_mins", pgettext_lazy("PriceUnit", "per 15 minutes")
diff --git a/backend/tilavarauspalvelu/models/reservation/actions.py b/backend/tilavarauspalvelu/models/reservation/actions.py
index 76862107a8..b6191ace43 100644
--- a/backend/tilavarauspalvelu/models/reservation/actions.py
+++ b/backend/tilavarauspalvelu/models/reservation/actions.py
@@ -332,17 +332,28 @@ def cancel_invoiced_reservation(self) -> None:
 
         payment_order.actions.set_order_as_cancelled()
 
-    def create_payment_order(self, payment_type: PaymentType) -> PaymentOrder:
+    def create_payment_order_paid_immediately(self, payment_type: PaymentType) -> PaymentOrder:
         if payment_type == PaymentType.ON_SITE:
-            return PaymentOrder.objects.create(
-                payment_type=payment_type,
-                status=OrderStatus.PAID_MANUALLY,
-                language=self.reservation.user.get_preferred_language(),
-                price_net=self.reservation.price_net,
-                price_vat=self.reservation.price_vat_amount,
-                price_total=self.reservation.price,
-                reservation=self.reservation,
-            )
+            return self.reservation.actions.create_payment_order_paid_on_site()
+        return self.reservation.actions.create_payment_order_paid_online(payment_type)
+
+    def create_payment_order_paid_after_handling(
+        self,
+        payment_type: PaymentType,
+        handled_payment_due_by: datetime.datetime,
+    ) -> PaymentOrder:
+        if payment_type == PaymentType.ON_SITE:
+            return self.reservation.actions.create_payment_order_paid_on_site()
+
+        return self.reservation.actions.create_payment_order_pending_after_handling(
+            payment_type=payment_type,
+            handled_payment_due_by=handled_payment_due_by,
+        )
+
+    def create_payment_order_paid_online(self, payment_type: PaymentType) -> PaymentOrder:
+        if payment_type not in PaymentType.requires_verkkokauppa:
+            msg = f"Payment type {payment_type!r} cannot be paid online"
+            raise ValidationError(msg)
 
         verkkokauppa_order = self.create_order_in_verkkokauppa()
 
@@ -360,6 +371,37 @@ def create_payment_order(self, payment_type: PaymentType) -> PaymentOrder:
             receipt_url=verkkokauppa_order.receipt_url,
         )
 
+    def create_payment_order_paid_on_site(self) -> PaymentOrder:
+        return PaymentOrder.objects.create(
+            payment_type=PaymentType.ON_SITE,
+            status=OrderStatus.PAID_MANUALLY,
+            language=self.reservation.user.get_preferred_language(),
+            price_net=self.reservation.price_net,
+            price_vat=self.reservation.price_vat_amount,
+            price_total=self.reservation.price,
+            reservation=self.reservation,
+        )
+
+    def create_payment_order_pending_after_handling(
+        self,
+        payment_type: PaymentType,
+        handled_payment_due_by: datetime.datetime,
+    ) -> PaymentOrder:
+        if payment_type not in PaymentType.types_that_can_be_pending:
+            msg = f"Payment type {payment_type!r} cannot have a pending payment order"
+            raise ValidationError(msg)
+
+        return PaymentOrder.objects.create(
+            payment_type=payment_type,
+            status=OrderStatus.PENDING,
+            language=self.reservation.user.get_preferred_language(),
+            price_net=self.reservation.price_net,
+            price_vat=self.reservation.price_vat_amount,
+            price_total=self.reservation.price,
+            reservation=self.reservation,
+            handled_payment_due_by=handled_payment_due_by,
+        )
+
     def create_order_in_verkkokauppa(self) -> Order:
         if settings.MOCK_VERKKOKAUPPA_API_ENABLED:
             return create_mock_verkkokauppa_order(self.reservation)
diff --git a/backend/tilavarauspalvelu/typing.py b/backend/tilavarauspalvelu/typing.py
index 0c9f2e5fbc..ccfcdc8b88 100644
--- a/backend/tilavarauspalvelu/typing.py
+++ b/backend/tilavarauspalvelu/typing.py
@@ -250,6 +250,10 @@ class ReservationApproveData(TypedDict):
     handled_at: NotRequired[datetime.datetime]
     access_code_generated_at: NotRequired[datetime.datetime | None]
     access_code_is_active: NotRequired[bool]
+    payment_type: NotRequired[PaymentType]
+    handled_payment_due_by: NotRequired[datetime.datetime]
+    tax_percentage_value: NotRequired[Decimal]
+    should_delete_previous_payment_order: NotRequired[bool]
 
 
 class ReservationCancellationData(TypedDict):


Reviews:
user: login: vergama
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T10:49:29Z
user: login: vergama
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T10:55:43Z
user: login: vergama
body: Nice but I realised now that the tax percentange should not be updated in the special case that an already paid price is retained.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T10:57:10Z
user: login: matti-lamppu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T12:49:09Z
user: login: vergama
body: Good to go except for the error code: https://github.com/City-of-Helsinki/tilavarauspalvelu-core/pull/1808/files#diff-1d64f3077d36de330572d8066571f54d57e98b69e193e0076ad369875a0ad606R103
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T13:37:35Z
user: login: matti-lamppu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T11:42:37Z

Review comments:
diff_hunk: @@ -100,6 +100,7 @@
 RESERVATION_MODIFICATION_NOT_ALLOWED = "RESERVATION_MODIFICATION_NOT_ALLOWED"
 RESERVATION_NO_PAYMENT_ORDER = "RESERVATION_NO_PAYMENT_ORDER"
 RESERVATION_NOT_WITHIN_ALLOWED_TIME_RANGE = "RESERVATION_NOT_WITHIN_ALLOWED_TIME_RANGE"
+RESERVATION_PRICE_CANNOT_BE_CHANGED = "RESERVATION_HAS_NO_PAYMENT_ORDER"
path: backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py
user: login: vergama
body: Copy-pasta?
created_at: 2025-05-16T10:49:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,9 +60,54 @@ def validate(self, data: ReservationApproveData) -> ReservationApproveData:
         self.instance.validators.validate_reservation_state_allows_approving()
         data["state"] = ReservationStateChoice.CONFIRMED
         data["handled_at"] = local_datetime()
+
+        if data["price"] == 0:
+            return data
+
+        if settings.PAYMENT_ORDERS_FOR_HANDLED_RESERVATIONS_ENABLED:
+            return self.validate_payment_order(data)
+
+        return data
+
+    def validate_payment_order(self, data: ReservationApproveData) -> ReservationApproveData:
+        data["should_delete_previous_payment_order"] = False
+
+        # In case reservation is approved -> set back to handled -> approved, there might be a payment order already
+        if hasattr(self.instance, "payment_order"):
+            payment_order = self.instance.payment_order
+
+            paid_in_webshop = payment_order.status in OrderStatus.paid_in_webshop
+            data["should_delete_previous_payment_order"] = not paid_in_webshop
+
+            if paid_in_webshop and payment_order.price_total != data["price"]:
+                msg = "Reservation already has a paid payment order with a different price."
+                raise ValidationError(msg, code=error_codes.RESERVATION_PRICE_CANNOT_BE_CHANGED)
+
path: backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/approve_serializers.py
user: login: vergama
body: Can return **if** `paid_in_webshop` and skip validating the rest?
1. Additional data for creation of new payment order will not be needed.
2. Payment product (and pricing) thus also not necessary.
3. Come to think of it, the old tax percentage value should in that case also be retained, since that's what the reservation was already paid with (and the approval will confirm that price as standing).
created_at: 2025-05-16T10:55:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,9 +60,54 @@ def validate(self, data: ReservationApproveData) -> ReservationApproveData:
         self.instance.validators.validate_reservation_state_allows_approving()
         data["state"] = ReservationStateChoice.CONFIRMED
         data["handled_at"] = local_datetime()
+
+        if data["price"] == 0:
+            return data
+
+        if settings.PAYMENT_ORDERS_FOR_HANDLED_RESERVATIONS_ENABLED:
+            return self.validate_payment_order(data)
+
+        return data
+
+    def validate_payment_order(self, data: ReservationApproveData) -> ReservationApproveData:
+        data["should_delete_previous_payment_order"] = False
+
+        # In case reservation is approved -> set back to handled -> approved, there might be a payment order already
+        if hasattr(self.instance, "payment_order"):
+            payment_order = self.instance.payment_order
+
+            paid_in_webshop = payment_order.status in OrderStatus.paid_in_webshop
+            data["should_delete_previous_payment_order"] = not paid_in_webshop
+
+            if paid_in_webshop and payment_order.price_total != data["price"]:
+                msg = "Reservation already has a paid payment order with a different price."
+                raise ValidationError(msg, code=error_codes.RESERVATION_PRICE_CANNOT_BE_CHANGED)
+
path: backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/approve_serializers.py
user: login: matti-lamppu
body: Changed.
created_at: 2025-05-19T12:49:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -100,6 +100,7 @@
 RESERVATION_MODIFICATION_NOT_ALLOWED = "RESERVATION_MODIFICATION_NOT_ALLOWED"
 RESERVATION_NO_PAYMENT_ORDER = "RESERVATION_NO_PAYMENT_ORDER"
 RESERVATION_NOT_WITHIN_ALLOWED_TIME_RANGE = "RESERVATION_NOT_WITHIN_ALLOWED_TIME_RANGE"
+RESERVATION_PRICE_CANNOT_BE_CHANGED = "RESERVATION_HAS_NO_PAYMENT_ORDER"
path: backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py
user: login: matti-lamppu
body: Fixed
created_at: 2025-05-20T11:42:37Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-20T12:19:04Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1808) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1808&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1808&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1808&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1808&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1808&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1808)


author_association: NONE