Skip to content

Release 0.199.0 - #4099

Merged
odlbot merged 6 commits into
releasefrom
release-candidate
Oct 5, 2026
Merged

odlbot merged 6 commits into
releasefrom
release-candidate

Conversation

@odlbot

@odlbot odlbot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

renovate[bot]

pre-commit-ci[bot]

Arslan Ashraf

Muhammad Anas

Anas12091101 and others added 6 commits September 11, 2026 18:02
* feat(ecommerce): add Stripe as a second payment gateway

Adds a Stripe checkout path alongside CyberSource, picked per user by a
PostHog flag so it can be rolled out gradually. CyberSource stays the
default and remains untouched as the fallback; B2B is unaffected.

Adopting `mitol-django-payment-gateway` means the library owns everything
gateway-specific. `CheckoutView` keeps returning the same
`{payload, url, method}` contract, so the frontend redirect path is
unchanged.

The substantive difference from CyberSource is that fulfilment becomes
asynchronous. Stripe returns the learner immediately and sends nothing to
that page, so:

- `Order.gateway_type` records which gateway processed an order, and
  `Order.stripe_checkout_session_id` is stored *before* the redirect, so a
  payment can still be reconciled if the webhook never arrives.
- The webhook is idempotent. Stripe delivers events at least once and
  retries anything that isn't a 2xx, so the order row is locked before its
  status is read and a duplicate delivery is a quiet no-op.
- `checkout.session.completed` is not treated as "paid". With delayed
  notification methods (ACH and friends) the session completes while the
  payment is still processing, so the real state is resolved from the
  session *and* the PaymentIntent, and the async success/failure events are
  handled rather than ignored.
- Fulfilment enrolls the learner before marking the order fulfilled. If it
  ran the other way round, anything failing afterwards would leave a paying
  learner unenrolled, and Stripe's retry would short-circuit on the
  already-fulfilled order and never repair it.
- An interstitial polls for fulfilment, since the learner can arrive back
  before the webhook lands.

Receipts are unchanged: Stripe data is translated into the same `req_*`
keys the receipt page and email already read, with the raw Stripe objects
kept alongside so nothing is lost.

Two dependency notes. `setuptools` is capped at <=81 because the
CyberSource SDK that `payment_gateway` depends on needs `pkg_resources`,
and that SDK is only Python 3.13-compatible from 0.0.78 (it backfills
`imghdr`). Three warning filters cover the deprecations it emits. We only
use the Stripe backend, so it is worth asking upstream to make the
CyberSource SDK an optional extra.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(b2b_ecommerce): add a kill switch for bulk purchasing

Bulk purchasing runs on CyberSource Secure Acceptance, which is being
retired. This adds a PostHog flag so it can be switched off without a
deploy, rather than leaving a broken payment page if the timing doesn't
work out.

The flag defaults to on, so nothing changes until someone turns it off.
When off, `/api/b2b/checkout/` refuses new orders with a 503 and a message
the page can show, and the bulk page renders a notice instead of the
purchase form.

Switching it off deliberately does not touch
`/api/b2b/orders/<hash>/codes/` or `/status/`: anyone who already paid must
still be able to collect their enrollment codes. There's a test for that
specifically, since it's the part that would hurt if it regressed.

The frontend check is `=== false` rather than a falsy check, so a missing
setting means "available" and matches the server-side default. Otherwise
any page that didn't get the settings context would silently hide bulk
purchasing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ecommerce): keep GTM purchase tracking working under Stripe

The checkout page tags its GTM purchase event with `reference_number`,
read off the payload the checkout API returns. CyberSource's payload
carries that key; a Stripe session calls the same thing
`client_reference_id`, so the event would have gone out untagged and the
purchase would have been unattributable in analytics.

Adds the key the frontend already reads, and covers the push with JS tests
-- there were none, so nothing guarded this.

Deliberately not filling in `transaction_id`, `transaction_total`,
`product_type` and `courseware_id`. Those are already null for CyberSource
purchases today (only the $0 path builds them), so populating them for
Stripe alone would make the two cohorts incomparable in analytics during
exactly the period we're using to judge the rollout. Worth fixing for both
gateways together, separately.

One thing to watch during rollout: when GTM is configured, the redirect to
the payment page happens inside the GTM `eventCallback`. That path
previously only affected $0 orders; every Stripe purchase now takes it, so
a blocked or slow GTM container has a wider blast radius. The 2s
`eventTimeout` is the existing safety net, and the tests now pin this
behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(ecommerce): add a command to resolve stuck Stripe orders

