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.
45d2b1b to
f695bd6
Compare
|
@ManuelStarDo, applied the 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. |
f695bd6 to
ed73316
Compare
|
@Tiago-Salles Going back on topic, this PR is still not ready to merge. Please address @rguerra-fccn 's concern above. |
ed73316 to
c792c16
Compare
|
Hey @ManuelStarDo, please approave if it meets your suggestions. @rguerra-fccn, I have applied the changes you suggested, we are good to merge. |
There was a problem hiding this comment.
Hello @Tiago-Salles ,
The main problem Rui raised was not addressed, you can see more details below on the CRITICAL finding. Besides that, I have identified some other issues that should be fixed before merging.
Findings:
-
[CRITICAL]
paygate/views.py—get_or_place_pending_orderruns unconditionally wheneverthank_you_urlis configured, for every payment method, not just asynchronous ones. Oncethank_you_urlis set (which the PR description implies will happen immediately post-merge for all NAU deployments, since it's the whole point of the change), a card/MB WAY payer landing on the success callback will get aPendingorder placed and be redirected to the Thank-You page withouthandle_processor_response/handle_paymentever being invoked from this view. Fulfilment for card payments then depends entirely on the server-to-server callback landing and callingconfirm_pending_orderafterward. I confirmed this by tracingget(): whenthank_you_urlis truthy, the only path isget_or_place_pending_order→ redirect;fulfil_synchronously(which is the only caller ofhandle_payment_and_create_orderfrom this view) is only reached in thenot thank_you_urlbranch. This directly contradicts the PR description's claim ("Card and MB WAY payments are unaffected... immediate enrolment") and issue #923's explicit acceptance criterion. This exact gap was already raised by rguerra-fccn's review (2026-08-31) and does not appear to have been resolved in the diff I fetched — no subsequent commit changes this branching logic.- Risk: If the server-to-server callback is delayed, dropped, or fails (network blip, PayGate outage, IP allowlist misconfiguration), a card payer who has genuinely and instantly paid will sit in
Pendingindefinitely with no enrolment and no receipt, and no lazy-resolution safety net analogous to what asynchronous payments get from the Order History page (since the whole premise of "lazy" resolution is that the user visits their orders — for a card payer landing on Thank-You, nothing prompts them to check status until the companionnau_extensionsview is hit, which is outside this repo's control). - Fix: Gate
get_or_place_pending_order's pending-order path specifically to asynchronous payment types, or alternatively still callhandle_processor_responsesynchronously for card/MB WAY on the success callback (distinguish based on the callback payload'spayment_type_code, which is already available in the recorded response) before falling back to placing aPendingorder only for REFMB. As proposed by rguerra-fccn: confirm this is deliberate and document it plainly, or fix the gating.
- Risk: If the server-to-server callback is delayed, dropped, or fails (network blip, PayGate outage, IP allowlist misconfiguration), a card payer who has genuinely and instantly paid will sit in
-
[HIGH]
paygate/views.py— the class docstring forPayGateCallbackSuccessResponseViewstill says the previous "synchronous fulfilment + redirect to receipt page" is preserved "untilthank_you_urlis configured", but as shown above this is only true for thethank_you_urlunset branch; the moment it's set, no deployment gets synchronous fulfilment for any payment type. The docstring should be corrected to avoid misleading future maintainers, independent of whichever direction the CRITICAL fix above takes. -
[LOW]
paygate/views.py::get_or_place_pending_order— theexcept Exceptionbranch (catching non-IntegrityErrorplacement failures, e.g.basket.submit()rejecting an already-submitted basket) re-fetches the order and treats any found order as success. This is good, but the logging there islogger.exceptionand would be more actionable if it also logged the payment type/basket total, since a silent swallow-and-recover pattern here can otherwise be hard to triage in production without reproducing the race.
Possible Improvements
pending_orders.py::confirm_pending_ordercatches a bareexcept Exceptionaroundhandle_post_order(mirroring the existing pattern inviews.py::run_post_order) — consider centralizing this "log and swallow post-order errors" logic into a shared helper since it's now duplicated acrosspending_orders.pyandviews.py, to avoid future drift between the two call sites.
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
c792c16 to
1e5e37d
Compare
|
Thanks @ManuelStarDo — the [HIGH] and [LOW] are done. On the [CRITICAL] I disagree with the proposed fix, but since it makes sense I have applied a fix. Let me separate the two. On routing by payment type — deliberate, and I'd like to keep it. Uniform routing is the intent of the change, not an oversight. On every platform I've paid on, the redirect after the provider is a "thank you for your order" page for every method; what differs is how fast the confirmation then arrives, not where you land. An immediate payment isn't routed differently, it's just confirmed quickly. Branching the success callback on On the underlying risk — I've fixed it where it actually lives. The scenario you describe (S2S callback delayed, dropped, or blocked by an IP allowlist mistake) is real, but it isn't specific to cards, and the safety net for it was never the Order History page. It's What I'd missed, and what your review made me go and check, is that this PR silently broke that net: if order_exist(basket):
logger.info("Order already exists for payment_ref=%s", payment_ref)
continueUnder the new flow every basket that comes back through the success callback has an order — a Fixed in order = get_order(basket)
if order and order.status != ORDER.PENDING:
continueTwo tests cover it: the callback is re-fired for a basket whose only order is Net effect: a dropped S2S callback is now recovered automatically by the next cron run, with no user action, for all payment types — a stronger guarantee than the per-type branch, which would still have left REFMB dependent on the learner opening Order History. A [HIGH] — done. The [LOW] — done. That On the shared post-order helper: fair point, the pattern is genuinely duplicated between Two corrections for the record, since they affect how the findings read:
|
|
@ManuelStarDo, remmember there is a bug with MBREF in PROD, and these three PR's, despite of adding a new feature, are highly focused on fixing this bug. These three are not inteded to be the final solution, and the goal is mainly testing on DEV to identify the potential bugs this enviroment can raise on running it. I strongly suggest you to focus on what really matters on this implementation, and gather any other not related concern on other tickets. For instance, you have mentioned a bug on the path of payments with card. We don't have payments with card, neither a clean deadline to serve this method, but we still have a real bug on PROD with MBREF. I thank you for focusing your review on points that break the flow of what we currently serve, and gather any other not related concern on their dedicated tickets. This is a new path being created on the project, and for sure will bring new challenges, but for them to be truly identified we need to test in a real enviroments. Thanks. |
|
@Tiago-Salles The raised concern was not only for card payments, although they were also mentioned, they apply to MBway as well. Here is a sample worst case scenario that might occur: And the current 3 Path ways this can be solved: And Path A should solve the issue, but there are edge cases where it fails: Previously there was a fallback to this: And with the PR changes it goes like this: The browser-return path's own ability to fulfill immediately has been removed for every payment type, not just the async ones that needed it removed. Before, the browser-return leg gave synchronous methods a second, independent, immediate shot. Now that redundancy is gone across the board, and everything collapses onto the same single dependency (S2S, then cron as backup). ConclusionMy real concern is that a user will make an MBWay payment, see a Thank You page for their payment and then NOT be enrolled in the course. The user then has a valid reason to create a ticket for this, not knowing that the cron will pick up their order later. My questions for you: |
|
@ManuelStarDo thanks, I disagree on what the fix should be and on where the discussion belongs, so let me answer your three questions. 1. Should the browser-return path try No, and this is the core design decision of the PR rather than a side effect of it. The path that creates an order should never be the path that validates it. The browser return is a user redirect: it proves the learner pressed "Continuar", nothing more. Using it to query PayGate and fulfil in the same request is exactly the coupling that produced the "you have not been charged" bug, because it assumes the world is consistent at the instant the learner lands back. Adding it back "just once, for fast methods" keeps that assumption alive for every method where it happens to hold today, and moves the boundary of the bug instead of removing it. The way this is meant to work, and what the PR sets up, is a payments flow in separate steps: a basket, then a MBWAY illustrates this rather than contradicting it. PayGate holds the payer on a countdown and only returns to our success URL after the approval, so by the time the browser leg runs the server callback has normally already fired and the order is already 2. Does the current design prevent a learner from seeing "Thank you" and not being enrolled? The confirmation flow is not the subject of this PR. This PR changes one thing: the success callback stops fulfilling synchronously and places a 3. Stating the worst-case bound in the PR description. The bound is a property of the confirmation flow, not of this change, and it will be defined in its dedicated ticket, where the schedule is actually decided. I can't see why describing this on the PR really matters for the purpose it has of validating on DEV... On urgencyMBREF is disabled in production precisely because the current implementation breaks it. We are offering a payment method we cannot serve, and we cannot even run it as a controlled bug, because it fails the whole execution for the learner. That seems urgent to me. This PR is what makes it possible to enable MBREF at all, and the goal of these three PRs is to get the flow running in DEV and find the problems that only show up against a real PayGate. |
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