Payments flow adaptation for lazy payments - #27
Conversation
3f44420 to
a4a28d4
Compare
a4a28d4 to
fe77fc8
Compare
fe77fc8 to
cb6922f
Compare
8cf6d1c to
2310455
Compare
|
Hey, @ManuelStarDo and @rguerra-fccn. You can start reviewing this PR, while I deal with the pipeline errors. Thanks. |
There was a problem hiding this comment.
The following are concrete code-level items worth addressing before merge:
-
paygate/pending_orders.py::confirm_pending_order— no guard against concurrent double-confirmation. If the lazy-resolution endpoint (in the nau_extensions PR) and the server-to-server callback both callconfirm_pending_orderfor the same order at nearly the same time, both could pass the "still Pending" check before either commits, and both would callsave_payment_details(order)/set_status(ORDER.OPEN), potentially double-recording aPaymentSource/PaymentEvent. There's noselect_for_update()or a re-check oforder.statusafter acquiring a lock.- Fix: add a lock/guard, e.g.
order = Order.objects.select_for_update().get(pk=order.pk); if order.status != ORDER.PENDING: return orderat the top ofconfirm_pending_order, run insidetransaction.atomic().
- Fix: add a lock/guard, e.g.
-
paygate/pending_orders.py::place_pending_order—basket.submit()is called without catching a possible double-submit if two requests race before either commits the newOrderrow (i.e., before theIntegrityErrorthat the caller inviews.pyalready handles). Ifbasket.submit()itself raises (Oscar generally rejects submitting an already-submitted basket) rather than theOrder.numberuniqueness constraint, that exception is notIntegrityErrorand falls into the view's genericexcept Exceptionbranch, which unconditionally redirects to the error page — even though the "losing" request's sibling actually succeeded.- Fix: in the view's generic
except Exceptionhandler (not just theIntegrityErrorbranch), also attemptget_order(basket)before deciding to show the error page, mirroring the recovery logic already used forIntegrityError.
- Fix: in the view's generic
-
paygate/utils.py— minor duplication betweenget_order()andorder_exist()
Risk:get_order(basket)(new in this PR) andorder_exist(basket)(pre-existing) both answer the same underlying question — "is there an Order for this basket?" — via two separateOrder.objects.filter(...)queries.order_exist()returns a bool and is still used inpaygate/processors.py::retry_baskets_payed_in_paygate();get_order()returns the object and is used inpaygate/views.py, which needs the actualOrder(e.g. to checkexisting_order.status). Neither is dead code, so nothing needs to be removed, but having two independent lookups for the same thing is a small maintainability smell — if the lookup logic ever needs to change (e.g. adding a filter), it's easy to update one and forget the other.
Fix: Rewriteorder_exist()to delegate toget_order()so there's a single source of truth:
def order_exist(basket: Basket) -> bool:
"""
Utility method that check if there is an Order for the Basket
"""
return get_order(basket) is not NonePossible Improvements / Suggestions
paygate/views.py::run_post_order: the docstring explaining why it's not namedhandle_post_order(to avoid shadowing the mixin's method) is good self-documentation; consider moving that explanation into the class-level docstring so it's visible alongside the other helper methods (create_new_order,handle_payment_and_create_order) for a reader scanning the class body.- The module docstring in
pending_orders.pyexplicitly flags the "nothing expires a Pending order" limitation as accepted for now — worth linking to or creating a follow-up issue if one doesn't already exist, so it doesn't get lost.
2310455 to
45d2b1b
Compare
|
@ManuelStarDo, I have applied the changes you requested. Ready to review. |
rguerra-fccn
left a comment
There was a problem hiding this comment.
Tested locally: 52/52 tests passing. The race handling (select_for_update), idempotency, and IntegrityError recovery in pending_orders.py/views.py are solid and well covered.
However, the success-callback fallback path (no thank_you_url configured) doesn't match the PR description's claim of 'falls back to the previous behaviour (synchronous fulfillment + redirect to the receipt page)'. In the current code, handle_payment_and_create_order is never called from PayGateCallbackSuccessResponseView.get() anymore, for any payment method, regardless of thank_you_url. It unconditionally places a Pending order (if none exists) and redirects — thank_you_url only changes the redirect destination, not whether synchronous fulfillment happens.
This is confirmed by your own test, test_success_callback_without_thank_you_url_uses_the_receipt_page:
self.assertEqual(Order.objects.get(number=basket.order_number).status, ORDER.PENDING)
mock__make_api_json_request.assert_not_called() # BackOfficeSearchTransactions never calledThis isn't just a transient window either: there's no nau-tutor-configs PR yet that sets thank_you_url in PAYMENT_PROCESSOR_CONFIG, so this will be the default production behaviour immediately after merge, for however long until a follow-up config PR lands. A paying card user could be redirected straight to the receipt page for an order that is still Pending at that exact moment, relying entirely on the server-to-server callback (which does correctly pick up and confirm the pending order) to land afterwards.
Could you either:
- Gate the pending-order-only path behind
thank_you_urlbeing configured, so the old synchronous path is genuinely preserved until the flag is turned on, or - Confirm the receipt page tolerates a momentarily-
Pendingorder gracefully, and update the PR description to state plainly that this is an intentional, unconditional change in reliance on the server-to-server callback.
Requesting changes pending that clarification — happy to re-review quickly once addressed.
ManuelStarDo
left a comment
There was a problem hiding this comment.
@Tiago-Salles
How do we handle payments that never get paid?
There should be a method to clean stale payments that never got paid.
Two points worth addressing:
[MEDIUM] paygate/views.py:364 — thank_you_url query-string append is naive string concatenation
Risk: If a misconfigured thank_you_url already contains a trailing ? or &, the redirect URL becomes malformed, potentially breaking the Thank-You page load.
Fix: Use urllib.parse.urlparse/urlencode (or at minimum .rstrip('?&') on thank_you_url before appending) for a more robust join.
[LOW / SUGGESTION] pending_orders.py — unresolved "Pending orders never expire" limitation
Risk: Over time, unpaid Multibanco baskets accumulate as permanently Pending orders with no automated cleanup, relying entirely on manual support intervention.
Fix: Confirm a tracking issue exists (referenced PR mentions nau-technical#923 — verify this specific limitation is captured there or file a dedicated follow-up) for a future management command to expire stale Pending orders using the already-scaffolded but commented-out REFMB_START_DATE/REFMB_END_DATE fields.
PayGate does not confirm every payment while the user is still in the browser. The Multibanco reference (REFMB) hands the learner an entity/reference pair that can be paid at an ATM days later. The success callback used to run handle_payment_and_create_order, which asks PayGate whether the transaction is complete. For an unpaid reference the answer is "not yet", so a GatewayError was raised and the learner was shown a payment error page even though nothing had gone wrong. The success callback now never takes payment. It places the order in the Pending status -- unpaid, unfulfilled, no PaymentSource, no PaymentEvent and no post_checkout signal, so the learner is not enrolled and nothing reaches the financial manager -- and redirects to the orders MFE thank-you page. The payment is confirmed later, either by the server-to-server callback when the reference is paid, or lazily from the Order History page. handle_payment_and_create_order now recognises an existing Pending order and confirms it, instead of bailing out with "the basket already has an order" and dropping the payment on the floor. Pending and Payment Error are not an invention of this plugin: upstream ecommerce already declares them and already allows the Pending -> (Open, Payment Error) transitions. Nothing shipped ever set them because OSCAR_INITIAL_ORDER_STATUS is Open. This fills in a slot upstream left open, which is what lets NAU support asynchronous payments without patching ecommerce itself. For a card payment the server callback lands at almost the same instant as the browser redirect. If it wins, our insert fails on the unique constraint on Order.number; that IntegrityError is caught and the existing order is picked up, so a learner who has just paid is never sent to the error page. thank_you_url is optional. Without it the success callback falls back to the receipt page, so a deployment that has not configured it does not regress. Known limitation, accepted deliberately: nothing expires a Pending order. An unpaid reference leaves a submitted basket and a Pending order in place indefinitely and the learner cannot retry without support. See the module docstring of paygate/pending_orders.py for what closing it would take. Related to: fccn/nau-technical#923
45d2b1b to
f695bd6
Compare
|
@ManuelStarDo, applied to suggestion. About the pending order, it's not a concern for this ticket, please don't mix subjects. This ticket (with the three PRs that implement it) has the intention of building the structure where payments are able to be handled in an asynchronous way, meaning the platform will not expect the confirmation right after it just created an order. I am about to create an issue to handle pending payments management subejct. Any specific MBREF business logic, or payments management related topics have nothing to do with the ability of a person paying a course asynchronously. |
Context
When the user clicks Continuar on PayGate, PayGate redirects back to
PayGateCallbackSuccessResponseView. Until now, this view calledhandle_payment_and_create_ordersynchronously, which in turn invokedPayGate.handle_processor_response. That method queries the PayGateBackOfficeSearchTransactionsAPI expecting to find a completed transaction for the basket.For asynchronous payment methods (MB references, MBWAY) the upstream payment is not yet confirmed at this exact moment — the user still has to pay at an ATM / home-banking / phone. PayGate correctly returns an empty list,
handle_processor_responseraises:…and the user is shown the misleading "You have not been charged." page even when, minutes later, PayGate will confirm the payment via the server-to-server callback.
What this PR changes
The success callback no longer attempts to fulfil the order synchronously when a Thank-You URL is configured. Instead, it records the callback response and redirects the user to a Thank-You page in the ecommerce micro-frontend, where the actual payment status is lazily resolved per basket by the new
nau_extensions.BasketPaymentStatusView.PayGatenow exposes a newthank_you_urlproperty that reads the optionalthank_you_urlpayment-processor configuration entry. ReturnsNonewhen not configured.PayGateCallbackSuccessResponseView.get:thank_you_urlis configured → redirects to<thank_you_url>?order_number=<basket.order_number>without callinghandle_payment_and_create_orderand without calling PayGateBackOfficeSearchTransactions;thank_you_urlis not configured → falls back to the previous behaviour (synchronous fulfillment + redirect to the receipt page). This preserves backwards compatibility for any deployment that has not yet rolled out the Thank-You page.Configuration
Add
thank_you_urlto thepaygateentry ofPAYMENT_PROCESSOR_CONFIGto enable the new behaviour. Example:Related PRs
Related to: https://github.com/fccn/nau-technical/issues/923