Under Stripe, an order is fulfilled by a webhook, and that webhook can fail
to arrive: the app might be down or mid-deploy, the endpoint misconfigured,
the signing secret rotated, or Stripe's retries exhausted after three days.
When that happens the learner has paid and the order sits in `created`
forever, because nothing else retries.

There was no way out of that except editing the order by hand after digging
through the Stripe dashboard. This command asks Stripe what actually
happened and makes our records match:

    ./manage.py resolve_pending_stripe_orders --all
    ./manage.py resolve_pending_stripe_orders --order xpro-b2c-dev-123 --commit

It reports and changes nothing unless `--commit` is passed. Fulfilling an
order enrolls the learner and emails them a receipt, and `--all` is the
easiest thing to type, so the safe option is the default one. Note this
differs from mitxonline's `resolve_pending_order`, which acts immediately.

Paid sessions are fulfilled, cancelled or failed ones mark the order
failed, and payments still in flight are deliberately left alone -- a
delayed payment method that hasn't cleared isn't stuck, and Stripe will
still send `async_payment_succeeded` for it.

It reuses the webhook's own code, so a resolved order goes through exactly
the same path as a normal one, and `fulfill_stripe_order` is idempotent so
running the command twice is harmless.

This is possible because the checkout session ID is recorded on the order
before the learner is redirected; without it there would be nothing to ask
Stripe about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ecommerce): address review findings on the Stripe flow

Twelve issues raised in review.

**The row lock stopped covering fulfillment.** When enrollment was moved
ahead of the status change, the `atomic()` block ended before both, so a
duplicate webhook delivery could acquire the lock afterwards, still see
CREATED, and enroll and email a second time. The lock now spans enrollment
and the transition. The receipt email moved outside the transaction, since
sending mail can't be rolled back.

**A learner could be charged twice.** An unpaid order is reused across
checkout attempts, and each attempt created a new Stripe session while the
previous one stayed payable. The superseded session is now expired, and the
order's session ID is updated first so the resulting `expired` event can't
fail the live order.

**Failed payments stored a receipt saying ACCEPT.** Receipts are written for
failures too -- CyberSource does the same, it's the audit trail -- so the
decision now reflects the real outcome: ACCEPT, CANCEL or DECLINE.

**Card brands were dropping off receipts.** `OrderReceiptSerializer` looks
the card type up in `CYBERSOURCE_CARD_TYPES` by numeric code, so Stripe's
`visa` rendered no brand. Translated at write time, with a test that runs
the real serializer.

**Orders could be labeled with the wrong gateway.** Only the Stripe branch
recorded `gateway_type`, so an order started on Stripe and finished on
CyberSource kept the Stripe label. Both branches now record it.

**Polling could never start.** It was driven by entity updates, so if the
first status request failed there was nothing to react to and a paying
learner sat on the page forever. It now runs on a timer that survives
request errors.

**The interstitial claimed too much, then went quiet.** It told every
learner "your payment went through" -- including bank transfers, where
confirmation takes days and can still fail -- and left that message up
forever once polling stopped. It now says we're confirming payment, and
explains itself when it gives up. The failure text no longer assumes a card
was used.

**`--order` and `--all` together silently ran everything.** The command now
rejects both-or-neither.

**The webhook boundary had no request-level tests.** Added: invalid
signature, each handled event class, ignored events, and malformed payloads.

**A passed-in coupon version was re-queried.** `None` is a legitimate value
meaning "no coupon", so `or` defeated the parameter and hit the database
again. Uses a sentinel now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ecommerce): close a session-swap race and two idempotency gaps

Findings from an architecture pass over the finished branch, two of them
introduced by the earlier review fixes.

**The session swap raced.** The "previous" session ID was read off the
in-memory order instance before a blind update. Two concurrent checkouts
both read the same previous session, each expired it, and both new sessions
stayed payable -- the exact double-charge window the expiry was meant to
close. The swap now happens under a row lock, so the second checkout sees
the first's session as previous and retires it, leaving one live session.

**Redelivered failure events piled up receipts.** Only FULFILLED
short-circuited the webhook handler, but FAILED is just as terminal (a
failed order is never reused for checkout), so each redelivery of a failure
event wrote a fresh receipt. Both states now short-circuit.

**Superseded sessions logged at ERROR.** Expiring a superseded session
fires checkout.session.expired, which deliberately matches no order -- the
ID was swapped first so the event can't fail the live order. That's the
expected outcome of a normal flow, so it now logs at info instead of
paging anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ecommerce): don't fail an empty resolve run

