diff --git a/doc/development/api/payment.rst b/doc/development/api/payment.rst index cc83ce4186..747e0229a0 100644 --- a/doc/development/api/payment.rst +++ b/doc/development/api/payment.rst @@ -112,6 +112,8 @@ The provider class .. automethod:: shred_payment_info + .. automethod:: cancel_payment + .. autoattribute:: is_implicit .. autoattribute:: is_meta diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index b862344230..6de4336857 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -173,9 +173,26 @@ class OrderViewSet(viewsets.ModelViewSet): amount=ps ) except OrderPayment.DoesNotExist: - order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING, - OrderPayment.PAYMENT_STATE_CREATED)) \ - .update(state=OrderPayment.PAYMENT_STATE_CANCELED) + for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING, + OrderPayment.PAYMENT_STATE_CREATED)): + try: + with transaction.atomic(): + p.payment_provider.cancel_payment(p) + order.log_action('pretix.event.order.payment.canceled', { + 'local_id': p.local_id, + 'provider': p.provider, + }, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) + except PaymentException as e: + order.log_action( + 'pretix.event.order.payment.canceled.failed', + { + 'local_id': p.local_id, + 'provider': p.provider, + 'error': str(e) + }, + user=self.request.user if self.request.user.is_authenticated else None, + auth=self.request.auth + ) p = order.payments.create( state=OrderPayment.PAYMENT_STATE_CREATED, provider='manual', @@ -896,13 +913,16 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet): if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED): return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST) - with transaction.atomic(): - payment.state = OrderPayment.PAYMENT_STATE_CANCELED - payment.save() - payment.order.log_action('pretix.event.order.payment.canceled', { - 'local_id': payment.local_id, - 'provider': payment.provider, - }, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) + try: + with transaction.atomic(): + payment.payment_provider.cancel_payment(payment) + payment.order.log_action('pretix.event.order.payment.canceled', { + 'local_id': payment.local_id, + 'provider': payment.provider, + }, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) + except PaymentException as e: + return Response({'detail': 'External error: {}'.format(str(e))}, + status=status.HTTP_400_BAD_REQUEST) return self.retrieve(request, [], **kwargs) diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index ea8962596e..b4df86cfbd 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -654,6 +654,17 @@ class BasePaymentProvider: """ return False + def cancel_payment(self, payment: OrderPayment): + """ + Will be called to cancel a payment. The default implementation just sets the payment state to canceled, + but in some cases you might want to notify an external provider. + + On success, you should set ``payment.state = OrderPayment.PAYMENT_STATE_CANCELED`` (or call the super method). + On failure, you should raise a PaymentException. + """ + payment.state = OrderPayment.PAYMENT_STATE_CANCELED + payment.save() + def execute_refund(self, refund: OrderRefund): """ Will be called to execute an refund. Note that refunds have an amount property and can be partial. diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 4d52b29ae3..ae8e04bf0d 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1183,20 +1183,49 @@ class OrderChangeManager: self.order.status = Order.STATUS_PAID self.order.save() elif self.open_payment: - self.open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED - self.open_payment.save() - self.order.log_action('pretix.event.order.payment.canceled', { - 'local_id': self.open_payment.local_id, - 'provider': self.open_payment.provider, - }, user=self.user, auth=self.auth) + try: + with transaction.atomic(): + self.open_payment.payment_provider.cancel_payment(self.open_payment) + self.order.log_action( + 'pretix.event.order.payment.canceled', + { + 'local_id': self.open_payment.local_id, + 'provider': self.open_payment.provider, + }, + user=self.user, + auth=self.auth + ) + except PaymentException as e: + self.order.log_action( + 'pretix.event.order.payment.canceled.failed', + { + 'local_id': self.open_payment.local_id, + 'provider': self.open_payment.provider, + 'error': str(e) + }, + user=self.user, + auth=self.auth + ) elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0: if self.open_payment: - self.open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED - self.open_payment.save() - self.order.log_action('pretix.event.order.payment.canceled', { - 'local_id': self.open_payment.local_id, - 'provider': self.open_payment.provider, - }, user=self.user, auth=self.auth) + try: + with transaction.atomic(): + self.open_payment.payment_provider.cancel_payment(self.open_payment) + self.order.log_action('pretix.event.order.payment.canceled', { + 'local_id': self.open_payment.local_id, + 'provider': self.open_payment.provider, + }, user=self.user, auth=self.auth) + except PaymentException as e: + self.order.log_action( + 'pretix.event.order.payment.canceled.failed', + { + 'local_id': self.open_payment.local_id, + 'provider': self.open_payment.provider, + 'error': str(e) + }, + user=self.user, + auth=self.auth, + ) def _check_paid_to_free(self): if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval: @@ -1726,8 +1755,22 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay if open_payment and open_payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED): - open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED - open_payment.save(update_fields=['state']) + try: + with transaction.atomic(): + open_payment.payment_provider.cancel_payment(open_payment) + order.log_action('pretix.event.order.payment.canceled', { + 'local_id': open_payment.local_id, + 'provider': open_payment.provider, + }) + except PaymentException as e: + order.log_action( + 'pretix.event.order.payment.canceled.failed', + { + 'local_id': open_payment.local_id, + 'provider': open_payment.provider, + 'error': str(e) + }, + ) order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0) order.save(update_fields=['total']) diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 78425511f2..991056f98b 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -225,6 +225,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.order.email.resend': _('An email with a link to the order detail page has been resent to the user.'), 'pretix.event.order.payment.confirmed': _('Payment {local_id} has been confirmed.'), 'pretix.event.order.payment.canceled': _('Payment {local_id} has been canceled.'), + 'pretix.event.order.payment.canceled.failed': _('Cancelling payment {local_id} has failed.'), 'pretix.event.order.payment.started': _('Payment {local_id} has been started.'), 'pretix.event.order.payment.failed': _('Payment {local_id} has failed.'), 'pretix.event.order.quotaexceeded': _('The order could not be marked as paid: {message}'), diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 3f5060f968..00434ddc8d 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -480,14 +480,26 @@ class OrderPaymentCancel(OrderView): def post(self, *args, **kwargs): if self.payment.state in (OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING): - with transaction.atomic(): - self.payment.state = OrderPayment.PAYMENT_STATE_CANCELED - self.payment.save() - self.order.log_action('pretix.event.order.payment.canceled', { - 'local_id': self.payment.local_id, - 'provider': self.payment.provider, - }, user=self.request.user) - messages.success(self.request, _('This payment has been canceled.')) + try: + with transaction.atomic(): + self.payment.payment_provider.cancel_payment(self.payment) + self.order.log_action('pretix.event.order.payment.canceled', { + 'local_id': self.payment.local_id, + 'provider': self.payment.provider, + }, user=self.request.user if self.request.user.is_authenticated else None) + except PaymentException as e: + self.order.log_action( + 'pretix.event.order.payment.canceled.failed', + { + 'local_id': self.payment.local_id, + 'provider': self.payment.provider, + 'error': str(e) + }, + user=self.request.user if self.request.user.is_authenticated else None, + ) + messages.error(self.request, str(e)) + else: + messages.success(self.request, _('This payment has been canceled.')) else: messages.error(self.request, _('This payment can not be canceled at the moment.')) return redirect(self.get_order_url()) @@ -859,9 +871,25 @@ class OrderTransition(OrderView): amount=ps ) except OrderPayment.DoesNotExist: - self.order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING, - OrderPayment.PAYMENT_STATE_CREATED)) \ - .update(state=OrderPayment.PAYMENT_STATE_CANCELED) + for p in self.order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING, + OrderPayment.PAYMENT_STATE_CREATED)): + try: + with transaction.atomic(): + p.payment_provider.cancel_payment(p) + self.order.log_action('pretix.event.order.payment.canceled', { + 'local_id': p.local_id, + 'provider': p.provider, + }, user=self.request.user if self.request.user.is_authenticated else None) + except PaymentException as e: + self.order.log_action( + 'pretix.event.order.payment.canceled.failed', + { + 'local_id': p.local_id, + 'provider': p.provider, + 'error': str(e) + }, + user=self.request.user if self.request.user.is_authenticated else None, + ) p = self.order.payments.create( state=OrderPayment.PAYMENT_STATE_CREATED, provider='manual', diff --git a/src/pretix/plugins/banktransfer/tasks.py b/src/pretix/plugins/banktransfer/tasks.py index 343106dbea..d11e5f9de8 100644 --- a/src/pretix/plugins/banktransfer/tasks.py +++ b/src/pretix/plugins/banktransfer/tasks.py @@ -12,6 +12,7 @@ from django_scopes import scope, scopes_disabled from pretix.base.email import get_email_context from pretix.base.i18n import language from pretix.base.models import Event, Order, OrderPayment, Organizer, Quota +from pretix.base.payment import PaymentException from pretix.base.services.locking import LockTimeoutException from pretix.base.services.mail import SendMailException from pretix.base.services.orders import change_payment_provider @@ -38,6 +39,30 @@ def notify_incomplete_payment(o: Order): logger.exception('Reminder email could not be sent') +def cancel_old_payments(order): + for p in order.payments.filter( + state__in=(OrderPayment.PAYMENT_STATE_PENDING, + OrderPayment.PAYMENT_STATE_CREATED), + provider='banktransfer', + ): + try: + with transaction.atomic(): + p.payment_provider.cancel_payment(p) + order.log_action('pretix.event.order.payment.canceled', { + 'local_id': p.local_id, + 'provider': p.provider, + }) + except PaymentException as e: + order.log_action( + 'pretix.event.order.payment.canceled.failed', + { + 'local_id': p.local_id, + 'provider': p.provider, + 'error': str(e) + }, + ) + + @transaction.atomic def _handle_transaction(trans: BankTransaction, code: str, event: Event=None, organizer: Organizer=None, slug: str=None): @@ -109,22 +134,13 @@ def _handle_transaction(trans: BankTransaction, code: str, event: Event=None, or p.confirm() except Quota.QuotaExceededException: trans.state = BankTransaction.STATE_VALID - trans.order.payments.filter( - provider='banktransfer', - state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING), - ).update(state=OrderPayment.PAYMENT_STATE_CANCELED) + cancel_old_payments(trans.order) except SendMailException: trans.state = BankTransaction.STATE_VALID - trans.order.payments.filter( - provider='banktransfer', - state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING), - ).update(state=OrderPayment.PAYMENT_STATE_CANCELED) + cancel_old_payments(trans.order) else: trans.state = BankTransaction.STATE_VALID - trans.order.payments.filter( - provider='banktransfer', - state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING), - ).update(state=OrderPayment.PAYMENT_STATE_CANCELED) + cancel_old_payments(trans.order) o = trans.order o.refresh_from_db()