Skip to content

Conversation

@ArnavBallinCode
Copy link
Contributor

@ArnavBallinCode ArnavBallinCode commented Nov 27, 2025

Fix: Payment Duplicate Confirmation Returns Proper Error Status

Root Cause

In OrderPayment.confirm() (lines 1671-1677), when a duplicate confirmation is detected:

if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
    logger.info('Confirmed payment {} but ignored due to likely race condition.'.format(self.full_id))
    return  # Silent return - no exception!

The method logs a message but returns None silently. The calling code cannot distinguish this from a successful confirmation.

Solution

  1. Created new exception: PaymentAlreadyConfirmedException in payment.py

    • Subclass of PaymentException for proper exception hierarchy
    • Clear semantic meaning for the error condition
  2. Updated model: Changed silent return to raise exception

    if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
        raise PaymentAlreadyConfirmedException(
            'Payment {} has already been confirmed.'.format(self.full_id)
        )
  3. Updated API: Added specific handler in api/views/order.py

    except PaymentAlreadyConfirmedException as e:
        return Response({'detail': str(e)}, status=status.HTTP_409_CONFLICT)

Addresses the race condition issue where duplicate payment confirmations were indistinguishable from successful confirmations, breaking idempotency contracts expected by payment integrations.

Fixes #1344

Summary by Sourcery

Ensure duplicate payment confirmations return a conflict error instead of silently succeeding, improving observability and idempotency for payment confirmation flows.

Bug Fixes:

  • Raise a dedicated PaymentAlreadyConfirmedException when attempting to confirm an already confirmed payment instead of returning silently.
  • Handle already confirmed payments in the order confirmation API by returning an HTTP 409 response with a clear error message.

Enhancements:

  • Introduce a specific PaymentAlreadyConfirmedException subclass to represent duplicate confirmation attempts in the payment domain.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Nov 27, 2025

Reviewer's Guide

Raises a dedicated PaymentAlreadyConfirmedException when a payment confirm call hits an already-confirmed payment, and wires this through the model and API layer so duplicate confirmations now return a 409 Conflict instead of silently succeeding.

Sequence diagram for payment confirmation with duplicate detection

sequenceDiagram
    actor APIClient
    participant OrderAPI as OrderAPI_confirm
    participant OrderPayment as OrderPayment
    participant DB as Database

    APIClient->>OrderAPI: POST /orders/{order_id}/payments/{payment_id}/confirm
    OrderAPI->>DB: Load OrderPayment by id
    DB-->>OrderAPI: OrderPayment instance

    alt payment already confirmed (precheck)
        OrderAPI->>OrderPayment: Read state
        OrderAPI-->>APIClient: HTTP 409 Conflict (Payment already confirmed)
    else payment not yet confirmed
        OrderAPI->>OrderPayment: confirm(currency, amount, send_mail, force, generate_invoice, invoice_qualified)
        OrderPayment->>DB: select_for_update(pk)
        DB-->>OrderPayment: locked_instance

        alt locked_instance.state is PAYMENT_STATE_CONFIRMED
            OrderPayment->>OrderPayment: log concurrent confirm attempt
            OrderPayment-->>OrderAPI: raise PaymentAlreadyConfirmedException
            OrderAPI-->>APIClient: HTTP 409 Conflict (Payment already confirmed)
        else locked_instance.state is not PAYMENT_STATE_CONFIRMED
            OrderPayment->>DB: update state, payment_date, save
            DB-->>OrderPayment: saved
            OrderPayment-->>OrderAPI: return successfully
            OrderAPI-->>APIClient: HTTP 200 OK (Payment confirmed)
        end
    end
Loading

Class diagram for payment confirmation exceptions and model update

classDiagram
    class PaymentException {
    }

    class PaymentAlreadyConfirmedException {
    }

    class OrderPayment {
        PAYMENT_STATE_CONFIRMED
        PAYMENT_STATE_PENDING
        PAYMENT_STATE_CREATED
        confirm(currency, amount, send_mail, force, generate_invoice, invoice_qualified)
    }

    PaymentException <|-- PaymentAlreadyConfirmedException

    class OrderConfirmView {
        confirm(request, kwargs)
    }

    OrderConfirmView ..> OrderPayment : uses
    OrderConfirmView ..> PaymentAlreadyConfirmedException : catches
    OrderConfirmView ..> PaymentException : catches