`resolve_pending_stripe_orders --all` exited non-zero when nothing was
stuck. Nothing stuck is the healthy state, and failing on it makes the
command unusable on a schedule. It now reports and exits 0.

A named order that doesn't exist still errors, since that's a typo rather
than a state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(ecommerce): describe gateway behaviour without dating it

The comments narrated the migration rather than the mechanism: "rolled
out gradually", "remains the fallback for the whole migration",
"CyberSource, which is being retired". All of that goes stale the moment
the rollout finishes, and nobody will come back to update it.

get_gateway_type_for_user's docstring was also already wrong. It said
anyone not covered by the flag stays on CyberSource, but the function
returns Stripe whenever ECOMMERCE_DEFAULT_PAYMENT_GATEWAY names it. It
now describes the rule the code actually implements: the flag decides per
user, and the setting decides for everyone else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(ecommerce): name the Stripe cart helpers for Stripe

_generate_gateway_cart_items and _generate_gateway_merchant_fields were
named after the type they return -- mitol.payment_gateway's CartItem --
but only start_stripe_checkout calls them, and both bake in Stripe's
inclusive-tax behaviour. The neutral name promised a generality that
isn't there, and it broke the convention set by the CyberSource helpers
next to them. The GatewayCartItem alias stays: that one really is the
library's type.

Both also opened by looking the coupon version up unconditionally, one
line above the guard meant to skip that when the caller already passed
it, so the sentinel never fired and the lookup ran three times per
checkout. Removed; for an order with a coupon that is 5 fewer queries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ecommerce): address review feedback on the Stripe flow

- Drop ECOMMERCE_DEFAULT_PAYMENT_GATEWAY from gateway selection. The PostHog
  flag already falls back to settings.FEATURES when PostHog has nothing to say,
  so a second control only made it possible for the two to disagree -- which is
  exactly what happened in local testing, where the setting silently sent every
  checkout to Stripe regardless of the flag.

- Reuse one merchant-defined-data helper for both gateways instead of keeping a
  byte-identical copy in each, and rename it now that it isn't Stripe-specific.

- Put money rounding in one place (round_to_cents, stripe_minor_units_to_amount)
  rather than restating the quantize at six call sites.

- Fix the purchaser name arriving as "None" on Stripe receipts: the Stripe
  receipt data never wrote req_bill_to_forename/surname, so the serializer left
  the name at its default. Stripe gives one full name, so split it. The
  serializer now also joins only the parts it has -- a single-word name would
  have rendered "John None" on CyberSource receipts too.

- Poll the checkout result every 3s rather than 2s, cutting each in-flight
  checkout from 30 to 20 requests a minute.

- Match the site's usual "contact customer support" wording on the B2B page.

- Cover the B2B kill switch with the flag both on and off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(ecommerce): resolve stuck CyberSource orders too

The stuck-order problem isn't Stripe-specific. CyberSource confirms a payment
by a server-to-server POST, and if that never lands the learner has paid and
the order sits in `created` forever. Stripe at least retries its webhook for a
few days; CyberSource documents no retry for the merchant POST, so one failed
delivery is likely permanent. The hazard predates Stripe -- we just had no way
to see it.

Renamed to resolve_pending_orders and split by gateway_type. The CyberSource
side turned out to need no new mapping code: get_transaction_details returns a
payload in the same shape the merchant POST would have delivered, so resolving
an order is literally replaying the reply we never received through the
existing fulfill_order().

Orders with no transaction at CyberSource are left alone. That's an abandoned
checkout, which looks identical to a stuck order in our own database -- being
able to tell the two apart is the reason this has to ask the gateway rather
than just query us.

Calls find_transactions and get_transaction_details directly rather than
PaymentGateway.find_and_get_transactions, which iterates results.items() and
then indexes the dict with the resulting tuple, so it raises KeyError as soon
as a search matches anything. Verified against real sandbox transactions. There
is a test asserting we don't call it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: shift the drf-lint baseline for the moved serializer lines

The receipt name fix added one line to OrderReceiptSerializer, so the six
pre-existing ORM findings below it moved down by one and no longer matched the
baseline, which records violations by line number. Regenerated: same 52
entries, six of them renumbered. No new findings, and nothing suppressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ecommerce): read the CyberSource outcome from reason_code, not decision

