feat: reconcile async Stripe refunds - #561
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
Persist CT REFUNDED before LMS/Segment, emit Order Refunded with a stable Stripe refund message_id, and autoretry reconciler errors on the forward return task. Co-authored-by: Cursor <cursoragent@cursor.com>
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
Adds first-class support for async Stripe refund lifecycles (including pending) for CommerceTools-backed orders by introducing a shared refund reconciler and extending webhook + forward-task flows to use it, so CT state and downstream side effects occur only once refunds are terminal.
Changes:
- Extend Stripe webhook routing to handle
refund.updated/refund.failed(Refund object events) and serialize processing by Stripe Refund ID. - Introduce
stripe_refund_reconcileto persist/transition CT refund transactions (PENDING → SUCCESS/FAILURE) and gate terminal side effects (return state, LMS revoke, Segment). - Update forward “order returned” task + pipeline logic to defer terminal actions for
pendingand delegate terminal reconciliation to the shared reconciler, with corresponding unit tests.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| commerce_coordinator/apps/stripe/views.py | Routes additional refund webhook event types and introduces refund-ID locking before dispatch. |
| commerce_coordinator/apps/stripe/tests/test_views.py | Adds coverage for refund object events, lock contention, and dispatch failure behavior. |
| commerce_coordinator/apps/stripe/tests/test_clients.py | Adds test coverage for pending refund responses. |
| commerce_coordinator/apps/stripe/constants.py | Adds new Stripe event types and refund statuses. |
| commerce_coordinator/apps/stripe/clients.py | Accepts pending refunds as a valid initiation result. |
| commerce_coordinator/apps/commercetools/utils.py | Updates “full refund” detection and adds lookup by refund interaction ID. |
| commerce_coordinator/apps/commercetools/tests/test_utils.py | Adds tests for pending refund not counting as fully refunded + canceled mapping. |
| commerce_coordinator/apps/commercetools/tests/test_tasks.py | Adjusts refund task tests to assert reconciler delegation. |
| commerce_coordinator/apps/commercetools/tests/test_stripe_refund_reconcile.py | New reconciler test suite covering state transitions and side-effect gating. |
| commerce_coordinator/apps/commercetools/tests/test_pipeline.py | Verifies pending Stripe refunds don’t transition CT returns to REFUNDED. |
| commerce_coordinator/apps/commercetools/tests/test_clients.py | Adds test for changing CT refund transaction state. |
| commerce_coordinator/apps/commercetools/tests/sub_messages/test_tasks.py | Updates forward-task tests to integrate reconciler and pending behavior. |
| commerce_coordinator/apps/commercetools/tasks.py | Delegates refund task behavior to the shared reconciler and expands autoretry conditions. |
| commerce_coordinator/apps/commercetools/sub_messages/tasks.py | Updates forward flow to defer pending and delegate succeeded Stripe refunds to reconciler. |
| commerce_coordinator/apps/commercetools/stripe_refund_reconcile.py | New shared reconciler implementing CT transaction persistence/state machine + terminal side effects. |
| commerce_coordinator/apps/commercetools/pipeline.py | Introduces “refund_pending” signal and defers CT return state transition for async Stripe refunds. |
| commerce_coordinator/apps/commercetools/clients.py | Adds CT API helper to change refund transaction state. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Reject refund events without Stripe IDs, compare raw refund status values explicitly, and cache per-refund side-effect completion so retries heal partial work without replaying completed effects.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
commerce_coordinator/apps/commercetools/stripe_refund_reconcile.py:120
_update_return_state()indexesstripe_refund["payment_intent"], but the Refund TypedDict markspayment_intentas optional and some Stripe refund payloads may omit it. That will raiseKeyErrorduring reconciliation and prevent return/payment updates and side effects.
refund_id = stripe_refund["id"]
payment = client.get_payment_by_key(payment_intent_id)
transaction = get_refund_transaction_by_interaction_id(payment, refund_id)
commerce_coordinator/apps/stripe/views.py:236
- The webhook handler acquires
refund_reconcile_lock_key(refund_id)and the Celery worker later callsreconcile_stripe_refund(), which acquires the same lock key again. If the task starts executing before the webhook releases the lock (possible on fast/low-latency queues), the worker will hitRefundReconcileInProgressErrorand autoretry, adding avoidable latency and retry noise.
lock_key = refund_reconcile_lock_key(stripe_refund['id'])
if not acquire_task_lock(lock_key, REFUND_RECONCILE_LOCK_EXPIRE):
logger.warning(
'[Stripe webhooks] refund %s is already reconciling; returning retryable failure',
stripe_refund['id'],
)
raise StripeWebhookDispatchAPIError
…350) Use the reconciler payment_intent_id when Stripe omits it on the Refund object, and enqueue webhook work without holding the worker lock so Celery does not immediately retry.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
commerce_coordinator/apps/commercetools/tasks.py:152
- The refund_from_stripe_task docstring still says it only creates a CT refund transaction, but the task now delegates to reconcile_stripe_refund (which can also update Return state and trigger LMS/Segment side effects on confirmed success). Updating the docstring will help avoid misuse/incorrect assumptions by future callers.
"""
Celery task for handling a refund registered in the Stripe dashboard.
Creates a refund payment transaction record via the Commercetools API.
Args:
commerce_coordinator/apps/commercetools/stripe_refund_reconcile.py:391
- _emit_segment_refund can return without emitting anything when products cannot be resolved (properties["products"] is empty), but reconcile_stripe_refund will still mark the Segment side effect as completed. That can permanently suppress the "Order Refunded" event for that refund. Consider failing fast (or otherwise signaling failure) when no products can be built so the caller doesn’t mark completion incorrectly.
if properties["products"]:
properties["title"] = ", ".join(
item.name["en-US"] for item in selected_line_items
)
Treat Commercetools ResourceNotFound as a non-CT refund and return 200 so legacy Stripe refunds do not retry forever; re-raise other CT errors. Drop the brittle inspect-based webhook lock assertion in favor of behavior tests. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.
Suppressed comments (2)
commerce_coordinator/apps/stripe/tests/test_views.py:519
- This test asserts an implementation detail via
inspect.getsource(...), which is brittle (whitespace/refactors can break it) and doesn't actually validate runtime behavior. Prefer patchingacquire_task_lockduring the request and asserting it was not called.
self.mock_stripe_event.id = "evt_unlocked"
self.mock_stripe_event.type = StripeEventType.REFUND_UPDATED.value
self.mock_stripe_event.data.object = refund
mock_construct_event.return_value = self.mock_stripe_event
commerce_coordinator/apps/commercetools/tasks.py:152
refund_from_stripe_tasknow delegates toreconcile_stripe_refund(which can update return state and trigger side effects), but the docstring still says it only creates a refund transaction and its Args list no longer matches the function signature.
"""
Celery task for handling a refund registered in the Stripe dashboard.
Creates a refund payment transaction record via the Commercetools API.
Args:
…g (EDUN-15350) Raise RefundSideEffectDispatchError if Order Refunded cannot include products so the side-effect cache is not marked complete. Update refund_from_stripe_task docs to describe full reconciliation, not only CT transaction creation. Co-authored-by: Cursor <cursoragent@cursor.com>
Patch acquire_task_lock in both the core tasks and reconciler modules and assert it is never called during the webhook request, replacing the source-inspection assertion with a behavioral check. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
commerce_coordinator/apps/stripe/clients.py:313
logger.exception(...)is used here without an active exception, which will log an unnecessary stack trace (oftenNoneType: None). Uselogger.error/warninginstead so unsuccessful-but-expected statuses (e.g.,failed,canceled) don’t look like unhandled exceptions in logs.
logger.exception('Refund for order [%s] was unsuccessful', order_uuid)
Use warning for expected non-success refund statuses instead of exception so failed/canceled do not look like unhandled errors. Compute Segment is_bundle from refunded line items only so sibling bundle items do not change product_id. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
commerce_coordinator/apps/commercetools/tasks.py:139
reconcile_stripe_refund()callsCommercetoolsAPIClient.update_return_payment_state_after_successful_refund(), which wrapsCommercetoolsErrorintoopenedx_filters.exceptions.OpenEdxFilterException(seecommerce_coordinator/apps/commercetools/clients.py:866-869). Because this task’sautoretry_fordoes not includeOpenEdxFilterException, transient CT write failures during return updates will not be retried, and Stripe will not retry because the webhook already acked after enqueueing the task.
autoretry_for=(
CommercetoolsError,
RefundReconcileInProgressError,
RefundSideEffectDispatchError,
),
commerce_coordinator/apps/stripe/views.py:202
refund.updated/refund.failedevents assumeevent_object.payment_intentis present. If it is missing/None,get_payment_by_key(payment_intent_id)will raise (it callsre.subon the key), causing a 5xx and repeated Stripe retries. Since the reconciler already supports refund payloads that omitpayment_intent, the webhook handler should validate it before calling CommerceTools and fail fast with a 400 (or explicitly skip) instead of raising a TypeError.
stripe_refund = dict(event_object)
payment_intent_id = event_object.payment_intent
client = CommercetoolsAPIClient()
try:
payment = client.get_payment_by_key(payment_intent_id)
Ack refund.updated/failed with no payment_intent so Stripe does not retry a lookup that cannot succeed. Log dispatch failures as refund reconcile vs CT finalize, and retry OpenEdxFilterException on both Stripe and CT-return tasks. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
commerce-coordinator.#8).pendingis a valid refund initiation: persist a CommerceTools Refund transaction inPENDING, leave returnInitial, and do not revoke LMS or emitOrder Refundeduntil Stripe confirms success.stripe_refund_reconcile) finalizesrefund.updated/refund.failed/charge.refundedand forward-pathsucceededwith Stripe Refund ID as the identity.canceledmaps likefailed.stripe_payment_finalize.py(EDUN-15347 pay-in path stays independent).What Was Built
refund_payment_intentacceptssucceededandpending; other statuses still fail.WebhookViewhandlesrefund.updatedandrefund.failed(Refund object). HTTP single-invocation uses Stripe Event ID; refund-ID lock serializes work.stripe_refund_reconcile.py: PENDING persist, PENDING→SUCCESS/FAILURE change-state, Dashboard path with no CT Return updates payment only.REFUNDEDbefore LMS revoke and Segment. SegmentOrder Refundedusesmessage_id= Stripe Refund ID. Failure →FAILURE/NotRefunded, no revoke/analytics.succeededuses the same reconciler; Celery autoretries lock/dispatch errors. Pending still skips terminal side effects.Deploy Verification
Not applicable (no CDK / CloudFormation). Activation still requires Stripe Dashboard allowlist of
refund.updatedandrefund.failedon the coordinator webhook endpoint (ops; not in this PR).Test Plan
py312-django42)refund.updatedsucceeded → Refunded + revoke + one Segment eventrefund.failed/ canceled → FAILURE + NotRefunded + access preservedOrder Refunded(stable Segmentmessage_id)refund.updatedandrefund.failedin the target envNFR Compliance
N/A for this Open edX Django IDA (no new public EDU API / OpenAPI / EDU-Events schema).
Registration Compliance
N/A (no service-discovery, M2M, or QAaS registration changes).