feat: UPI payment_intent.succeeded webhook - #557
Conversation
Implement end-to-end CT order finalization from Stripe PaymentIntents: - Add CT client methods: get_cart_by_id, create_charge_payment_transaction - Add shared finalize_ct_order_from_stripe_pi module with full parity to customer-twou finalizeStripePayment (charge txn, order creation, line item state transition, Segment Order Completed plan 18, PI metadata backfill) - Add Celery task with bounded retries (CommercetoolsError, max=5, countdown=3) and quarantine log+metric on exhaustion - Add payment_succeeded_commercetools_signal + CC_SIGNALS wiring - Extend WebhookView: route source_system=commercetools to CT signal with SingleInvocation on payment_intent.id; leave legacy + refund paths unchanged - Add recovery management command recover_orphaned_stripe_commercetools_payments with --since, --limit, --dry-run; Stripe Search with list+filter fallback - Quarantine contract: structured log with pi_id, ct_payment_id, ct_cart_id, reason, source fields - 34 new tests covering finalize happy path, charge/order skip, error paths, webhook routing, task quarantine, recovery command dry-run/finalize/fallback - All 140 tests (106 existing + 34 new) pass with zero regressions Co-authored-by: Cursor <cursoragent@cursor.com>
Backfill Stripe order_id when an order already exists so recovery converges; narrow order-lookup exceptions to ValueError; add CT-secondary orphan discovery, quarantine field population, Stripe retries, and missing tests. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Pull request overview
This PR makes Stripe payment_intent.succeeded the authoritative async finalization path for CommerceTools-originated (UPI) checkouts when learners don’t return to /payment-return, and adds an orphan-recovery command to backfill missed finalizations.
Changes:
- Extends Stripe webhook routing to emit a new CT-specific “payment succeeded” signal for
source_system=commercetools, dispatching a Celery finalize task. - Introduces shared finalization logic to create/verify CT charge transaction, create CT order from cart, transition line items, emit Segment “Order Completed”, and backfill Stripe PI metadata.
- Adds a management command to discover and recover orphaned Stripe/CT payments via Stripe-primary + CT-secondary discovery, with quarantine logging.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| commerce_coordinator/settings/base.py | Wires new Stripe→CT finalize signal receiver. |
| commerce_coordinator/apps/stripe/views.py | Adds CT webhook routing for payment_intent.succeeded and refactors refund routing. |
| commerce_coordinator/apps/stripe/tests/test_views.py | Adds unit coverage for CT webhook routing and SingleInvocation behavior. |
| commerce_coordinator/apps/stripe/signals.py | Adds new payment_succeeded_commercetools_signal. |
| commerce_coordinator/apps/commercetools/signals.py | Adds receiver that dispatches the CT Stripe finalize Celery task. |
| commerce_coordinator/apps/commercetools/tasks.py | Adds finalize task + structured quarantine logging utilities. |
| commerce_coordinator/apps/commercetools/stripe_payment_finalize.py | Implements shared CT order finalization from Stripe PaymentIntents. |
| commerce_coordinator/apps/commercetools/clients.py | Adds CT cart fetch + charge transaction helper; changes not-found order lookup to ValueError. |
| commerce_coordinator/apps/commercetools/management/commands/recover_orphaned_stripe_commercetools_payments.py | Adds orphan discovery + finalize recovery command. |
| commerce_coordinator/apps/commercetools/tests/test_stripe_payment_finalize.py | Adds unit tests for shared finalize flow and metadata healing. |
| commerce_coordinator/apps/commercetools/tests/test_recovery_command.py | Adds unit tests for recovery discovery, dry-run, finalize, and quarantine behavior. |
| commerce_coordinator/apps/commercetools/tests/test_finalize_task.py | Adds unit tests for finalize Celery task behavior and quarantine handling. |
| commerce_coordinator/apps/commercetools/tests/test_clients.py | Updates order-lookup exception type test + adds charge transaction test. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Merge PI metadata on backfill, only fall back CT payment lookup on ResourceNotFound, set Celery max_retries on the task decorator, and use event.id when refund idempotency_key is missing. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (2)
commerce_coordinator/apps/commercetools/stripe_payment_finalize.py:248
- Deriving from_state_id via
order.line_items[0].state[0].state.idcan raise IndexError if the order has no line items or the line-item state list is empty, and it also implicitly assumes all line items share the same current state. Since the desired transition is explicitly from the built-inInitialfulfillment state, consider looking up that state ID directly (viaTwoUKeys.INITIAL_FULFILMENT_STATE) and using it asfrom_state_idinstead of indexing into the order object.
order_version=order.version,
line_items=order.line_items,
from_state_id=order.line_items[0].state[0].state.id,
new_state_key=TwoUKeys.PENDING_FULFILMENT_STATE,
use_state_id=True,
commerce_coordinator/apps/commercetools/management/commands/recover_orphaned_stripe_commercetools_payments.py:103
meta = self._pi_metadata(pi_id)performs an extra Stripe API call for every orphan, even thoughfinalize_ct_order_from_stripe_piwill immediately retrieve the PaymentIntent again. This doubles Stripe traffic for the recovery job and increases runtime/rate-limit risk. Consider fetching PI metadata lazily only inside the quarantine/exception paths (where you need it for logging), and skipping the metadata fetch on the happy path.
for pi_id in orphans:
meta = self._pi_metadata(pi_id)
try:
result = finalize_ct_order_from_stripe_pi(
pi_id, source="recovery", client=self.ct_api_client,
rv-mromero
left a comment
There was a problem hiding this comment.
Request changes
Architecture matches EDUN-15452: Stripe-signed payment_intent.succeeded for source_system=commercetools, shared CT finalize, recovery command, legacy/refund paths left intact. Copilot's earlier comments (metadata merge, ResourceNotFound-only fallback, max_retries on the decorator, refund event.id) are addressed. CI is green.
The gap is partial success: if the order is created and a later step fails, retry/recovery will not finish fulfillment.
Blocker: existing-order path skips fulfillment
finalize_ct_order_from_stripe_pi creates the order, then transitions line items to PENDING_FULFILMENT, then emits Segment, then backfills PI metadata. If anything after create_order_from_cart fails (CT blip, empty line_items/state, worker kill), the next run hits get_order_by_payment_id and returns already_existed=True without retrying the transition.
Same-cart double-create is naturally blocked by CommerceTools (one order per cart), so this is not the EDUN-15347 duplicate-order case. It is paid + order exists + line items still Initial, so enrollment may never start. Recovery will keep treating it as "already existed" and skip.
Fix: on the existing-order path, still transition line items that are not already pending (use TwoUKeys.INITIAL_FULFILMENT_STATE as from_state, not line_items[0].state[0]). Keep Segment best-effort if you want parity, but the state heal is the one that unblocks learners.
Medium
- Celery retries vs SingleInvocation TTL. New task:
max_retries=5,countdown=3(~15s). Fulfillment tasks in this file wait 5 minutes between retries. WebhookSingleInvocationcache TTL is 10 minutes, so Stripe's 5-minute retry is dropped; the next real webhook retry is typically ~1 hour. Recovery is the real backstop — do not treat merge as launch-ready until the cron in the PR checklist is actually scheduled. - Recovery Search fallback can scan the whole Stripe account. Confirm the Search query in stage (docs show double quotes). If Search rejects the query,
_list_filter_stripe_orphanspages every PI since--sinceuntil it finds--limitorphans. Cap pages/PIs examined. - Recovery quarantines transients immediately. Webhook Celery retries CT/Stripe errors; the management command logs
[quarantine]on the firstCommercetoolsError. Next cron will retry, but a noisy New Relic alert on that contract will page during blips. Either retry a few times in-command, or don't quarantine retryable errors. - No lock around finalize. Webhook + recovery can both enter create-order. CT will reject the second cart→order, which is fine, but concurrent Charge adds can still duplicate
interactionIds. A shortacquire_task_lockon PI id (same pattern asfulfillment_completed_update_ct_line_item_task) would serialize this ticket's own writers. EDUN-15347 still owns browser+webhook analytics duplication.
Low / nits
get_cart_by_idhas nohandle_commercetools_errorwrapper (unlikecreate_order_from_cart).- Extra Stripe
PaymentIntent.retrieveper orphan in recovery before finalize (Copilot); fetch metadata only on quarantine. - Coverage: new finalize ~74%, recovery command ~76%; missing task retry-exhaustion and empty line-item paths.
- Drive-by: deleted the
SONIC-898IP-allowlist TODO onWebhookView. Unrelated; keep it or replace with a ticket.
What looks solid
- Signature check on raw body; CT vs legacy
source_systemsplit; unknown sources no-op 200. - Charge txn skipped when
interaction_idalready matches; CT outage on order lookup is not treated as not-found. - PI metadata backfill merges existing keys (
source_system,ct_cart_id). - Refund SingleInvocation falls back to
event.idwhenidempotency_keyis null. - Segment payload: plan 18,
is_mobile=False,payment_methodfrom CT (upiin tests), totals from USD cart. - Tests cover routing, Charge skip, metadata heal,
ResourceNotFoundvs transient, recovery dry-run / fallback / CT-secondary.
Launch still depends on ops, even after the code fix: Stripe Dashboard subscribed to payment_intent.succeeded, recovery cron, quarantine alert. Stage smokes in the PR body are still unchecked.
Address Marilyn's review: transition Initial line items on the already-existed path, lock finalize on PI id, cap list+filter scans, and stop quarantining retryable CT/Stripe errors in recovery. Co-authored-by: Cursor <cursoragent@cursor.com>
Combine paymentInterface and createdAt filters with and so the call matches existing payments.query string usage. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
commerce_coordinator/apps/commercetools/management/commands/recover_orphaned_stripe_commercetools_payments.py:31
- This command imports
_log_quarantinefrom commercetools.tasks even though the leading underscore marks it as private/internal. Since this is now part of the recovery command’s stable behavior, consider making it a public helper (e.g.,log_quarantine) or moving it to a shared module to avoid accidental breaking changes/renames in tasks.py.
from commerce_coordinator.apps.commercetools.stripe_payment_finalize import (
FinalizeError,
FinalizeInProgressError,
finalize_ct_order_from_stripe_pi
)
from commerce_coordinator.apps.commercetools.tasks import _log_quarantine
Default 60s is too short for Stripe + CT + Segment; match the commercetools views lock expiry so concurrent writers cannot overlap. Co-authored-by: Cursor <cursoragent@cursor.com>
Treat paid-without-enrollment as a support edge case instead of a scheduled job. Cut the PI lock from 30 minutes to 5 minutes so a crashed worker does not block retries. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (1)
commerce_coordinator/apps/commercetools/tasks.py:595
- The PR description says it adds a recovery management command
recover_orphaned_stripe_commercetools_payments(plus unit tests), but that command doesn’t appear to exist anywhere in this change set (repo-wide search returns no matches). If orphan recovery is part of EDUN-15452’s acceptance criteria, the command (and its tests) likely still needs to be added here; otherwise please update the PR description/test plan to reflect what’s actually included.
"""
Celery task wrapping the shared finalize path for a Stripe
PaymentIntent that originated from a CommerceTools cart.
Bounded retries on transient CT/Stripe errors; non-retryable failures
are quarantined via structured log.
"""
…ook-orphan-recovery
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
commerce_coordinator/apps/stripe/views.py:84
- Same Enum-vs-string mismatch here:
event.typeneeds to be compared toStripeEventType.PAYMENT_SUCCESS.valueor the CT finalize path will never run.
if event.type != StripeEventType.PAYMENT_SUCCESS:
event.type is a plain string; match StripeEventType.*.value so routing does not miss payment_intent and refund events. Co-authored-by: Cursor <cursoragent@cursor.com>
|
without the recovery command is the celery timeout the right length? not sure if we wanted to adjust it to be closer to the fulfillment CT update task (longer in case of a CT outage). approving either way 👍 |
Match fulfillment CT updates so a Commercetools outage can recover without orphan recovery; the webhook already ACKs Stripe immediately. Co-authored-by: Cursor <cursoragent@cursor.com>
send_robust can swallow a Celery broker error while still ACKing Stripe and leaving the 10-minute running flag set. Raise so Stripe retries and SingleInvocation clears the flag. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (1)
commerce_coordinator/apps/stripe/exceptions.py:27
- The default_detail for this 503 is a bit generic; this exception is specifically raised when the CommerceTools finalize path fails to enqueue from the webhook. Making the message explicit will help ops triage (webhook dispatch vs Stripe API failures).
class StripeWebhookDispatchAPIError(APIException):
status_code = 503
default_detail = 'Failed to enqueue Stripe webhook handler.'
default_code = 'stripe_webhook_dispatch_error'
Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
payment_intent.succeededthe authoritative async path for CommerceTools order finalization when UPI learners never return to/payment-return.source_system=commercetools, writing CT Payment/Order/fulfillment state and SegmentOrder Completeddirectly (parity with customer-twou finalize from EDUN-15346).edx/commerce_coordinator?v=1success path andcharge.refundedhandling remain unchanged.What Was Built
WebhookViewpayment_intent.succeededbranch with signature verification +SingleInvocationon PI id; firespayment_succeeded_commercetools_signal. Event types compared toStripeEventType.*.value. If enqueue fails (send_robustreceiver error), return 503 so Stripe retries andhandle_exceptionclears the running flag (avoids ACK + 10-minute suppress). Refund SingleInvocation falls back toevent.idwhenidempotency_keyis null.interactionId) → Order from cart → pending fulfillment (fromInitialby key) → Segment plan 18 (is_mobile=False,payment_method=upi) → PI metadata backfill (order_id,ct_payment_id) merged with existing keys. Existing-order path still healsPENDING_FULFILMENT+ metadata.get_cart_by_id(withhandle_commercetools_error),create_charge_payment_transaction;get_order_by_payment_idraisesValueErrorwhen not found. Payment lookup by metadata id falls back to key only onResourceNotFound.finally). Celerymax_retries=5with 300s countdown (same outage window asfulfillment_completed_update_ct_line_item_task); webhook ACKs Stripe only after successful enqueue, so Celery is the automated retry path for CT/Stripe errors. Structured quarantine logs for non-retryable failures.Test Plan
ResourceNotFoundvs transient, Segment props includingpayment_method=upi, lock contention)/payment-return→ CT Order + Charge + pending fulfillment + PI metadata + SegmentActivation Checklist (ops)
payment_intent.succeededin stage then prod (owner / verified date). URL:https://commerce-coordinator.<env>.edx.org/stripe/webhook/NFR / Registration
Links