Loading

File-Level Changes

Change Details Files
Make duplicate payment confirmation attempts fail with a specific exception instead of silently returning
  • Within OrderPayment.confirm, import PaymentAlreadyConfirmedException lazily to avoid circular imports
  • Under transaction.atomic with select_for_update, detect already-confirmed payments and log a concise concurrency message
  • Raise PaymentAlreadyConfirmedException with a clear message when the payment is already confirmed instead of returning None
  • Leave the normal confirmation path unchanged when the payment is not yet confirmed
app/eventyay/base/models/orders.py
Return a 409 Conflict HTTP response for already-confirmed payments at the API layer
  • Short-circuit confirm endpoint if the payment is already in CONFIRMED state and respond with 409 and a human-readable message
  • Catch PaymentAlreadyConfirmedException from OrderPayment.confirm and map it to a 409 Conflict response body
  • Leave existing error handling (quota exceeded, generic payment errors) intact
app/eventyay/api/views/order.py
Introduce a dedicated exception type for already-confirmed payment errors
  • Add PaymentAlreadyConfirmedException as a subclass of PaymentException
  • Document the exception as representing attempts to confirm already-confirmed payments
app/eventyay/base/payment.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1344 Change OrderPayment.confirm() so that when a payment is already confirmed, it raises a specific exception instead of returning silently (thus allowing callers to detect duplicate confirmations).
#1344 Update the HTTP/API layer so that a duplicate payment confirmation returns a clear error response (non-2xx, e.g. 409/422) with an explanatory message that clients can use to distinguish duplicates from successful confirmations.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • In api/views/order.py, PaymentAlreadyConfirmedException is used in the confirm handler but not imported; add it alongside the existing payment-related imports so the new except block works at runtime.
  • In OrderPayment.confirm, consider moving the PaymentAlreadyConfirmedException import to the module level (or adding a brief comment explaining the local import) to avoid repeated imports and make the dependency clearer unless there is a circular-import constraint.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `api/views/order.py`, `PaymentAlreadyConfirmedException` is used in the `confirm` handler but not imported; add it alongside the existing payment-related imports so the new `except` block works at runtime.
- In `OrderPayment.confirm`, consider moving the `PaymentAlreadyConfirmedException` import to the module level (or adding a brief comment explaining the local import) to avoid repeated imports and make the dependency clearer unless there is a circular-import constraint.

## Individual Comments

### Comment 1
<location> `app/eventyay/base/models/orders.py:1673-1674` </location>
<code_context>
-                        self.full_id,
-                    )
+                # Payment is already confirmed, raise exception
+                raise PaymentAlreadyConfirmedException(
+                    'Payment {} has already been confirmed.'.format(self.full_id)
                 )
-                return
</code_context>

<issue_to_address>
**suggestion:** Raising instead of logging changes behavior and removes observability of the race condition.

Previously, a concurrent double-confirm was logged and treated as a no-op; now it raises and propagates. If this behavior change is intended, consider adding a log (info/debug) just before raising so you preserve the observability of these race conditions while using the more explicit error handling.

Suggested implementation:

```python
        with transaction.atomic():
            locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
            if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
                # Payment is already confirmed; log and raise exception to preserve observability of race conditions
                logger.info(
                    "Concurrent confirm attempt for already confirmed payment %s",
                    self.full_id,
                )
                raise PaymentAlreadyConfirmedException(
                    "Payment {} has already been confirmed.".format(self.full_id)
                )

            locked_instance.state = self.PAYMENT_STATE_CONFIRMED
            locked_instance.payment_date = payment_date or now()

```

1. Ensure there is a module-level logger defined consistent with the existing conventions in `orders.py`, for example:
   ```python
   import logging

   logger = logging.getLogger(__name__)
   ```
   If the file already defines a `logger` (or uses a different logging pattern), reuse that instead of introducing a new name.
2. If your logging policy prefers a different level (`debug` instead of `info`) for expected-but-rare races, adjust `logger.info` accordingly.
</issue_to_address>

