From 1e0bfc51ffa9a5f9b66ebf149524aabcc12835a5 Mon Sep 17 00:00:00 2001 From: Danick Fort Date: Thu, 6 Jun 2024 17:34:14 +0200 Subject: [PATCH] YC-1188 Update unpaid PF transactions status in cron task, refactor confirm transaction code --- .env.example | 1 + docker-compose.yml | 1 + geocity/apps/submissions/cron.py | 20 ++++ .../update_payment_transactions_status.py | 56 +++++++++ ...tfinancetransaction_extra_data_and_more.py | 27 +++++ geocity/apps/submissions/models.py | 33 +++++ geocity/apps/submissions/payments/models.py | 21 ++++ .../payments/postfinance/processor.py | 12 +- .../apps/submissions/payments/processors.py | 6 +- geocity/apps/submissions/urls.py | 2 +- geocity/apps/submissions/views.py | 113 ++++++++---------- geocity/settings.py | 3 + 12 files changed, 227 insertions(+), 68 deletions(-) create mode 100644 geocity/apps/submissions/management/commands/update_payment_transactions_status.py create mode 100644 geocity/apps/submissions/migrations/0031_historicalpostfinancetransaction_extra_data_and_more.py diff --git a/.env.example b/.env.example index 855246ae4..3e456f1c6 100644 --- a/.env.example +++ b/.env.example @@ -94,6 +94,7 @@ OAUTH2_PKCE_REQUIRED=false # Available payment processor. Comma-separated values. PAYMENT_CURRENCY=CHF PAYMENT_PROCESSING_TEST_ENVIRONMENT=true +PAYMENT_PENDING_TRANSACTION_MAX_AGE_MINS=1440 # Used to convert dates to the correct UTC, in hours. Choose a value between -12 and +14 (has to be integer) LOCAL_TIME_ZONE_UTC=+1 # Use "localhost" in local diff --git a/docker-compose.yml b/docker-compose.yml index 9c096651d..a86c7976b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,6 +75,7 @@ services: AUTHOR_IBAN_VISIBLE: PAYMENT_PROCESSING_TEST_ENVIRONMENT: PAYMENT_CURRENCY: + PAYMENT_PENDING_TRANSACTION_MAX_AGE_MINS: LOCAL_TIME_ZONE_UTC: SITE_DOMAIN: USE_THUMBOR: diff --git a/geocity/apps/submissions/cron.py b/geocity/apps/submissions/cron.py index b469483af..c4bc081a8 100644 --- a/geocity/apps/submissions/cron.py +++ b/geocity/apps/submissions/cron.py @@ -131,3 +131,23 @@ def do(self): inquiry.submission.save() logger.info("The submission opening Cronjob finished") + + +class PaymentTransactionsStatusUpdate(CronJobBase): + RUN_EVERY_MINS = 1 + schedule = Schedule(run_every_mins=RUN_EVERY_MINS) + + code = "submissions.payment_transactions_status_update" + + def do(self): + try: + call_command("update_payment_transactions_status") + except CommandError: + logger.error( + "Error occured while trying to update the payment transactions " + "status from the Cronjob." + ) + else: + logger.info( + "The payment transactions status update Cronjob finished successfully" + ) diff --git a/geocity/apps/submissions/management/commands/update_payment_transactions_status.py b/geocity/apps/submissions/management/commands/update_payment_transactions_status.py new file mode 100644 index 000000000..77aea2129 --- /dev/null +++ b/geocity/apps/submissions/management/commands/update_payment_transactions_status.py @@ -0,0 +1,56 @@ +import datetime + +from django.conf import settings +from django.core.management import BaseCommand +from django.utils import timezone +from django.utils.translation import gettext + +from geocity.apps.submissions.payments.models import Transaction +from geocity.apps.submissions.payments.postfinance.models import PostFinanceTransaction +from geocity.apps.submissions.payments.services import get_payment_processor + + +class Command(BaseCommand): + help = gettext( + "Update the status of transactions that are pending and not older than %s hours." + % settings.PAYMENT_PENDING_TRANSACTION_MAX_AGE_MINS + ) + + def handle(self, *args, **options): + self.stdout.write( + "Checking status of unpaid transations that are not older than %s minutes..." + % settings.PAYMENT_PENDING_TRANSACTION_MAX_AGE_MINS + ) + + nb_transactions_confirmed = 0 + nb_transactions_failed = 0 + # Get all unpaid transactions that are not older than the specified time + transactions_to_update = PostFinanceTransaction.objects.filter( + status=Transaction.STATUS_UNPAID, + authorization_timeout_on__gte=timezone.now() + - datetime.timedelta( + minutes=settings.PAYMENT_PENDING_TRANSACTION_MAX_AGE_MINS + ), + ) + + for transaction in transactions_to_update: + submission = transaction.submission_price.submission + processor = get_payment_processor(submission.get_form_for_payment()) + + if processor.is_transaction_authorized(transaction): + transaction.confirm_payment() + nb_transactions_confirmed += 1 + elif processor.is_transaction_failed(transaction): + transaction.set_failed() + nb_transactions_failed += 1 + + if nb_transactions_confirmed: + self.stdout.write( + "Marked %d transactions as confirmed." % nb_transactions_confirmed + ) + if nb_transactions_failed: + self.stdout.write( + "Marked %d transactions as failed." % nb_transactions_failed + ) + if not nb_transactions_confirmed and not nb_transactions_failed: + self.stdout.write("No transactions to update.") diff --git a/geocity/apps/submissions/migrations/0031_historicalpostfinancetransaction_extra_data_and_more.py b/geocity/apps/submissions/migrations/0031_historicalpostfinancetransaction_extra_data_and_more.py new file mode 100644 index 000000000..bd738fda3 --- /dev/null +++ b/geocity/apps/submissions/migrations/0031_historicalpostfinancetransaction_extra_data_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.11 on 2024-06-06 12:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("submissions", "0030_alter_servicefeetype_fix_price_editable"), + ] + + operations = [ + migrations.AddField( + model_name="historicalpostfinancetransaction", + name="extra_data", + field=models.JSONField( + default=dict, verbose_name="Données supplémentaires" + ), + ), + migrations.AddField( + model_name="postfinancetransaction", + name="extra_data", + field=models.JSONField( + default=dict, verbose_name="Données supplémentaires" + ), + ), + ] diff --git a/geocity/apps/submissions/models.py b/geocity/apps/submissions/models.py index 209c54941..ae0b438f5 100644 --- a/geocity/apps/submissions/models.py +++ b/geocity/apps/submissions/models.py @@ -1308,6 +1308,39 @@ def get_last_prolongation_transaction(self): .first() ) + def set_prolongation_requested_and_notify(self, prolongation_date): + from . import services + + self.prolongation_status = self.PROLONGATION_STATUS_PENDING + self.prolongation_date = prolongation_date + self.save() + + attachments = [] + if self.requires_online_payment() and self.author.userprofile.notify_per_email: + attachments = self.get_submission_payment_attachments("confirmation") + data = { + "subject": "{} ({})".format( + _("Votre demande de prolongation"), self.get_forms_names_list() + ), + "users_to_notify": [self.author.email], + "template": "submission_acknowledgment.txt", + "submission": self, + "absolute_uri_func": Submission.get_absolute_url, + } + services.send_email_notification(data, attachments=attachments) + + data = { + "subject": "{} ({})".format( + _("Une demande de prolongation vient d'être soumise"), + self.get_forms_names_list(), + ), + "users_to_notify": self.get_secretary_email(), + "template": "submission_prolongation_for_services.txt", + "submission": self, + "absolute_uri_func": Submission.get_absolute_url, + } + services.send_email_notification(data, attachments=attachments) + # ServiceFees for submission def get_service_fees(self): return ServiceFee.objects.filter(submission=self) diff --git a/geocity/apps/submissions/payments/models.py b/geocity/apps/submissions/payments/models.py index cbcbf02fe..96e96a586 100644 --- a/geocity/apps/submissions/payments/models.py +++ b/geocity/apps/submissions/payments/models.py @@ -74,6 +74,8 @@ class Transaction(models.Model): default=TYPE_SUBMISSION, ) + extra_data = models.JSONField(_("Données supplémentaires"), default=dict) + class Meta: abstract = True ordering = ("-creation_date",) @@ -82,6 +84,14 @@ class Meta: def can_have_status_changed(self): return self.amount > 0 + @property + def has_been_confirmed(self): + return self.status in ( + self.STATUS_PAID, + self.STATUS_TO_REFUND, + self.STATUS_REFUNDED, + ) + def set_refunded(self): self.status = self.STATUS_REFUNDED self.save() @@ -136,6 +146,17 @@ def get_refund_pdf(self, read=False): output = output.read() return f"refund_{self.transaction_id}.pdf", output + def confirm_payment(self): + if self.transaction_type == self.TYPE_PROLONGATION: + prolongation_date = self.extra_data.get("prolongation_date") + if prolongation_date is None: + raise SuspiciousOperation + self.submission_price.submission.set_prolongation_requested_and_notify( + timezone.datetime.fromtimestamp(prolongation_date) + ) + self.set_paid() + self.submission_price.submission.generate_and_save_pdf("confirmation", self) + class ServiceFeeType(models.Model): administrative_entity = models.ForeignKey( diff --git a/geocity/apps/submissions/payments/postfinance/processor.py b/geocity/apps/submissions/payments/postfinance/processor.py index b32094f90..e22c3e58c 100644 --- a/geocity/apps/submissions/payments/postfinance/processor.py +++ b/geocity/apps/submissions/payments/postfinance/processor.py @@ -173,8 +173,16 @@ def is_transaction_authorized(self, transaction): TransactionState.AUTHORIZED, ) + def is_transaction_failed(self, transaction): + status = self._get_transaction_status(transaction) + return status in ( + TransactionState.FAILED, + TransactionState.VOIDED, + TransactionState.DECLINE, + ) + def _create_internal_transaction( - self, submission, transaction_type=None, override_price=None + self, submission, transaction_type=None, override_price=None, extra_data=None ): # If there is a related existing transaction, which: # 1. Is still within the PostFinance authorization time window @@ -203,7 +211,7 @@ def _create_internal_transaction( if existing_transaction: return existing_transaction, False return super(PostFinanceCheckoutProcessor, self)._create_internal_transaction( - submission, transaction_type, override_price + submission, transaction_type, override_price, extra_data ) def _save_merchant_data(self, transaction, merchant_transaction_data): diff --git a/geocity/apps/submissions/payments/processors.py b/geocity/apps/submissions/payments/processors.py index 5fce258ab..aaba7232c 100644 --- a/geocity/apps/submissions/payments/processors.py +++ b/geocity/apps/submissions/payments/processors.py @@ -38,7 +38,7 @@ def create_merchant_transaction( raise NotImplementedError def _create_internal_transaction( - self, submission, transaction_type=None, override_price=None + self, submission, transaction_type=None, override_price=None, extra_data=None ): price = submission.get_submission_price() if override_price is None: @@ -53,6 +53,8 @@ def _create_internal_transaction( "amount": amount, "currency": currency, } + if extra_data is not None: + create_kwargs.update({"extra_data": extra_data}) if transaction_type is not None: create_kwargs["transaction_type"] = transaction_type return ( @@ -89,6 +91,7 @@ def create_prolongation_transaction_and_return_payment_page_url( submission, transaction_type=self.transaction_class.TYPE_PROLONGATION, override_price=prolongation_price, + extra_data={"prolongation_date": prolongation_date}, ) if is_new_transaction: merchant_transaction_data = self.create_merchant_transaction( @@ -105,7 +108,6 @@ def create_prolongation_transaction_and_return_payment_page_url( "submissions:confirm_prolongation_transaction", kwargs={ "pk": transaction.pk, - "prolongation_date": prolongation_date, }, ) ), diff --git a/geocity/apps/submissions/urls.py b/geocity/apps/submissions/urls.py index aa07b1df6..e56850676 100644 --- a/geocity/apps/submissions/urls.py +++ b/geocity/apps/submissions/urls.py @@ -132,7 +132,7 @@ name="confirm_transaction", ), path( - "transactions/confirm_prolongation//", + "transactions/confirm_prolongation/", views.ConfirmProlongationTransactionView.as_view(), name="confirm_prolongation_transaction", ), diff --git a/geocity/apps/submissions/views.py b/geocity/apps/submissions/views.py index 586a1d662..a1e3f23b9 100644 --- a/geocity/apps/submissions/views.py +++ b/geocity/apps/submissions/views.py @@ -1500,43 +1500,6 @@ def submission_fields(request, submission_id): ) -def _set_prolongation_requested_and_notify(submission, request): - submission.prolongation_status = submission.PROLONGATION_STATUS_PENDING - submission.save() - - attachments = [] - if ( - submission.requires_online_payment() - and submission.author.userprofile.notify_per_email - ): - attachments = submission.get_submission_payment_attachments("confirmation") - data = { - "subject": "{} ({})".format( - _("Votre demande de prolongation"), submission.get_forms_names_list() - ), - "users_to_notify": [submission.author.email], - "template": "submission_acknowledgment.txt", - "submission": submission, - "absolute_uri_func": request.build_absolute_uri, - } - services.send_email_notification(data, attachments=attachments) - - # Send the email to the services - messages.success(request, _("Votre demande de prolongation a été envoyée")) - - data = { - "subject": "{} ({})".format( - _("Une demande de prolongation vient d'être soumise"), - submission.get_forms_names_list(), - ), - "users_to_notify": submission.get_secretary_email(), - "template": "submission_prolongation_for_services.txt", - "submission": submission, - "absolute_uri_func": request.build_absolute_uri, - } - services.send_email_notification(data, attachments=attachments) - - @redirect_bad_status_to_detail @login_required @permanent_user_required @@ -1604,7 +1567,10 @@ def submission_prolongation(request, submission_id): return redirect(payment_url) obj = form.save() - _set_prolongation_requested_and_notify(obj, request) + obj.set_prolongation_requested_and_notify( + form.cleaned_data["prolongation_date"] + ) + messages.success(request, _("Votre demande de prolongation a été envoyée")) return redirect("submissions:submissions_list") else: if submission.author != request.user: @@ -2639,22 +2605,36 @@ def get(self, request, *args, **kwargs): return redirect(reverse_lazy("submissions:submissions_list")) -@method_decorator(login_required, name="dispatch") class ConfirmTransactionCallback(View): def get(self, request, pk, *args, **kwargs): transaction = get_transaction_from_id(pk) submission = transaction.submission_price.submission - if ( - not request.user == submission.author - or not transaction.status == transaction.STATUS_UNPAID - ): - raise PermissionDenied + if transaction.has_been_confirmed: + return render( + request, + "submissions/submission_payment_callback_confirm.html", + { + "submission": submission, + }, + ) + + if transaction.status == transaction.STATUS_FAILED: + return render( + request, + "submissions/submission_payment_callback_fail.html", + { + "submission": submission, + "submission_url": reverse( + "submissions:submission_submit", + kwargs={"submission_id": submission.pk}, + ), + }, + ) processor = get_payment_processor(submission.get_form_for_payment()) if processor.is_transaction_authorized(transaction): - submission.generate_and_save_pdf("confirmation", transaction) - transaction.set_paid() + transaction.confirm_payment() submission_submit_confirmed(request, submission.pk) return render( @@ -2679,13 +2659,10 @@ def get(self, request, pk, *args, **kwargs): ) -@method_decorator(login_required, name="dispatch") class FailTransactionCallback(View): def get(self, request, pk, *args, **kwargs): transaction = get_transaction_from_id(pk) submission = transaction.submission_price.submission - if not request.user == submission.author: - raise PermissionDenied transaction.set_failed() @@ -2783,24 +2760,37 @@ def submission_validations_edit(request, submission_id): ) -@method_decorator(login_required, name="dispatch") class ConfirmProlongationTransactionView(View): - def get(self, request, pk, prolongation_date, *args, **kwargs): + def get(self, request, pk, *args, **kwargs): transaction = get_transaction_from_id(pk) submission = transaction.submission_price.submission - if ( - not request.user == submission.author - or not transaction.status == transaction.STATUS_UNPAID - ): - raise PermissionDenied + if transaction.has_been_confirmed: + return render( + request, + "submissions/submission_payment_callback_confirm.html", + { + "submission": submission, + }, + ) + + if transaction.status == transaction.STATUS_FAILED: + return render( + request, + "submissions/submission_payment_callback_fail.html", + { + "submission": submission, + "submission_url": reverse( + "submissions:submission_submit", + kwargs={"submission_id": submission.pk}, + ), + }, + ) processor = get_payment_processor(submission.get_form_for_payment()) if processor.is_transaction_authorized(transaction): - submission.generate_and_save_pdf("confirmation", transaction) - transaction.set_paid() - submission.prolongation_date = datetime.fromtimestamp(prolongation_date) - _set_prolongation_requested_and_notify(submission, request) + transaction.confirm_payment() + messages.success(request, _("Votre demande de prolongation a été envoyée")) return render( request, @@ -2824,13 +2814,10 @@ def get(self, request, pk, prolongation_date, *args, **kwargs): ) -@method_decorator(login_required, name="dispatch") class FailProlongationTransactionCallback(View): def get(self, request, pk, *args, **kwargs): transaction = get_transaction_from_id(pk) submission = transaction.submission_price.submission - if not request.user == submission.author: - raise PermissionDenied transaction.set_failed() diff --git a/geocity/settings.py b/geocity/settings.py index b7fa91b57..05f0d9f51 100644 --- a/geocity/settings.py +++ b/geocity/settings.py @@ -771,6 +771,9 @@ def show_toolbar(request): os.getenv("PAYMENT_PROCESSING_TEST_ENVIRONMENT", "true").lower() == "true" or DEBUG ) PAYMENT_CURRENCY = os.getenv("PAYMENT_CURRENCY") +PAYMENT_PENDING_TRANSACTION_MAX_AGE_MINS = int( + os.getenv("PAYMENT_PENDING_TRANSACTION_MAX_AGE_MINS", 30) +) # Services fees ## Default rate [CHF] for services fees