The payment gateway library builds its transaction payload from the
Transaction Details API and writes the numeric reason code into `decision` --
"100" on success -- rather than the word a Secure Acceptance reply carries.
fulfill_order compares `decision` to "ACCEPT", so a genuinely paid order came
through as "100" != "ACCEPT" and would have been marked FAILED with the learner
never enrolled. The tests didn't catch it because they used a tidy fake
{"decision": "ACCEPT"} payload.

Derive the word from reason_code, as MITx Online does, and keep the raw code
alongside. The tests now use payloads shaped the way the library actually
returns them, so this can't regress silently. Verified against the two real
stuck sandbox orders: both now report "would fulfill".

Also drops an unused logging import from the command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(ecommerce): cover the receipt name fix; tidy two review leftovers

- Cover the Stripe name split (full name, multi-part surname, single name,
  padding, missing) and the serializer joining only the parts it has -- the
  "John None" case -- neither of which had a test.
- Update a serializer test whose expectation still encoded the old formula.
- Compute the checkout cancel URL once instead of in both gateway branches.
- Use the same "contact customer support" wording in the B2B API error that
  the page now uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(ecommerce): manage the Stripe webhook secret from Django admin

The payment gateway verifies every incoming Stripe webhook against a signing
secret held in the database. Without that row webhooks are rejected with a 401,
so learners are charged and their orders sit in `created` forever -- which makes
this a prerequisite for enabling Stripe anywhere, not a convenience.

The library ships no admin for these models and nothing in the org uses them: a
check against MITx Online production came back with the app installed, the table
migrated, and zero rows. So until now the only way to set this up on a deployed
environment was pasting ORM code into a shell on a running pod.

The route is an inline, because a secret without a route is never consulted and
was easy to forget as a second object. The list masks the secret rather than
printing it in full, and the queryset uses all_objects so a rotated-out secret
stays visible and can be re-activated -- the model's default manager hides
anything inactive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: describe setting up Stripe locally and in production

Asked for in review: developers had no way to get Stripe running locally without
asking someone who had already done it.

Covers getting a test account and key, the feature flag, forwarding webhooks with
the Stripe CLI, storing the signing secret, placing a test payment, and
recovering an order whose webhook never arrived. Production is a separate
section, since there is no `stripe listen` there and the endpoint is registered
in Stripe's dashboard instead.

Every step was run rather than written from memory. Two things that came out of
doing that: the flag works through `.env` whether or not PostHog is configured,
and the running server does not reload mounted code, so a change can sit on disk
while the app still serves the old version -- which cost us twice while
verifying, and is now called out in the doc.

Also links the existing digital credentials doc, which nothing referenced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: mark the placeholder webhook secret for detect-secrets

The setup snippet assigns to `secret_name` and `webhook_secret`, which the
keyword scanner flags regardless of the value being a literal `whsec_...`
placeholder. Marked both with the allowlist pragma the repo already uses
elsewhere, rather than adding baseline entries, since those are keyed by line
and would drift the next time this doc is edited.

The `secret_name` hint moved into the surrounding prose so the code block does
not carry two comments on one line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ecommerce): treat a refunded order as terminal, and receipt failed payments

Two gaps raised in review.

A refunded order was not in the terminal set, so a late redelivery of
checkout.session.completed would re-fulfil it and re-enroll a learner whose
money has already been returned. Added alongside FULFILLED and FAILED.

cancel_stripe_order marked the order failed without recording anything, while
the CyberSource path stores a receipt whatever the decision. The audit trail
should not depend on which gateway took the order, so a failed Stripe payment
now writes one too, with the decision reflecting what actually happened rather
than a blanket ACCEPT. The session is read back only after confirming the order
is still CREATED, so a superseded session costs no API call, and the fetch
happens outside the transaction to keep a network round trip out of the row
lock.

Verified against real Stripe events, not only in tests: expiring a genuine
checkout session left the order failed with one receipt and decision CANCEL,
and replaying a paid event at a refunded order changed nothing and logged the
skip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ecommerce): make order resolution survive a bad order, and accept order IDs

Review feedback on resolve_pending_orders.

The transaction choice was an order-dependent bug. A reused order can carry
several CyberSource transactions -- a decline followed by a successful retry is
exactly what this command exists to rescue -- and the payload was overwritten
per result with no preference, so the outcome depended on the iteration order
of a search that promises none. If the decline came back last, a genuinely paid
order was marked FAILED. Now an accepted transaction wins and the most recent
breaks a tie.

Nothing was isolated per order, so one failure abandoned the rest of an --all
run. Each order is now wrapped, and the batch CyberSource lookup is guarded
too: running this for real surfaced a raw "MerchantID is mandatory" traceback
from that lookup, which sits before the per-order loop and would have taken the
Stripe orders down with it. The command still exits non-zero afterwards so a
scheduled run notices.