### Comment 2
<location> `app/eventyay/base/models/orders.py:1674` </location>
<code_context>
    def confirm(
        self,
        count_waitinglist=True,
        send_mail=True,
        force=False,
        user=None,
        auth=None,
        mail_text='',
        ignore_date=False,
        lock=True,
        payment_date=None,
    ):
        """
        Marks the payment as complete. If possible, this also marks the order as paid if no further
        payment is required

        :param count_waitinglist: Whether, when calculating quota, people on the waiting list should be taken into
                                  consideration (default: ``True``).
        :type count_waitinglist: boolean
        :param force: Whether this payment should be marked as paid even if no remaining
                      quota is available (default: ``False``).
        :param ignore_date: Whether this order should be marked as paid even when the last date of payments is over.
        :type force: boolean
        :param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
        :type send_mail: boolean
        :param user: The user who performed the change
        :param auth: The API auth token that performed the change
        :param mail_text: Additional text to be included in the email
        :type mail_text: str
        :raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
        """
        from eventyay.base.services.invoices import (
            generate_invoice,
            invoice_qualified,
        )
        from eventyay.base.payment import PaymentAlreadyConfirmedException

        with transaction.atomic():
            locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
            if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
                # Payment is already confirmed, raise exception
                raise PaymentAlreadyConfirmedException(
                    'Payment {} has already been confirmed.'.format(self.full_id)
                )

            locked_instance.state = self.PAYMENT_STATE_CONFIRMED
            locked_instance.payment_date = payment_date or now()
            locked_instance.info = self.info  # required for backwards compatibility
            locked_instance.save(update_fields=['state', 'payment_date', 'info'])

            # Do a cheap manual "refresh from db" on non-complex fields
            for field in self._meta.concrete_fields:
                if not field.is_relation:
                    setattr(self, field.attname, getattr(locked_instance, field.attname))

        self.refresh_from_db()

        self.order.log_action(
            'eventyay.event.order.payment.confirmed',
            {
                'local_id': self.local_id,
                'provider': self.provider,
            },
            user=user,
            auth=auth,
        )

        if self.order.status in (Order.STATUS_PAID, Order.STATUS_CANCELED):
            logger.info('Confirmed payment {} but order is in status {}.'.format(self.full_id, self.order.status))
            return

        payment_sum = self.order.payments.filter(
            state__in=(self.PAYMENT_STATE_CONFIRMED, self.PAYMENT_STATE_REFUNDED)
        ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
        refund_sum = self.order.refunds.filter(
            state__in=(
                OrderRefund.REFUND_STATE_DONE,
                OrderRefund.REFUND_STATE_TRANSIT,
                OrderRefund.REFUND_STATE_CREATED,
            )
        ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
        if payment_sum - refund_sum < self.order.total:
            logger.info(
                'Confirmed payment {} but payment sum is {} and refund sum is {}.'.format(
                    self.full_id, payment_sum, refund_sum
                )
            )
            return

        if (self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12)) or not lock:
            # Performance optimization. In this case, there's really no reason to lock everything and an atomic
            # database transaction is more than enough.
            lockfn = NoLockManager
        else:
            lockfn = self.order.event.lock

        with lockfn():
            self._mark_paid(
                force,
                count_waitinglist,
                user,
                auth,
                overpaid=payment_sum - refund_sum > self.order.total,
                ignore_date=ignore_date,
            )

        invoice = None
        if invoice_qualified(self.order):
            invoices = self.order.invoices.filter(is_cancellation=False).count()
            cancellations = self.order.invoices.filter(is_cancellation=True).count()
            gen_invoice = (
                invoices == 0 and self.order.event.settings.get('invoice_generate') in ('True', 'paid')
            ) or 0 < invoices <= cancellations
            if gen_invoice:
                invoice = generate_invoice(
                    self.order,
                    trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment,
                )

        if send_mail and self.order.sales_channel in self.order.event.settings.mail_sales_channel_placed_paid:
            self._send_paid_mail(invoice, user, mail_text)
            if self.order.event.settings.mail_send_order_paid_attendee:
                for p in self.order.positions.all():
                    if p.addon_to_id is None and p.attendee_email and p.attendee_email != self.order.email:
                        self._send_paid_mail_attendee(p, user)

</code_context>

<issue_to_address>
**suggestion (code-quality):** We've found these issues:

- Replace call to format with f-string ([`use-fstring-for-formatting`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-fstring-for-formatting/))
- Low code quality found in OrderPayment.confirm - 13% ([`low-code-quality`](https://docs.sourcery.ai/Reference/Default-Rules/comments/low-code-quality/))

```suggestion
                    f'Payment {self.full_id} has already been confirmed.'
```

<br/><details><summary>Explanation</summary>
The quality score for this function is below the quality threshold of 25%.
This score is a combination of the method length, cognitive complexity and working memory.

How can you solve this?

It might be worth refactoring this function to make it shorter and more readable.

- Reduce the function length by extracting pieces of functionality out into
  their own functions. This is the most important thing you can do - ideally a
  function should be less than 10 lines.
- Reduce nesting, perhaps by introducing guard clauses to return early.
- Ensure that variables are tightly scoped, so that code using related concepts
  sits together within the function rather than being scattered.</details>
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a critical bug where duplicate payment confirmations returned None silently instead of raising an exception, making it impossible for callers to distinguish between successful confirmations and race condition scenarios. The fix introduces a new PaymentAlreadyConfirmedException that is raised when attempting to confirm an already-confirmed payment, and adds specific handling in the API layer to return HTTP 409 CONFLICT.

Key changes:

  • Added PaymentAlreadyConfirmedException as a subclass of PaymentException for semantic clarity
  • Modified OrderPayment.confirm() to raise the exception instead of silently returning
  • Updated API endpoint to catch the exception and return appropriate 409 CONFLICT status

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
app/eventyay/base/payment.py Defines new PaymentAlreadyConfirmedException exception class
app/eventyay/base/models/orders.py Changes silent return to exception raise when payment already confirmed
app/eventyay/api/views/order.py Imports new exception and handles it with 409 CONFLICT response

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- API consistency for 409 responses
@ArnavBallinCode
Copy link
Contributor Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • In OrderViewSet.confirm, PaymentAlreadyConfirmedException is referenced in the except block but never imported, which will raise a NameError at runtime; add the appropriate import or fully qualify the exception name.
  • The confirmed-payment check is now implemented both in the view (early 409 return) and in OrderPayment.confirm (raising PaymentAlreadyConfirmedException); consider consolidating this into the model logic only and letting the view rely on the exception to avoid duplicated state checks.
  • Since PaymentAlreadyConfirmedException is imported inside OrderPayment.confirm to work around circular imports, consider extracting shared payment exceptions to a small, dependency-light module so the model and payment.py can import it normally without runtime imports.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `OrderViewSet.confirm`, `PaymentAlreadyConfirmedException` is referenced in the `except` block but never imported, which will raise a `NameError` at runtime; add the appropriate import or fully qualify the exception name.
- The confirmed-payment check is now implemented both in the view (early 409 return) and in `OrderPayment.confirm` (raising `PaymentAlreadyConfirmedException`); consider consolidating this into the model logic only and letting the view rely on the exception to avoid duplicated state checks.
- Since `PaymentAlreadyConfirmedException` is imported inside `OrderPayment.confirm` to work around circular imports, consider extracting shared payment exceptions to a small, dependency-light module so the model and `payment.py` can import it normally without runtime imports.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copilot finished reviewing on behalf of ArnavBallinCode November 28, 2025 11:27
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

app/eventyay/api/views/order.py:1198

  • The create() method calls payment.confirm() but doesn't handle the new PaymentAlreadyConfirmedException. While this scenario may be unlikely (confirming a newly created payment), for consistency and to prevent unexpected errors, this exception should be caught here as well. Consider adding except PaymentAlreadyConfirmedException: pass or similar handling between lines 1194 and 1195.
            if mark_confirmed:
                try:
                    r.confirm(
                        user=self.request.user if self.request.user.is_authenticated else None,
                        auth=self.request.auth,
                        count_waitinglist=False,
                        force=request.data.get('force', False),
                        send_mail=send_mail,
                    )
                except Quota.QuotaExceededException:
                    pass
                except SendMailException:
                    pass

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

app/eventyay/api/views/order.py:1195

  • 'except' clause does nothing but pass and there is no explanatory comment.
                except PaymentAlreadyConfirmedException:

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@Sak1012
Copy link
Member

Sak1012 commented Nov 28, 2025

Please refer #1344 (comment)

@mariobehling mariobehling requested a review from Sak1012 November 28, 2025 23:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

Bug: Payment Duplicate Confirmation Returns Success

3 participants