--order now takes a plain order ID as well as a reference number. The two can
never be confused: a reference number always carries letters and hyphens, an ID
is always digits. A malformed reference raised ParseException from inside the
lookup and escaped as a traceback; it now explains itself.

--order and --all became an argparse mutually exclusive group, which replaces
the hand-rolled guard and documents the constraint in the usage line. Verified
that call_command still rejects both neither and both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ecommerce): await the real request when polling for checkout status

connectRequest's forceRequest() dispatches without returning the promise, so
awaiting it resolved immediately and the try/catch around it could never fire.
Polls therefore overlapped, and since the reducer is last-write-wins a slow
response landing after a newer one would overwrite it, delaying detection of
the final status by a poll cycle.

Dispatching requestAsync ourselves gives a real promise, so each poll waits for
its own round trip. Matches how ProductSelector already dispatches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: update readme

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
mitol-drf-lint 2026.8.28 added cross-file analysis and rules
ORM003-ORM009; the hook pins no version, so pre-commit.ci picked the new
release up when its cached env rebuilt. The checked-in baseline only
covered ORM001-ORM002, leaving 110 pre-existing N+1 risks unsuppressed
and every build in the repo red.

Regenerate the baseline over all 8 tracked serializers.py so existing
debt is grandfathered and recorded, while new violations still fail the
hook. Existing ORM001/ORM002 entries are preserved; nothing is dropped.

Also ignore .drf_lint_cache.json, the cross-file index cache the new
version writes at the repo root.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.16.5 → v0.16.6](astral-sh/ruff-pre-commit@v0.16.5...v0.16.6)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Comment thread ecommerce/views.py
Comment on lines +374 to +376
fulfill_stripe_order(checkout_session_id)
elif event.type in STRIPE_CANCEL_EVENTS:
cancel_stripe_order(checkout_session_id, reason=event.type)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The StripeWebhookView.post method lacks exception handling for fulfill_stripe_order, which can lead to an infinite retry loop from Stripe if a ParseException occurs.
Severity: HIGH

Suggested Fix

Wrap the calls to fulfill_stripe_order and cancel_stripe_order within the StripeWebhookView.post method in a try...except block. Catch ParseException and other potential exceptions, log the error, and return a 2xx response to Stripe to acknowledge receipt and prevent retries on unrecoverable errors.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: ecommerce/views.py#L374-L376

Potential issue: The `StripeWebhookView.post` method calls `fulfill_stripe_order`
without handling potential exceptions. The `fulfill_stripe_order` function can raise a
`ParseException` if it receives a malformed `client_reference_id` from Stripe, for
instance due to an environment misconfiguration. An unhandled exception in the webhook
handler will cause an HTTP 500 response. As Stripe retries any webhook that doesn't
return a 2xx status, this will trigger an indefinite retry loop, creating unnecessary
server load and log noise.

Also affects:

  • ecommerce/api.py:844~852

Did we get this right? 👍 / 👎 to inform future reviews.

Comment on lines +132 to +142
Q(stripe_checkout_session_id__isnull=True)
| Q(stripe_checkout_session_id="")
)
orders = Order.objects.filter(status=Order.CREATED).exclude(
stripe_without_session
)

if process_all:
return orders

return orders.filter(id=self._resolve_identifier(reference_number).id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The resolve_pending_orders command misleadingly reports "No stuck orders found" when a specified Stripe order is excluded for lacking a session ID.
Severity: MEDIUM

Suggested Fix

After resolving the order via _resolve_identifier, check if that order was excluded from the main orders queryset. If it was excluded, print a specific error message informing the operator that the order was found but cannot be processed because it is a Stripe order missing a stripe_checkout_session_id.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: ecommerce/management/commands/resolve_pending_orders.py#L131-L142

Potential issue: When the `resolve_pending_orders` management command is run with the
`--order` flag for a specific Stripe order that lacks a `stripe_checkout_session_id`,
the command provides a misleading message. The order is first excluded from the initial
queryset because it has no session ID. When the command then tries to filter for the
specified order, it finds no match in the pre-filtered set and incorrectly reports "No
stuck orders found". This is confusing for an operator who knows the order exists and is
stuck, as it hides the actual reason it cannot be processed (missing session ID).

Did we get this right? 👍 / 👎 to inform future reviews.

@odlbot
odlbot merged commit f54c221 into release Oct 5, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants